diff --git a/.replit b/.replit index dc867db8..88419a58 100644 --- a/.replit +++ b/.replit @@ -29,7 +29,7 @@ outputType = "console" [[workflows.workflow.tasks]] task = "shell.exec" -args = "pnpm --filter @workspace/api-server test:wait && pnpm --filter @workspace/api-server test && pnpm --filter @workspace/tx-os test:e2e" +args = "pnpm --filter @workspace/api-server test:wait && pnpm --filter @workspace/tx-os test && pnpm --filter @workspace/api-server test && pnpm --filter @workspace/tx-os test:e2e" [agent] stack = "PNPM_WORKSPACE" diff --git a/artifacts/api-server/tests/group-audit-metadata.test.mjs b/artifacts/api-server/tests/group-audit-metadata.test.mjs new file mode 100644 index 00000000..945c9ba5 --- /dev/null +++ b/artifacts/api-server/tests/group-audit-metadata.test.mjs @@ -0,0 +1,221 @@ +// API-level coverage for the enriched audit_logs metadata that the group +// sub-resource endpoints write. We assert that: +// - group.user.add / group.user.remove rows record `username` +// - group.app.add / group.app.remove rows record `appSlug`, `appNameEn`, +// `appNameAr` +// - group.role.add / group.role.remove rows record `roleName` +// +// The rows themselves are produced by POST /api/groups/:id/:kind/:targetId +// and DELETE /api/groups/:id/:kind/:targetId in routes/groups.ts, and are +// what powers the readable summary formatter on the admin audit log UI. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD = "TestPass123!"; +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; + +const pool = new pg.Pool({ connectionString: DATABASE_URL }); + +let adminId; +let adminUsername; +let adminCookie; +let memberId; +let memberUsername; +let appId; +let appSlug; +let appNameEn; +let appNameAr; +let roleId; +let roleName; +let groupId; + +async function loginAndGetCookie(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login expected 200, got ${res.status}`); + const setCookie = res.headers.get("set-cookie"); + return setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")); +} + +before(async () => { + const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; + adminUsername = `gam_admin_${stamp}`; + memberUsername = `gam_user_${stamp}`; + + const a = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'Audit Admin', 'en', true) RETURNING id`, + [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH], + ); + adminId = a.rows[0].id; + await pool.query( + `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`, + [adminId], + ); + + const u = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, 'Audit Member', 'en', true) RETURNING id`, + [memberUsername, `${memberUsername}@example.com`, TEST_PASSWORD_HASH], + ); + memberId = u.rows[0].id; + + appSlug = `gam_app_${stamp}`; + appNameEn = `GAM App ${stamp}`; + appNameAr = `تطبيق ${stamp}`; + const ap = await pool.query( + `INSERT INTO apps (slug, name_en, name_ar, icon_name, route, color, sort_order) + VALUES ($1, $2, $3, 'Box', '/gam', '#000000', 999) RETURNING id`, + [appSlug, appNameEn, appNameAr], + ); + appId = ap.rows[0].id; + + roleName = `gam_role_${stamp}`; + const r = await pool.query( + `INSERT INTO roles (name) VALUES ($1) RETURNING id`, + [roleName], + ); + roleId = r.rows[0].id; + + // Empty group so we can drive sub-resource add/remove operations cleanly. + const g = await pool.query( + `INSERT INTO groups (name) VALUES ($1) RETURNING id`, + [`gam_grp_${stamp}`], + ); + groupId = g.rows[0].id; + + adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD); +}); + +after(async () => { + // Clean up audit rows the tests produced before tearing down their targets, + // otherwise actor_user_id/target_id FKs (or noisy leftovers) can linger. + if (groupId !== undefined) { + await pool.query(`DELETE FROM audit_logs WHERE target_type = 'group' AND target_id = $1`, [groupId]); + await pool.query(`DELETE FROM user_groups WHERE group_id = $1`, [groupId]); + await pool.query(`DELETE FROM group_apps WHERE group_id = $1`, [groupId]); + await pool.query(`DELETE FROM group_roles WHERE group_id = $1`, [groupId]); + await pool.query(`DELETE FROM groups WHERE id = $1`, [groupId]); + } + if (memberId !== undefined) { + await pool.query(`DELETE FROM audit_logs WHERE target_type = 'user' AND target_id = $1`, [memberId]); + await pool.query(`DELETE FROM user_groups WHERE user_id = $1`, [memberId]); + await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [memberId]); + await pool.query(`DELETE FROM users WHERE id = $1`, [memberId]); + } + if (appId !== undefined) { + await pool.query(`DELETE FROM audit_logs WHERE target_type = 'app' AND target_id = $1`, [appId]); + await pool.query(`DELETE FROM group_apps WHERE app_id = $1`, [appId]); + await pool.query(`DELETE FROM apps WHERE id = $1`, [appId]); + } + if (roleId !== undefined) { + await pool.query(`DELETE FROM audit_logs WHERE target_type = 'role' AND target_id = $1`, [roleId]); + await pool.query(`DELETE FROM group_roles WHERE role_id = $1`, [roleId]); + await pool.query(`DELETE FROM user_roles WHERE role_id = $1`, [roleId]); + await pool.query(`DELETE FROM roles WHERE id = $1`, [roleId]); + } + if (adminId !== undefined) { + await pool.query(`DELETE FROM audit_logs WHERE actor_user_id = $1`, [adminId]); + await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]); + await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]); + } + await pool.end(); +}); + +// Look up the most recent audit_logs row for a given (action, target_type, +// target_id) tuple so each test can target the row it just produced. +async function latestAuditRow(action, targetType, targetId) { + const { rows } = await pool.query( + `SELECT metadata FROM audit_logs + WHERE action = $1 AND target_type = $2 AND target_id = $3 + ORDER BY id DESC LIMIT 1`, + [action, targetType, targetId], + ); + assert.equal(rows.length, 1, `expected one ${action} row for ${targetType}#${targetId}`); + return rows[0].metadata; +} + +test("group.user.add audit row records username (not just userId)", async () => { + const res = await fetch( + `${API_BASE}/api/groups/${groupId}/users/${memberId}`, + { method: "POST", headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 204); + const meta = await latestAuditRow("group.user.add", "group", groupId); + assert.equal(meta.userId, memberId); + assert.equal(meta.username, memberUsername); + assert.ok(typeof meta.groupName === "string" && meta.groupName.length > 0); +}); + +test("group.user.remove audit row records username (not just userId)", async () => { + const res = await fetch( + `${API_BASE}/api/groups/${groupId}/users/${memberId}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 204); + const meta = await latestAuditRow("group.user.remove", "group", groupId); + assert.equal(meta.userId, memberId); + assert.equal(meta.username, memberUsername); +}); + +test("group.app.add audit row records appSlug, appNameEn, appNameAr (not just appId)", async () => { + const res = await fetch( + `${API_BASE}/api/groups/${groupId}/apps/${appId}`, + { method: "POST", headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 204); + const meta = await latestAuditRow("group.app.add", "group", groupId); + assert.equal(meta.appId, appId); + assert.equal(meta.appSlug, appSlug); + assert.equal(meta.appNameEn, appNameEn); + assert.equal(meta.appNameAr, appNameAr); +}); + +test("group.app.remove audit row records appSlug, appNameEn, appNameAr (not just appId)", async () => { + const res = await fetch( + `${API_BASE}/api/groups/${groupId}/apps/${appId}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 204); + const meta = await latestAuditRow("group.app.remove", "group", groupId); + assert.equal(meta.appId, appId); + assert.equal(meta.appSlug, appSlug); + assert.equal(meta.appNameEn, appNameEn); + assert.equal(meta.appNameAr, appNameAr); +}); + +test("group.role.add audit row records roleName (not just roleId)", async () => { + const res = await fetch( + `${API_BASE}/api/groups/${groupId}/roles/${roleId}`, + { method: "POST", headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 204); + const meta = await latestAuditRow("group.role.add", "group", groupId); + assert.equal(meta.roleId, roleId); + assert.equal(meta.roleName, roleName); +}); + +test("group.role.remove audit row records roleName (not just roleId)", async () => { + const res = await fetch( + `${API_BASE}/api/groups/${groupId}/roles/${roleId}`, + { method: "DELETE", headers: { Cookie: adminCookie } }, + ); + assert.equal(res.status, 204); + const meta = await latestAuditRow("group.role.remove", "group", groupId); + assert.equal(meta.roleId, roleId); + assert.equal(meta.roleName, roleName); +}); diff --git a/artifacts/tx-os/package.json b/artifacts/tx-os/package.json index 2415c47b..ef7f1bc6 100644 --- a/artifacts/tx-os/package.json +++ b/artifacts/tx-os/package.json @@ -8,6 +8,7 @@ "build": "vite build --config vite.config.ts", "serve": "vite preview --config vite.config.ts --host 0.0.0.0", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --test 'src/__tests__/**/*.test.mjs'", "test:e2e": "playwright test --config playwright.config.mjs" }, "devDependencies": { diff --git a/artifacts/tx-os/src/__tests__/audit-summary.test.mjs b/artifacts/tx-os/src/__tests__/audit-summary.test.mjs new file mode 100644 index 00000000..2cba06dc --- /dev/null +++ b/artifacts/tx-os/src/__tests__/audit-summary.test.mjs @@ -0,0 +1,439 @@ +// Unit tests for formatAuditSummary and its helpers. +// +// These run with Node 24's built-in test runner + native TypeScript support, +// importing the .ts module directly. We mock the i18next `t` function with a +// simple recorder so assertions can target both: +// 1) which translation key was selected (rename vs. update vs. id-only, +// EN vs. AR), and +// 2) which interpolation values were forwarded into that key. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { formatAuditSummary } from "../lib/audit-summary.ts"; + +// `t` mock that returns a deterministic, easy-to-assert string of the form +// `key{json}`. Tests parse out the key and the interpolation payload. +function makeT() { + return (key, options) => { + const opts = options ?? {}; + return `${key}${JSON.stringify(opts)}`; + }; +} + +function entry(overrides) { + return { + id: 1, + action: "noop", + targetType: "app", + targetId: 100, + metadata: {}, + createdAt: "2026-04-30T00:00:00.000Z", + actor: null, + ...overrides, + }; +} + +function parse(result) { + if (result == null) return { key: null, opts: null }; + const idx = result.indexOf("{"); + if (idx === -1) return { key: result, opts: {} }; + return { + key: result.slice(0, idx), + opts: JSON.parse(result.slice(idx)), + }; +} + +test("app.update emits rename branch when nameEn changes (EN locale)", () => { + const result = formatAuditSummary( + entry({ + action: "app.update", + metadata: { + nameEn: "Tx New", + nameAr: "تي إكس الجديد", + slug: "tx", + changes: { + nameEn: { from: "Tx Old", to: "Tx New" }, + }, + }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.app.rename"); + assert.equal(opts.previousName, "Tx Old"); + assert.equal(opts.name, "Tx New"); +}); + +test("app.update prefers Arabic rename diff when language is AR", () => { + // Both EN and AR were edited in the same patch. The AR-locale formatter + // should announce the AR rename, not the EN one. + const result = formatAuditSummary( + entry({ + action: "app.update", + metadata: { + nameEn: "Tx New", + nameAr: "تي إكس الجديد", + changes: { + nameEn: { from: "Tx Old", to: "Tx New" }, + nameAr: { from: "تي إكس القديم", to: "تي إكس الجديد" }, + }, + }, + }), + makeT(), + "ar", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.app.rename"); + assert.equal(opts.previousName, "تي إكس القديم"); + assert.equal(opts.name, "تي إكس الجديد"); +}); + +test("app.update without rename falls back to generic update summary", () => { + const result = formatAuditSummary( + entry({ + action: "app.update", + metadata: { + nameEn: "Tx", + nameAr: "تي إكس", + changes: { + color: { from: "blue", to: "red" }, + iconName: { from: "a", to: "b" }, + }, + }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.app.update"); + assert.equal(opts.name, "Tx"); + // changes count goes through unitLabel → "admin.audit.unit.change{count:2}" + assert.equal(opts.changes, `admin.audit.unit.change${JSON.stringify({ count: 2 })}`); +}); + +test("group.update emits rename branch when previousName differs", () => { + const result = formatAuditSummary( + entry({ + action: "group.update", + targetType: "group", + targetId: 7, + metadata: { + name: "Eng", + previousName: "Engineering", + changes: { name: { from: "Engineering", to: "Eng" } }, + }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.rename"); + assert.equal(opts.previousName, "Engineering"); + assert.equal(opts.name, "Eng"); +}); + +test("group.update with multiple field changes uses generic update branch", () => { + // Multi-field updates are no longer rename-only, so the rename copy is + // skipped and we fall through to the generic '... · N changes' summary. + const result = formatAuditSummary( + entry({ + action: "group.update", + targetType: "group", + targetId: 7, + metadata: { + name: "Eng", + previousName: "Engineering", + changes: { + name: { from: "Engineering", to: "Eng" }, + descriptionEn: { from: "old", to: "new" }, + }, + }, + }), + makeT(), + "en", + ); + const { key } = parse(result); + assert.equal(key, "admin.audit.summary.group.update"); +}); + +test("settings.update for registrationOpen=true alone uses opened summary", () => { + const result = formatAuditSummary( + entry({ + action: "settings.update", + targetType: "settings", + targetId: null, + metadata: { changes: { registrationOpen: { from: false, to: true } } }, + }), + makeT(), + "en", + ); + const { key } = parse(result); + assert.equal(key, "admin.audit.summary.settings.registrationOpened"); +}); + +test("settings.update for registrationOpen=false alone uses closed summary", () => { + const result = formatAuditSummary( + entry({ + action: "settings.update", + targetType: "settings", + targetId: null, + metadata: { changes: { registrationOpen: { from: true, to: false } } }, + }), + makeT(), + "en", + ); + const { key } = parse(result); + assert.equal(key, "admin.audit.summary.settings.registrationClosed"); +}); + +test("settings.update with registration toggle plus other fields uses '...With' variant", () => { + const result = formatAuditSummary( + entry({ + action: "settings.update", + targetType: "settings", + targetId: null, + metadata: { + changes: { + registrationOpen: { from: false, to: true }, + siteTitleEn: { from: "old", to: "new" }, + }, + }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.settings.registrationOpenedWith"); + assert.equal(opts.others, `admin.audit.unit.change${JSON.stringify({ count: 1 })}`); +}); + +test("group.user.add prefers username when present", () => { + const result = formatAuditSummary( + entry({ + action: "group.user.add", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", userId: 42, username: "alice" }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.userAdd"); + assert.equal(opts.username, "alice"); + assert.equal(opts.name, "Engineering"); +}); + +test("group.user.add falls back to id-only branch when username missing", () => { + const result = formatAuditSummary( + entry({ + action: "group.user.add", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", userId: 42 }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.userAddById"); + assert.equal(opts.userId, 42); +}); + +test("group.user.remove prefers username when present", () => { + const result = formatAuditSummary( + entry({ + action: "group.user.remove", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", userId: 42, username: "alice" }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.userRemove"); + assert.equal(opts.username, "alice"); +}); + +test("group.user.remove falls back to id-only branch when username missing", () => { + const result = formatAuditSummary( + entry({ + action: "group.user.remove", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", userId: 42 }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.userRemoveById"); + assert.equal(opts.userId, 42); +}); + +test("group.app.add uses appNameEn for English locale", () => { + const result = formatAuditSummary( + entry({ + action: "group.app.add", + targetType: "group", + targetId: 5, + metadata: { + groupName: "Engineering", + appId: 12, + appSlug: "tx", + appNameEn: "Tx App", + appNameAr: "تطبيق", + }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.appAdd"); + assert.equal(opts.appName, "Tx App"); +}); + +test("group.app.add uses appNameAr for Arabic locale", () => { + const result = formatAuditSummary( + entry({ + action: "group.app.add", + targetType: "group", + targetId: 5, + metadata: { + groupName: "Engineering", + appId: 12, + appSlug: "tx", + appNameEn: "Tx App", + appNameAr: "تطبيق", + }, + }), + makeT(), + "ar", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.appAdd"); + assert.equal(opts.appName, "تطبيق"); +}); + +test("group.app.add falls back to slug when both localized names are missing", () => { + // appSlug satisfies linkedAppName(), so this still hits the named branch. + const result = formatAuditSummary( + entry({ + action: "group.app.add", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", appId: 12, appSlug: "tx" }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.appAdd"); + assert.equal(opts.appName, "tx"); +}); + +test("group.app.add falls back to id-only branch when no name and no slug", () => { + const result = formatAuditSummary( + entry({ + action: "group.app.add", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", appId: 12 }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.appAddById"); + assert.equal(opts.appId, 12); +}); + +test("group.app.remove falls back to id-only branch when name and slug are missing", () => { + const result = formatAuditSummary( + entry({ + action: "group.app.remove", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", appId: 12 }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.appRemoveById"); + assert.equal(opts.appId, 12); +}); + +test("group.role.add prefers roleName when present", () => { + const result = formatAuditSummary( + entry({ + action: "group.role.add", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", roleId: 9, roleName: "manager" }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.roleAdd"); + assert.equal(opts.roleName, "manager"); +}); + +test("group.role.add falls back to id-only branch when roleName missing", () => { + const result = formatAuditSummary( + entry({ + action: "group.role.add", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", roleId: 9 }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.roleAddById"); + assert.equal(opts.roleId, 9); +}); + +test("group.role.remove prefers roleName when present", () => { + const result = formatAuditSummary( + entry({ + action: "group.role.remove", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", roleId: 9, roleName: "manager" }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.roleRemove"); + assert.equal(opts.roleName, "manager"); +}); + +test("group.role.remove falls back to id-only branch when roleName missing", () => { + const result = formatAuditSummary( + entry({ + action: "group.role.remove", + targetType: "group", + targetId: 5, + metadata: { groupName: "Engineering", roleId: 9 }, + }), + makeT(), + "en", + ); + const { key, opts } = parse(result); + assert.equal(key, "admin.audit.summary.group.roleRemoveById"); + assert.equal(opts.roleId, 9); +}); + +test("unknown action returns null so callers can hide the summary line", () => { + const result = formatAuditSummary( + entry({ action: "totally.unknown.event" }), + makeT(), + "en", + ); + assert.equal(result, null); +}); diff --git a/artifacts/tx-os/src/lib/audit-summary.ts b/artifacts/tx-os/src/lib/audit-summary.ts new file mode 100644 index 00000000..77a8e9c6 --- /dev/null +++ b/artifacts/tx-os/src/lib/audit-summary.ts @@ -0,0 +1,339 @@ +import type { AuditLogEntry } from "@workspace/api-client-react"; + +export type AuditTFunction = ( + key: string, + options?: Record, +) => string; + +export function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function asString(v: unknown): string | null { + return typeof v === "string" && v.trim() ? v : null; +} + +export function asNumber(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +export function unitLabel( + t: AuditTFunction, + kind: string, + count: number, +): string { + return t(`admin.audit.unit.${kind}`, { count }); +} + +export function appName( + meta: Record, + lang: string, + fallbackId: AuditLogEntry["targetId"], +): string { + const en = asString(meta.nameEn) ?? asString(meta.appNameEn); + const ar = asString(meta.nameAr) ?? asString(meta.appNameAr); + const slug = asString(meta.slug) ?? asString(meta.appSlug); + const preferred = lang === "ar" ? ar ?? en : en ?? ar; + return preferred ?? slug ?? `#${fallbackId ?? "?"}`; +} + +export function linkedAppName( + meta: Record, + 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; +} + +export function plainName( + meta: Record, + fallbackId: AuditLogEntry["targetId"], +): string { + return asString(meta.name) ?? `#${fallbackId ?? "?"}`; +} + +export function changeCount(meta: Record): number { + const changes = meta.changes; + if (changes && typeof changes === "object" && !Array.isArray(changes)) { + return Object.keys(changes as Record).length; + } + if (Array.isArray(changes)) return changes.length; + let count = 0; + for (const key of ["members", "apps", "roles"]) { + const diff = meta[key]; + if (diff && typeof diff === "object" && !Array.isArray(diff)) { + const d = diff as Record; + const added = Array.isArray(d.added) ? d.added.length : 0; + const removed = Array.isArray(d.removed) ? d.removed.length : 0; + if (added + removed > 0) count += 1; + } + } + return count; +} + +export function formatAuditSummary( + entry: AuditLogEntry, + t: AuditTFunction, + lang: string, +): string | null { + const action = entry.action; + const meta = asRecord(entry.metadata); + const targetId = entry.targetId; + + switch (action) { + case "app.create": + return t("admin.audit.summary.app.create", { + name: appName(meta, lang, targetId), + }); + 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, + changes: unitLabel(t, "change", changeCount(meta)), + }); + } + case "app.permission.add": + return t("admin.audit.summary.app.permissionAdd", { + name: appName(meta, lang, targetId), + permission: + asString(meta.permissionName) ?? + (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), + }); + case "app.permission.remove": + return t("admin.audit.summary.app.permissionRemove", { + name: appName(meta, lang, targetId), + permission: + asString(meta.permissionName) ?? + (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), + }); + case "app.delete": { + const name = appName(meta, lang, targetId); + if (meta.force) { + const groupCount = asNumber(meta.groupCount) ?? 0; + return t("admin.audit.summary.app.forceDelete", { + name, + groups: unitLabel(t, "group", groupCount), + }); + } + return t("admin.audit.summary.app.delete", { name }); + } + case "auth.issue_reset_link": + return t("admin.audit.summary.auth.issueResetLink", { + username: asString(meta.username) ?? `#${targetId ?? "?"}`, + }); + case "group.create": + return t("admin.audit.summary.group.create", { + name: plainName(meta, targetId), + members: unitLabel(t, "member", asNumber(meta.memberCount) ?? 0), + apps: unitLabel(t, "app", asNumber(meta.appCount) ?? 0), + }); + 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, + changes: unitLabel(t, "change", Math.max(1, fieldsChanged)), + }); + } + case "group.delete": { + const name = plainName(meta, targetId); + if (meta.force) { + const memberCount = asNumber(meta.memberCount) ?? 0; + return t("admin.audit.summary.group.forceDelete", { + name, + members: unitLabel(t, "member", memberCount), + }); + } + return t("admin.audit.summary.group.delete", { name }); + } + 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), + }); + case "role.update": { + const previousName = asString(meta.previousName); + const name = plainName(meta, targetId); + if (previousName && previousName !== name) { + return t("admin.audit.summary.role.rename", { + previousName, + name, + }); + } + return t("admin.audit.summary.role.update", { + name, + changes: unitLabel(t, "change", Math.max(1, changeCount(meta))), + }); + } + case "role.delete": + return t("admin.audit.summary.role.delete", { + name: + asString(meta.roleName) ?? + asString(meta.name) ?? + `#${targetId ?? "?"}`, + }); + case "role.permissions.replace": { + const added = Array.isArray(meta.added) ? meta.added.length : 0; + const removed = Array.isArray(meta.removed) ? meta.removed.length : 0; + return t("admin.audit.summary.role.permissionsReplace", { + name: asString(meta.roleName) ?? `#${targetId ?? "?"}`, + added: unitLabel(t, "permission", added), + removed: unitLabel(t, "permission", removed), + }); + } + case "role.permission.add": + return t("admin.audit.summary.role.permissionAdd", { + name: asString(meta.roleName) ?? `#${targetId ?? "?"}`, + permission: + asString(meta.permissionName) ?? + (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), + }); + case "role.permission.remove": + return t("admin.audit.summary.role.permissionRemove", { + name: asString(meta.roleName) ?? `#${targetId ?? "?"}`, + permission: + asString(meta.permissionName) ?? + (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), + }); + case "service.force_delete": + return t("admin.audit.summary.service.forceDelete", { + name: asString(meta.nameEn) ?? `#${targetId ?? "?"}`, + orders: unitLabel(t, "order", asNumber(meta.orderCount) ?? 0), + }); + 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", Math.max(1, totalChanges)), + }); + } + case "user.delete": { + const username = asString(meta.username) ?? `#${targetId ?? "?"}`; + const displayEn = asString(meta.displayNameEn); + const displayAr = asString(meta.displayNameAr); + const displayName = lang === "ar" + ? displayAr ?? displayEn + : displayEn ?? displayAr; + if (displayName) { + return meta.force + ? t("admin.audit.summary.user.forceDeleteWithName", { + username, + displayName, + }) + : t("admin.audit.summary.user.deleteWithName", { + username, + displayName, + }); + } + return meta.force + ? t("admin.audit.summary.user.forceDelete", { username }) + : t("admin.audit.summary.user.delete", { username }); + } + default: + return null; + } +} diff --git a/artifacts/tx-os/src/pages/admin.tsx b/artifacts/tx-os/src/pages/admin.tsx index edb094f3..b50edf49 100644 --- a/artifacts/tx-os/src/pages/admin.tsx +++ b/artifacts/tx-os/src/pages/admin.tsx @@ -115,6 +115,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useToast } from "@/hooks/use-toast"; import { cn } from "@/lib/utils"; import { formatDate as formatDateUtil, formatWeekday as formatWeekdayUtil } from "@/lib/i18n-format"; +import { + asNumber, + asRecord, + asString, + formatAuditSummary, + unitLabel, +} from "@/lib/audit-summary"; const ADMIN_APPS_KEY = ["/api/admin/apps"] as const; @@ -5674,325 +5681,6 @@ function actorLabel(actor: AuditLogEntry["actor"], lang: string): string { type AuditTFunction = ReturnType["t"]; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function asString(v: unknown): string | null { - return typeof v === "string" && v.trim() ? v : null; -} - -function asNumber(v: unknown): number | null { - return typeof v === "number" && Number.isFinite(v) ? v : null; -} - -function unitLabel(t: AuditTFunction, kind: string, count: number): string { - return t(`admin.audit.unit.${kind}`, { count }); -} - -function appName(meta: Record, lang: string, fallbackId: AuditLogEntry["targetId"]): string { - const en = asString(meta.nameEn) ?? asString(meta.appNameEn); - const ar = asString(meta.nameAr) ?? asString(meta.appNameAr); - const slug = asString(meta.slug) ?? asString(meta.appSlug); - const preferred = lang === "ar" ? ar ?? en : en ?? ar; - return preferred ?? slug ?? `#${fallbackId ?? "?"}`; -} - -function linkedAppName(meta: Record, 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, fallbackId: AuditLogEntry["targetId"]): string { - return asString(meta.name) ?? `#${fallbackId ?? "?"}`; -} - -function changeCount(meta: Record): number { - const changes = meta.changes; - if (changes && typeof changes === "object" && !Array.isArray(changes)) { - return Object.keys(changes as Record).length; - } - if (Array.isArray(changes)) return changes.length; - let count = 0; - for (const key of ["members", "apps", "roles"]) { - const diff = meta[key]; - if (diff && typeof diff === "object" && !Array.isArray(diff)) { - const d = diff as Record; - const added = Array.isArray(d.added) ? d.added.length : 0; - const removed = Array.isArray(d.removed) ? d.removed.length : 0; - if (added + removed > 0) count += 1; - } - } - return count; -} - -function formatAuditSummary( - entry: AuditLogEntry, - t: AuditTFunction, - lang: string, -): string | null { - const action = entry.action; - const meta = asRecord(entry.metadata); - const targetId = entry.targetId; - - switch (action) { - case "app.create": - return t("admin.audit.summary.app.create", { - name: appName(meta, lang, targetId), - }); - 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, - changes: unitLabel(t, "change", changeCount(meta)), - }); - } - case "app.permission.add": - return t("admin.audit.summary.app.permissionAdd", { - name: appName(meta, lang, targetId), - permission: - asString(meta.permissionName) ?? - (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), - }); - case "app.permission.remove": - return t("admin.audit.summary.app.permissionRemove", { - name: appName(meta, lang, targetId), - permission: - asString(meta.permissionName) ?? - (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), - }); - case "app.delete": { - const name = appName(meta, lang, targetId); - if (meta.force) { - const groupCount = asNumber(meta.groupCount) ?? 0; - return t("admin.audit.summary.app.forceDelete", { - name, - groups: unitLabel(t, "group", groupCount), - }); - } - return t("admin.audit.summary.app.delete", { name }); - } - case "auth.issue_reset_link": - return t("admin.audit.summary.auth.issueResetLink", { - username: asString(meta.username) ?? `#${targetId ?? "?"}`, - }); - case "group.create": - return t("admin.audit.summary.group.create", { - name: plainName(meta, targetId), - members: unitLabel(t, "member", asNumber(meta.memberCount) ?? 0), - apps: unitLabel(t, "app", asNumber(meta.appCount) ?? 0), - }); - 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, - changes: unitLabel(t, "change", Math.max(1, fieldsChanged)), - }); - } - case "group.delete": { - const name = plainName(meta, targetId); - if (meta.force) { - const memberCount = asNumber(meta.memberCount) ?? 0; - return t("admin.audit.summary.group.forceDelete", { - name, - members: unitLabel(t, "member", memberCount), - }); - } - return t("admin.audit.summary.group.delete", { name }); - } - 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), - }); - case "role.update": { - const previousName = asString(meta.previousName); - const name = plainName(meta, targetId); - if (previousName && previousName !== name) { - return t("admin.audit.summary.role.rename", { - previousName, - name, - }); - } - return t("admin.audit.summary.role.update", { - name, - changes: unitLabel(t, "change", Math.max(1, changeCount(meta))), - }); - } - case "role.delete": - return t("admin.audit.summary.role.delete", { - name: - asString(meta.roleName) ?? - asString(meta.name) ?? - `#${targetId ?? "?"}`, - }); - case "role.permissions.replace": { - const added = Array.isArray(meta.added) ? meta.added.length : 0; - const removed = Array.isArray(meta.removed) ? meta.removed.length : 0; - return t("admin.audit.summary.role.permissionsReplace", { - name: asString(meta.roleName) ?? `#${targetId ?? "?"}`, - added: unitLabel(t, "permission", added), - removed: unitLabel(t, "permission", removed), - }); - } - case "role.permission.add": - return t("admin.audit.summary.role.permissionAdd", { - name: asString(meta.roleName) ?? `#${targetId ?? "?"}`, - permission: - asString(meta.permissionName) ?? - (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), - }); - case "role.permission.remove": - return t("admin.audit.summary.role.permissionRemove", { - name: asString(meta.roleName) ?? `#${targetId ?? "?"}`, - permission: - asString(meta.permissionName) ?? - (asNumber(meta.permissionId) != null ? `#${meta.permissionId}` : "?"), - }); - case "service.force_delete": - return t("admin.audit.summary.service.forceDelete", { - name: asString(meta.nameEn) ?? `#${targetId ?? "?"}`, - orders: unitLabel(t, "order", asNumber(meta.orderCount) ?? 0), - }); - 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", Math.max(1, totalChanges)), - }); - } - case "user.delete": { - const username = asString(meta.username) ?? `#${targetId ?? "?"}`; - const displayEn = asString(meta.displayNameEn); - const displayAr = asString(meta.displayNameAr); - const displayName = lang === "ar" - ? displayAr ?? displayEn - : displayEn ?? displayAr; - if (displayName) { - return meta.force - ? t("admin.audit.summary.user.forceDeleteWithName", { - username, - displayName, - }) - : t("admin.audit.summary.user.deleteWithName", { - username, - displayName, - }); - } - return meta.force - ? t("admin.audit.summary.user.forceDelete", { username }) - : t("admin.audit.summary.user.delete", { username }); - } - default: - return null; - } -} - // Map of well-known dependency-count metadata keys → unit i18n root used by // `unitLabel`. Keys not in this map are skipped from the inline chip strip. const DEPENDENCY_COUNT_KEYS: Record = {