Files
TX/artifacts/api-server/src/routes/audit.ts
T
riyadhafraa 058cb8b817 Make forced-delete dependency chips clickable to pivot the audit log
Task #134: Admins investigating a forced deletion can now click any
dependency chip on a force_delete row to jump to a pre-filtered audit log
view of the related history.

Backend (artifacts/api-server, lib/api-spec):
- Added targetType + targetId query params to GET /api/admin/audit-logs
  and its CSV export. targetId is validated as a positive integer (400
  on bad input). Codegen regenerated for the React API client.

Frontend (artifacts/tx-os/src/pages/admin.tsx):
- Dependency chips on forced-delete rows are now real <button> elements
  with aria-labels and keyboard focus. Non-deletion rows are unchanged.
- Chip → pivot mapping: groupCount→targetType=group,
  memberCount→targetType=user, appCount→targetType=app,
  roleCount→targetType=role; cascade chips (orderCount, messageCount,
  noteCount, etc.) pivot to the parent (targetType+targetId).
- Active filter is reflected in the URL hash (deep-linkable + reload
  safe) and shown as a dismissible pill in the panel; the pill includes
  a Clear button. Switching audit sub-section drops the filter.
- Section sync logic preserves hash params and uses a one-shot
  skipNextSectionSync ref so initial deep-linked hashes aren't clobbered.
- New i18n keys in en.json and ar.json for filter labels and chip
  aria-labels.

Tests:
- New backend tests in artifacts/api-server/tests/audit-logs-target-filter.test.mjs
  cover targetType narrowing, targetType+targetId narrowing, invalid
  targetId rejection, combination with forcedOnly, and CSV export
  honoring the new filters (7 tests, all passing).
- Verified end-to-end via the browser testing skill: chip click,
  filter pill, clear, deep-link reload all behave correctly.

Pre-existing unrelated failures (not touched): two PDF archive tests in
executive-meetings.test.mjs and the matching typecheck errors in
executive-meetings.ts.

Replit-Task-Id: 46f972ef-2874-4fc3-95c5-53d0ff0732e9
2026-04-30 07:25:16 +00:00

270 lines
8.9 KiB
TypeScript

