Task #243: Admin audit log — focused readability + actor-filter subset

Landed a tight subset of the 13-item umbrella, mirroring the proven
narrow-then-defer pattern from #242:

- #195 — Plain DELETE /api/services/:id now writes a `service.delete`
  audit row carrying nameEn + nameAr (force-with-deps still uses the
  dedicated `service.force_delete`). Added matching `service.delete`
  formatter case + EN/AR i18n keys, and surfaced nameAr on the existing
  `service.force_delete` summary.
- #197 — `actorUserId` filter for `/admin/audit-logs` and CSV export.
  openapi.yaml updated, codegen regenerated, server filter wired through
  parseFilters/buildWhere with 400-on-invalid handling, AuditLogPanel UI
  got an actor dropdown wired into params + export URL + reset, and a
  new audit-logs-actor-filter API test (4 cases) covers list narrowing,
  exclusion, invalid input, and CSV export.
- #178 — Formatter unit tests for user.delete (id-only, EN/AR display
  name resolution, force flag, force + name) and the new service.delete
  (id-only, EN/AR), 11 new cases (33/33 pass).

Skipped #194 — already implemented; users.ts DELETE persists displayName
fields and audit-summary already renders user.deleteWithName/forceDeleteWithName.

Deferred via follow-ups (no duplicate of existing #182/#183/#184):
- F1: #196 recent-activity endpoint + 5 admin panels
- F2: #205+#206+#208 permission history CSV/name resolution/timeline
- F3: #209+#210 cascade/bulk audit rows + e2e UI spec for History tabs

Validation: tx-os typecheck clean; pre-existing executive-meetings.ts
errors not regressed; all targeted server tests pass (delete-force-warnings 10,
audit-logs target-filter 7, forced-only 6, audit-log-coverage 27, new
actor-filter 4, broader audit/services sweep 40); e2e test verified actor
dropdown rendering, filter behavior, readable Arabic service.delete summary,
and CSV export honoring the filter.
This commit is contained in:
riyadhafraa
2026-05-01 06:54:26 +00:00
parent 76d08a76da
commit b3ad701ba6
13 changed files with 577 additions and 6 deletions
@@ -355,8 +355,8 @@ test("DELETE /api/apps/:id?force=true deletes app and writes audit log", async (
test("DELETE /api/services/:id without dependents succeeds (no force needed)", async () => {
const s = await pool.query(
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ('خ', $1, 1.00, true) RETURNING id`,
[`${SVC_PREFIX}Clean`],
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.00, true) RETURNING id`,
[`${SVC_PREFIX}CleanAr`, `${SVC_PREFIX}Clean`],
);
const id = s.rows[0].id;
createdServiceIds.push(id);
@@ -366,6 +366,43 @@ test("DELETE /api/services/:id without dependents succeeds (no force needed)", a
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
// #195: plain (no-deps) deletes now write a `service.delete` audit row
// carrying both nameEn and nameAr so the audit log can render the service
// name in either locale even after the row is gone.
const audit = await pool.query(
`SELECT action, metadata FROM audit_logs WHERE target_type = 'service' AND target_id = $1`,
[String(id)],
);
assert.equal(audit.rowCount, 1);
assert.equal(audit.rows[0].action, "service.delete");
assert.equal(audit.rows[0].metadata.nameEn, `${SVC_PREFIX}Clean`);
assert.equal(audit.rows[0].metadata.nameAr, `${SVC_PREFIX}CleanAr`);
});
test("DELETE /api/services/:id?force=true with no dependents writes service.delete (not service.force_delete)", async () => {
// #195: the `force` query has no effect when there are no dependents, so
// the plain `service.delete` row is correct here. `service.force_delete`
// is reserved for the path that actually had to clear dependent orders.
const s = await pool.query(
`INSERT INTO services (name_ar, name_en, price, is_available) VALUES ($1, $2, 1.50, true) RETURNING id`,
[`${SVC_PREFIX}ForceCleanAr`, `${SVC_PREFIX}ForceClean`],
);
const id = s.rows[0].id;
createdServiceIds.push(id);
const res = await fetch(`${API_BASE}/api/services/${id}?force=true`, {
method: "DELETE",
headers: { Cookie: adminCookie },
});
assert.equal(res.status, 204);
const audit = await pool.query(
`SELECT action FROM audit_logs WHERE target_type = 'service' AND target_id = $1`,
[String(id)],
);
assert.equal(audit.rowCount, 1);
assert.equal(audit.rows[0].action, "service.delete");
});
test("DELETE /api/services/:id with orders returns 409 with counts", async () => {