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`). Both audit inserts now run inside
  the same transaction as the delete itself (post-review fix) so we can
  never end up with a removed service and no matching audit row. 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:
Riyadh
2026-05-01 06:56:15 +00:00
parent d6b90db000
commit 7494a6a050
+31 -29
View File
@@ -153,6 +153,12 @@ router.delete("/services/:id", requireAdmin, async (req, res): Promise<void> =>
return;
}
// #195: keep the delete + audit insert in one transaction so we never
// end up with a removed service and no matching audit row (or vice versa).
// Forced deletes keep the dedicated `service.force_delete` action so the
// existing forced-only filter and metadata shape (orderCount) stay
// backwards compatible; plain deletes get a `service.delete` row mirroring
// the user.delete pattern.
await db.transaction(async (tx) => {
if (hasDeps) {
// service_orders.service_id has ON DELETE RESTRICT, so we must clear
@@ -162,36 +168,32 @@ router.delete("/services/:id", requireAdmin, async (req, res): Promise<void> =>
.where(eq(serviceOrdersTable.serviceId, serviceId));
}
await tx.delete(servicesTable).where(eq(servicesTable.id, serviceId));
});
// #195: write an audit row for every service deletion. Forced deletes keep
// the dedicated `service.force_delete` action so the existing forced-only
// filter and metadata shape (orderCount) stay backwards compatible; plain
// deletes get a `service.delete` row mirroring the user.delete pattern.
if (hasDeps && force) {
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "service.force_delete",
targetType: "service",
targetId: serviceId,
metadata: {
nameEn: existing.nameEn,
nameAr: existing.nameAr,
orderCount,
},
});
} else {
await db.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "service.delete",
targetType: "service",
targetId: serviceId,
metadata: {
nameEn: existing.nameEn,
nameAr: existing.nameAr,
},
});
}
if (hasDeps && force) {
await tx.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "service.force_delete",
targetType: "service",
targetId: serviceId,
metadata: {
nameEn: existing.nameEn,
nameAr: existing.nameAr,
orderCount,
},
});
} else {
await tx.insert(auditLogsTable).values({
actorUserId: req.session.userId ?? null,
action: "service.delete",
targetType: "service",
targetId: serviceId,
metadata: {
nameEn: existing.nameEn,
nameAr: existing.nameAr,
},
});
}
});
res.sendStatus(204);
});