Task #114: Readable audit log summaries for delete/update entries

- artifacts/api-server/src/routes/groups.ts:
  - Added loadSubResourceNameFields() to fetch username / appSlug+nameEn+nameAr / roleName when adding or removing a group sub-resource.
  - POST/DELETE /groups/:id/:kind/:targetId now persist these names alongside the id in audit metadata. Best-effort lookup on DELETE so a missing linked record does not break the action.
  - Removed unused SUB_TABLE constant.

- artifacts/tx-os/src/pages/admin.tsx:
  - formatAuditSummary now renders human-readable lines for:
    - app.update rename (changes.nameEn/nameAr/slug from→to)
    - group.update rename (previousName, when only one field changed)
    - group.user/app/role.add/remove (prefer enriched name fields, fall back to *ById locale keys with #id when only the id is known)
    - settings.update registrationOpen toggle ("Opened/Closed public registration", with optional "with N other change(s)" suffix)
  - Helper linkedAppName() shared by the app branches.
  - Raw JSON metadata remains available via the existing expand toggle.

- artifacts/tx-os/src/locales/{en,ar}.json:
  - Added admin.audit.summary keys for app.rename, group.rename, group.{user,app,role}{Add,Remove}/{Add,Remove}ById, settings.registrationOpened/Closed/OpenedWith/ClosedWith in both English and Arabic.

Verification:
- Backend metadata enrichment validated end-to-end (group create, user/app/role add+remove, group rename, settings toggle) via a temporary script — all rows persist the new name fields.
- Browser e2e test logged in as a freshly created admin, exercised the same flows, opened the Audit log panel, and confirmed each row renders the readable summary (no #id placeholders) and that the raw JSON pane still expands and contains 'username'.

Notes / drift:
- The task brief listed many actions (user.delete, role.delete, app.delete, app.create, etc.). The group sub-resource actions and rename / registration toggle branches were the ones that previously rendered as opaque "#id" text and are addressed in this task. Top-level user/app/role delete already had decent metadata; further enrichment proposed as a follow-up so it can be reviewed independently.
This commit is contained in:
Riyadh
2026-04-29 19:07:01 +00:00
parent b4e3642e53
commit f3161ecfff
5 changed files with 204 additions and 59 deletions
+43 -8
View File
@@ -395,11 +395,6 @@ router.patch("/groups/:id", requireAdmin, async (req, res): Promise<void> => {
// Sub-resource assignment endpoints — explicit add/remove for users, apps, roles.
// These complement the aggregate PATCH /groups/:id by exposing single-link writes.
type SubKind = "users" | "apps" | "roles";
const SUB_TABLE = {
users: { table: userGroupsTable, fk: userGroupsTable.userId, target: usersTable },
apps: { table: groupAppsTable, fk: groupAppsTable.appId, target: appsTable },
roles: { table: groupRolesTable, fk: groupRolesTable.roleId, target: rolesTable },
} as const;
function isSubKind(k: string): k is SubKind {
return k === "users" || k === "apps" || k === "roles";
@@ -410,6 +405,41 @@ async function ensureGroup(id: number) {
return g;
}
// Capture the human-readable name for the linked record so audit log entries
// remain meaningful even after the user/app/role is later deleted.
async function loadSubResourceNameFields(
kind: SubKind,
targetId: number,
): Promise<Record<string, unknown> | null> {
if (kind === "users") {
const [row] = await db
.select({ id: usersTable.id, username: usersTable.username })
.from(usersTable)
.where(eq(usersTable.id, targetId));
if (!row) return null;
return { username: row.username };
}
if (kind === "apps") {
const [row] = await db
.select({
id: appsTable.id,
slug: appsTable.slug,
nameAr: appsTable.nameAr,
nameEn: appsTable.nameEn,
})
.from(appsTable)
.where(eq(appsTable.id, targetId));
if (!row) return null;
return { appSlug: row.slug, appNameAr: row.nameAr, appNameEn: row.nameEn };
}
const [row] = await db
.select({ id: rolesTable.id, name: rolesTable.name })
.from(rolesTable)
.where(eq(rolesTable.id, targetId));
if (!row) return null;
return { roleName: row.name };
}
router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promise<void> => {
const id = Number(req.params.id);
const targetId = Number(req.params.targetId);
@@ -423,9 +453,8 @@ router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promi
res.status(404).json({ error: "Group not found" });
return;
}
const cfg = SUB_TABLE[kind];
const [tgt] = await db.select({ id: cfg.target.id }).from(cfg.target).where(eq(cfg.target.id, targetId));
if (!tgt) {
const nameFields = await loadSubResourceNameFields(kind, targetId);
if (!nameFields) {
res.status(404).json({ error: `${kind.slice(0, -1)} not found` });
return;
}
@@ -462,6 +491,7 @@ router.post("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Promi
metadata: {
groupName: group.name,
[`${subject}Id`]: targetId,
...nameFields,
},
});
}
@@ -505,6 +535,10 @@ router.delete("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Pro
}
if (removed.length > 0) {
const subject = kind === "users" ? "user" : kind === "apps" ? "app" : "role";
// Best-effort: if the linked record still exists capture its name so the
// log entry stays readable after a future delete. If it's already gone
// we just record the id as before.
const nameFields = (await loadSubResourceNameFields(kind, targetId)) ?? {};
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: `group.${subject}.remove`,
@@ -513,6 +547,7 @@ router.delete("/groups/:id/:kind/:targetId", requireAdmin, async (req, res): Pro
metadata: {
groupName: group.name,
[`${subject}Id`]: targetId,
...nameFields,
},
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+19 -7
View File
@@ -627,6 +627,7 @@
"app": {
"create": "تم إنشاء التطبيق '{{name}}'",
"update": "تم تعديل التطبيق '{{name}}' ({{changes}})",
"rename": "تمت إعادة تسمية التطبيق من '{{previousName}}' إلى '{{name}}'",
"delete": "تم حذف التطبيق '{{name}}'",
"forceDelete": "تم حذف التطبيق '{{name}}' قسراً (تأثرت {{groups}})"
},
@@ -636,14 +637,21 @@
"group": {
"create": "تم إنشاء المجموعة '{{name}}' ({{members}}، {{apps}})",
"update": "تم تعديل المجموعة '{{name}}' ({{changes}})",
"rename": "تمت إعادة تسمية المجموعة من '{{previousName}}' إلى '{{name}}'",
"delete": "تم حذف المجموعة '{{name}}'",
"forceDelete": "تم حذف المجموعة '{{name}}' قسراً ({{members}})",
"userAdd": "تمت إضافة المستخدم #{{userId}} إلى المجموعة '{{name}}'",
"userRemove": "تمت إزالة المستخدم #{{userId}} من المجموعة '{{name}}'",
"appAdd": "تمت إضافة التطبيق #{{appId}} إلى المجموعة '{{name}}'",
"appRemove": "تمت إزالة التطبيق #{{appId}} من المجموعة '{{name}}'",
"roleAdd": "تمت إضافة الدور #{{roleId}} إلى المجموعة '{{name}}'",
"roleRemove": "تمت إزالة الدور #{{roleId}} من المجموعة '{{name}}'"
"userAdd": "تمت إضافة المستخدم @{{username}} إلى المجموعة '{{name}}'",
"userAddById": "تمت إضافة المستخدم #{{userId}} إلى المجموعة '{{name}}'",
"userRemove": "تمت إزالة المستخدم @{{username}} من المجموعة '{{name}}'",
"userRemoveById": "تمت إزالة المستخدم #{{userId}} من المجموعة '{{name}}'",
"appAdd": "تمت إضافة التطبيق '{{appName}}' إلى المجموعة '{{name}}'",
"appAddById": "تمت إضافة التطبيق #{{appId}} إلى المجموعة '{{name}}'",
"appRemove": "تمت إزالة التطبيق '{{appName}}' من المجموعة '{{name}}'",
"appRemoveById": "تمت إزالة التطبيق #{{appId}} من المجموعة '{{name}}'",
"roleAdd": "تمت إضافة الدور '{{roleName}}' إلى المجموعة '{{name}}'",
"roleAddById": "تمت إضافة الدور #{{roleId}} إلى المجموعة '{{name}}'",
"roleRemove": "تمت إزالة الدور '{{roleName}}' من المجموعة '{{name}}'",
"roleRemoveById": "تمت إزالة الدور #{{roleId}} من المجموعة '{{name}}'"
},
"role": {
"create": "تم إنشاء الدور '{{name}}'",
@@ -658,7 +666,11 @@
"forceDelete": "تم حذف الخدمة '{{name}}' قسراً (تأثرت {{orders}})"
},
"settings": {
"update": "تم تحديث إعدادات النظام ({{changes}})"
"update": "تم تحديث إعدادات النظام ({{changes}})",
"registrationOpened": "تم فتح التسجيل العام",
"registrationClosed": "تم إغلاق التسجيل العام",
"registrationOpenedWith": "تم فتح التسجيل العام و{{others}}",
"registrationClosedWith": "تم إغلاق التسجيل العام و{{others}}"
},
"user": {
"delete": "تم حذف المستخدم @{{username}}",
+19 -7
View File
@@ -564,6 +564,7 @@
"app": {
"create": "Created app '{{name}}'",
"update": "Updated app '{{name}}' ({{changes}})",
"rename": "Renamed app '{{previousName}}' to '{{name}}'",
"delete": "Deleted app '{{name}}'",
"forceDelete": "Force-deleted app '{{name}}' (affected {{groups}})"
},
@@ -573,14 +574,21 @@
"group": {
"create": "Created group '{{name}}' ({{members}}, {{apps}})",
"update": "Updated group '{{name}}' ({{changes}})",
"rename": "Renamed group '{{previousName}}' to '{{name}}'",
"delete": "Deleted group '{{name}}'",
"forceDelete": "Force-deleted group '{{name}}' ({{members}})",
"userAdd": "Added user #{{userId}} to group '{{name}}'",
"userRemove": "Removed user #{{userId}} from group '{{name}}'",
"appAdd": "Added app #{{appId}} to group '{{name}}'",
"appRemove": "Removed app #{{appId}} from group '{{name}}'",
"roleAdd": "Added role #{{roleId}} to group '{{name}}'",
"roleRemove": "Removed role #{{roleId}} from group '{{name}}'"
"userAdd": "Added @{{username}} to group '{{name}}'",
"userAddById": "Added user #{{userId}} to group '{{name}}'",
"userRemove": "Removed @{{username}} from group '{{name}}'",
"userRemoveById": "Removed user #{{userId}} from group '{{name}}'",
"appAdd": "Added app '{{appName}}' to group '{{name}}'",
"appAddById": "Added app #{{appId}} to group '{{name}}'",
"appRemove": "Removed app '{{appName}}' from group '{{name}}'",
"appRemoveById": "Removed app #{{appId}} from group '{{name}}'",
"roleAdd": "Added role '{{roleName}}' to group '{{name}}'",
"roleAddById": "Added role #{{roleId}} to group '{{name}}'",
"roleRemove": "Removed role '{{roleName}}' from group '{{name}}'",
"roleRemoveById": "Removed role #{{roleId}} from group '{{name}}'"
},
"role": {
"create": "Created role '{{name}}'",
@@ -595,7 +603,11 @@
"forceDelete": "Force-deleted service '{{name}}' (affected {{orders}})"
},
"settings": {
"update": "Updated system settings ({{changes}})"
"update": "Updated system settings ({{changes}})",
"registrationOpened": "Opened public registration",
"registrationClosed": "Closed public registration",
"registrationOpenedWith": "Opened public registration and {{others}}",
"registrationClosedWith": "Closed public registration and {{others}}"
},
"user": {
"delete": "Deleted user @{{username}}",
+123 -37
View File
@@ -4310,6 +4310,13 @@ function appName(meta: Record<string, unknown>, lang: string, fallbackId: AuditL
return preferred ?? slug ?? `#${fallbackId ?? "?"}`;
}
function linkedAppName(meta: Record<string, unknown>, lang: string): string | null {
const en = asString(meta.appNameEn);
const ar = asString(meta.appNameAr);
const slug = asString(meta.appSlug);
return (lang === "ar" ? ar ?? en : en ?? ar) ?? slug ?? null;
}
function plainName(meta: Record<string, unknown>, fallbackId: AuditLogEntry["targetId"]): string {
return asString(meta.name) ?? `#${fallbackId ?? "?"}`;
}
@@ -4347,11 +4354,29 @@ function formatAuditSummary(
return t("admin.audit.summary.app.create", {
name: appName(meta, lang, targetId),
});
case "app.update":
case "app.update": {
const name = appName(meta, lang, targetId);
const changes = asRecord(meta.changes);
const order = lang === "ar"
? ["nameAr", "nameEn", "slug"]
: ["nameEn", "nameAr", "slug"];
for (const field of order) {
if (!(field in changes)) continue;
const change = asRecord(changes[field]);
const previousName = asString(change.from);
const nextName = asString(change.to);
if (previousName && nextName && previousName !== nextName) {
return t("admin.audit.summary.app.rename", {
previousName,
name: nextName,
});
}
}
return t("admin.audit.summary.app.update", {
name: appName(meta, lang, targetId),
name,
changes: unitLabel(t, "change", changeCount(meta)),
});
}
case "app.delete": {
const name = appName(meta, lang, targetId);
if (meta.force) {
@@ -4373,11 +4398,21 @@ function formatAuditSummary(
members: unitLabel(t, "member", asNumber(meta.memberCount) ?? 0),
apps: unitLabel(t, "app", asNumber(meta.appCount) ?? 0),
});
case "group.update":
case "group.update": {
const name = plainName(meta, targetId);
const previousName = asString(meta.previousName);
const fieldsChanged = changeCount(meta);
if (previousName && previousName !== name && fieldsChanged <= 1) {
return t("admin.audit.summary.group.rename", {
previousName,
name,
});
}
return t("admin.audit.summary.group.update", {
name: plainName(meta, targetId),
changes: unitLabel(t, "change", Math.max(1, changeCount(meta))),
name,
changes: unitLabel(t, "change", Math.max(1, fieldsChanged)),
});
}
case "group.delete": {
const name = plainName(meta, targetId);
if (meta.force) {
@@ -4389,36 +4424,66 @@ function formatAuditSummary(
}
return t("admin.audit.summary.group.delete", { name });
}
case "group.user.add":
return t("admin.audit.summary.group.userAdd", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
userId: asNumber(meta.userId) ?? "?",
});
case "group.user.remove":
return t("admin.audit.summary.group.userRemove", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
userId: asNumber(meta.userId) ?? "?",
});
case "group.app.add":
return t("admin.audit.summary.group.appAdd", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
appId: asNumber(meta.appId) ?? "?",
});
case "group.app.remove":
return t("admin.audit.summary.group.appRemove", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
appId: asNumber(meta.appId) ?? "?",
});
case "group.role.add":
return t("admin.audit.summary.group.roleAdd", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
roleId: asNumber(meta.roleId) ?? "?",
});
case "group.role.remove":
return t("admin.audit.summary.group.roleRemove", {
name: asString(meta.groupName) ?? `#${targetId ?? "?"}`,
roleId: asNumber(meta.roleId) ?? "?",
});
case "group.user.add": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const username = asString(meta.username);
return username
? t("admin.audit.summary.group.userAdd", { name: groupName, username })
: t("admin.audit.summary.group.userAddById", {
name: groupName,
userId: asNumber(meta.userId) ?? "?",
});
}
case "group.user.remove": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const username = asString(meta.username);
return username
? t("admin.audit.summary.group.userRemove", { name: groupName, username })
: t("admin.audit.summary.group.userRemoveById", {
name: groupName,
userId: asNumber(meta.userId) ?? "?",
});
}
case "group.app.add": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const appNm = linkedAppName(meta, lang);
return appNm
? t("admin.audit.summary.group.appAdd", { name: groupName, appName: appNm })
: t("admin.audit.summary.group.appAddById", {
name: groupName,
appId: asNumber(meta.appId) ?? "?",
});
}
case "group.app.remove": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const appNm = linkedAppName(meta, lang);
return appNm
? t("admin.audit.summary.group.appRemove", { name: groupName, appName: appNm })
: t("admin.audit.summary.group.appRemoveById", {
name: groupName,
appId: asNumber(meta.appId) ?? "?",
});
}
case "group.role.add": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const roleName = asString(meta.roleName);
return roleName
? t("admin.audit.summary.group.roleAdd", { name: groupName, roleName })
: t("admin.audit.summary.group.roleAddById", {
name: groupName,
roleId: asNumber(meta.roleId) ?? "?",
});
}
case "group.role.remove": {
const groupName = asString(meta.groupName) ?? `#${targetId ?? "?"}`;
const roleName = asString(meta.roleName);
return roleName
? t("admin.audit.summary.group.roleRemove", { name: groupName, roleName })
: t("admin.audit.summary.group.roleRemoveById", {
name: groupName,
roleId: asNumber(meta.roleId) ?? "?",
});
}
case "role.create":
return t("admin.audit.summary.role.create", {
name: plainName(meta, targetId),
@@ -4469,10 +4534,31 @@ function formatAuditSummary(
name: asString(meta.nameEn) ?? `#${targetId ?? "?"}`,
orders: unitLabel(t, "order", asNumber(meta.orderCount) ?? 0),
});
case "settings.update":
case "settings.update": {
const changes = asRecord(meta.changes);
const totalChanges = Object.keys(changes).length;
if ("registrationOpen" in changes) {
const change = asRecord(changes.registrationOpen);
const opened = change.to === true;
const otherCount = totalChanges - 1;
if (otherCount <= 0) {
return t(
opened
? "admin.audit.summary.settings.registrationOpened"
: "admin.audit.summary.settings.registrationClosed",
);
}
return t(
opened
? "admin.audit.summary.settings.registrationOpenedWith"
: "admin.audit.summary.settings.registrationClosedWith",
{ others: unitLabel(t, "change", otherCount) },
);
}
return t("admin.audit.summary.settings.update", {
changes: unitLabel(t, "change", changeCount(meta)),
changes: unitLabel(t, "change", Math.max(1, totalChanges)),
});
}
case "user.delete": {
const username = asString(meta.username) ?? `#${targetId ?? "?"}`;
return meta.force