import { Router, type IRouter, type Request, type Response } from "express";
import { and, desc, eq, gte, inArray, lt, or, sql, type SQL } from "drizzle-orm";
import { db } from "@workspace/db";
import { auditLogsTable, usersTable } from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
const router: IRouter = Router();
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const EXPORT_ROW_CAP = 50_000;
// Actions that explicitly mark a forced deletion. Both the dedicated
// `*.force_delete` action names and the legacy `*.delete` rows whose metadata
// carries `force: true` count as forced deletions for the audit-log filter.
const FORCE_DELETE_ACTIONS = [
"group.force_delete",
"user.force_delete",
"app.force_delete",
"service.force_delete",
] as const;
const SOFT_DELETE_ACTIONS_WITH_FORCE_FLAG = [
"group.delete",
"user.delete",
"app.delete",
] as const;
function parseUtcDate(s: string): Date | null {
if (!DATE_RE.test(s)) return null;
const d = new Date(`${s}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? null : d;
}
type AuditFilters = {
action: string;
forcedOnly: boolean;
fromDate: Date | null;
toDateExclusive: Date | null;
targetType: string;
targetId: number | null;
};
function parseFilters(req: Request, res: Response): AuditFilters | null {
const action = typeof req.query.action === "string" ? req.query.action.trim() : "";
const forcedOnlyRaw =
typeof req.query.forcedOnly === "string" ? req.query.forcedOnly.trim() : "";
const forcedOnly = forcedOnlyRaw === "true" || forcedOnlyRaw === "1";
const fromStr = typeof req.query.from === "string" ? req.query.from.trim() : "";
const toStr = typeof req.query.to === "string" ? req.query.to.trim() : "";
const targetType =
typeof req.query.targetType === "string" ? req.query.targetType.trim() : "";
const targetIdStr =
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
let fromDate: Date | null = null;
let toDateExclusive: Date | null = null;
if (fromStr) {
fromDate = parseUtcDate(fromStr);
if (!fromDate) {
res.status(400).json({ error: "Invalid 'from' date (expected YYYY-MM-DD)" });
return null;
}
}
if (toStr) {
const toDate = parseUtcDate(toStr);
if (!toDate) {
res.status(400).json({ error: "Invalid 'to' date (expected YYYY-MM-DD)" });
return null;
}
toDateExclusive = new Date(toDate.getTime() + 24 * 60 * 60 * 1000);
}
if (fromDate && toDateExclusive && fromDate >= toDateExclusive) {
res.status(400).json({ error: "'from' must be on or before 'to'" });
return null;
}
let targetId: number | null = null;
if (targetIdStr) {
const parsed = Number(targetIdStr);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
res.status(400).json({ error: "Invalid 'targetId' (expected positive integer)" });
return null;
}
targetId = parsed;
}
return { action, forcedOnly, fromDate, toDateExclusive, targetType, targetId };
}
function buildWhere(filters: AuditFilters): SQL | undefined {
const conditions: SQL[] = [];
if (filters.forcedOnly) {
// Either an explicit *.force_delete action, or a legacy *.delete row whose
// metadata records that the deletion was forced (force: true).
const forcedClause = or(
inArray(auditLogsTable.action, [...FORCE_DELETE_ACTIONS]),
and(
inArray(auditLogsTable.action, [...SOFT_DELETE_ACTIONS_WITH_FORCE_FLAG]),
sql`${auditLogsTable.metadata} @> '{"force": true}'::jsonb`,
),
);
if (forcedClause) conditions.push(forcedClause);
} else if (filters.action) {
conditions.push(eq(auditLogsTable.action, filters.action));
}
if (filters.fromDate) conditions.push(gte(auditLogsTable.createdAt, filters.fromDate));
if (filters.toDateExclusive)
conditions.push(lt(auditLogsTable.createdAt, filters.toDateExclusive));
if (filters.targetType)
conditions.push(eq(auditLogsTable.targetType, filters.targetType));
if (filters.targetId != null)
conditions.push(eq(auditLogsTable.targetId, filters.targetId));
return conditions.length > 0 ? and(...conditions) : undefined;
}
function csvEscape(value: unknown): string {
if (value === null || value === undefined) return "";
let s = typeof value === "string" ? value : String(value);
// Mitigate CSV/spreadsheet formula injection (Excel, LibreOffice, Google
// Sheets evaluate cells starting with =, +, -, @, or a tab/CR as formulas).
// Prefix a single quote so the spreadsheet renders the value as text.
if (s.length > 0 && /^[=+\-@\t\r]/.test(s)) {
s = `'${s}`;
}
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
router.get("/admin/audit-logs", requireAdmin, async (req, res): Promise<void> => {
const filters = parseFilters(req, res);
if (!filters) return;
const limitRaw = Number(req.query.limit);
const offsetRaw = Number(req.query.offset);
const limit =
Number.isFinite(limitRaw) && limitRaw > 0
? Math.min(Math.floor(limitRaw), 200)
: 50;
const offset =
Number.isFinite(offsetRaw) && offsetRaw >= 0 ? Math.floor(offsetRaw) : 0;
const whereClause = buildWhere(filters);
const baseQuery = db
.select({
id: auditLogsTable.id,
action: auditLogsTable.action,
targetType: auditLogsTable.targetType,
targetId: auditLogsTable.targetId,
metadata: auditLogsTable.metadata,
createdAt: auditLogsTable.createdAt,
actorUserId: auditLogsTable.actorUserId,
actorUsername: usersTable.username,
actorDisplayNameAr: usersTable.displayNameAr,
actorDisplayNameEn: usersTable.displayNameEn,
actorAvatarUrl: usersTable.avatarUrl,
})
.from(auditLogsTable)
.leftJoin(usersTable, eq(usersTable.id, auditLogsTable.actorUserId));
const rows = await (whereClause ? baseQuery.where(whereClause) : baseQuery)
.orderBy(desc(auditLogsTable.createdAt), desc(auditLogsTable.id))
.limit(limit)
.offset(offset);
const countQuery = db
.select({ count: sql<number>`count(*)::int` })
.from(auditLogsTable);
const [{ count: totalCount } = { count: 0 }] = await (whereClause
? countQuery.where(whereClause)
: countQuery);
const actionRows = await db
.selectDistinct({ action: auditLogsTable.action })
.from(auditLogsTable)
.orderBy(auditLogsTable.action);
const entries = rows.map((r) => ({
id: r.id,
action: r.action,
targetType: r.targetType,
targetId: r.targetId,
metadata: r.metadata,
createdAt: r.createdAt,
actor:
r.actorUserId != null && r.actorUsername != null
? {
id: r.actorUserId,
username: r.actorUsername,
displayNameAr: r.actorDisplayNameAr,
displayNameEn: r.actorDisplayNameEn,
avatarUrl: r.actorAvatarUrl,
}
: null,
}));
const nextOffset = offset + rows.length < totalCount ? offset + rows.length : null;
res.json({
entries,
totalCount,
limit,
offset,
nextOffset,
actions: actionRows.map((a) => a.action),
});
});
router.get(
"/admin/audit-logs/export",
requireAdmin,
async (req, res): Promise<void> => {
const filters = parseFilters(req, res);
if (!filters) return;
const whereClause = buildWhere(filters);
const baseQuery = db
.select({
action: auditLogsTable.action,
targetType: auditLogsTable.targetType,
targetId: auditLogsTable.targetId,
metadata: auditLogsTable.metadata,
createdAt: auditLogsTable.createdAt,
actorUsername: usersTable.username,
})
.from(auditLogsTable)
.leftJoin(usersTable, eq(usersTable.id, auditLogsTable.actorUserId));
const rows = await (whereClause ? baseQuery.where(whereClause) : baseQuery)
.orderBy(desc(auditLogsTable.createdAt), desc(auditLogsTable.id))
.limit(EXPORT_ROW_CAP);
const stamp = new Date().toISOString().slice(0, 10);
const filename = `audit-log-${stamp}.csv`;
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}"`,
);
res.setHeader("Cache-Control", "no-store");
// UTF-8 BOM so spreadsheet apps (Excel) detect encoding correctly,
// important for Arabic content stored in metadata.
res.write("\uFEFF");
res.write("action,actor_username,target_type,target_id,created_at,metadata\r\n");
for (const r of rows) {
const createdAtIso =
r.createdAt instanceof Date
? r.createdAt.toISOString()
: new Date(r.createdAt as unknown as string).toISOString();
const metadataJson =
r.metadata == null ? "" : JSON.stringify(r.metadata);
const line = [
csvEscape(r.action),
csvEscape(r.actorUsername),
csvEscape(r.targetType),
csvEscape(r.targetId),
csvEscape(createdAtIso),
csvEscape(metadataJson),
].join(",");
res.write(line);
res.write("\r\n");
}
res.end();
},
);
export default router;