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:
@@ -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 |
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user