058cb8b817
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
270 lines
8.9 KiB
TypeScript
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;
|