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
This commit is contained in:
riyadhafraa
2026-04-30 07:25:16 +00:00
parent d72c5cc851
commit 058cb8b817
8 changed files with 560 additions and 20 deletions
+195 -15
View File
@@ -580,9 +580,28 @@ export default function AdminPage() {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Skip the very first render so we don't clobber a deep-linked hash like
// `#section=audit-log&targetType=group&targetId=42` before the mount-time
// parser above has had a chance to apply it.
const skipNextSectionSync = useRef<boolean>(true);
useEffect(() => {
if (typeof window === "undefined") return;
const newHash = `#section=${section}`;
if (skipNextSectionSync.current) {
skipNextSectionSync.current = false;
return;
}
const raw = window.location.hash.replace(/^#/, "");
const params = new URLSearchParams(raw);
const previousSection = params.get("section");
params.set("section", section);
// Section-scoped params (the audit log's targetType / targetId deep
// links) only make sense within their owning section. Switching to a
// different section drops them so the next visit starts fresh.
if (previousSection !== section) {
params.delete("targetType");
params.delete("targetId");
}
const newHash = `#${params.toString()}`;
if (window.location.hash !== newHash) {
window.history.replaceState(null, "", newHash);
}
@@ -4827,6 +4846,23 @@ const DEPENDENCY_COUNT_KEYS: Record<string, string> = {
openCount: "open",
};
// When a dependency chip key maps to a known audit-log target_type, clicking
// the chip pivots to "audit log filtered by that target_type". Other chip
// keys (orders, messages, notes, conversations, opens) have no audit_logs
// rows of their own, so they pivot to the parent entity's audit history
// instead (target_type + target_id of the deletion row).
const DEPENDENCY_CHIP_TARGET_TYPES: Record<string, string> = {
groupCount: "group",
memberCount: "user",
appCount: "app",
roleCount: "role",
};
type AuditTargetPivot = {
targetType: string;
targetId: number | null;
};
function isForceDeleteEntry(entry: AuditLogEntry): boolean {
const action = entry.action;
if (action.endsWith(".force_delete")) return true;
@@ -4861,18 +4897,35 @@ function forceDeletedEntityName(
function dependencyChips(
entry: AuditLogEntry,
t: AuditTFunction,
): Array<{ key: string; label: string }> {
): Array<{ key: string; label: string; pivot: AuditTargetPivot }> {
const meta = asRecord(entry.metadata);
const chips: Array<{ key: string; label: string }> = [];
const chips: Array<{ key: string; label: string; pivot: AuditTargetPivot }> =
[];
for (const [metaKey, unitRoot] of Object.entries(DEPENDENCY_COUNT_KEYS)) {
const value = asNumber(meta[metaKey]);
if (value == null || value <= 0) continue;
chips.push({ key: metaKey, label: unitLabel(t, unitRoot, value) });
const mappedTargetType = DEPENDENCY_CHIP_TARGET_TYPES[metaKey];
const pivot: AuditTargetPivot = mappedTargetType
? { targetType: mappedTargetType, targetId: null }
: { targetType: entry.targetType, targetId: entry.targetId ?? null };
chips.push({
key: metaKey,
label: unitLabel(t, unitRoot, value),
pivot,
});
}
return chips;
}
function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
function AuditLogRow({
entry,
lang,
onPivotToTarget,
}: {
entry: AuditLogEntry;
lang: string;
onPivotToTarget: (pivot: AuditTargetPivot) => void;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const hasMetadata =
@@ -4961,15 +5014,31 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
data-testid={`audit-deps-${entry.id}`}
aria-label={t("admin.audit.dependencies.label")}
>
{chips.map((chip) => (
<span
key={chip.key}
className="inline-block text-[11px] px-2 py-0.5 rounded-md bg-rose-50 text-rose-700 border border-rose-100"
data-testid={`audit-dep-${entry.id}-${chip.key}`}
>
{chip.label}
</span>
))}
{chips.map((chip) => {
const ariaLabel = chip.pivot.targetId != null
? t("admin.audit.dependencies.chipAriaParent", {
label: chip.label,
type: entry.targetType,
id: chip.pivot.targetId,
})
: t("admin.audit.dependencies.chipAriaTargetType", {
label: chip.label,
type: chip.pivot.targetType,
});
return (
<button
type="button"
key={chip.key}
onClick={() => onPivotToTarget(chip.pivot)}
aria-label={ariaLabel}
title={ariaLabel}
className="inline-flex items-center text-[11px] px-2 py-0.5 rounded-md bg-rose-50 text-rose-700 border border-rose-100 hover:bg-rose-100 hover:border-rose-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 transition-colors cursor-pointer"
data-testid={`audit-dep-${entry.id}-${chip.key}`}
>
{chip.label}
</button>
);
})}
</div>
)}
</div>
@@ -5000,6 +5069,51 @@ function AuditLogRow({ entry, lang }: { entry: AuditLogEntry; lang: string }) {
);
}
// Parses targetType / targetId values out of the admin page URL hash so the
// audit log panel can deep-link to a pre-filtered view (e.g. when an admin
// clicks a dependency chip on a forced-delete row).
function parseAuditHashTarget(): AuditTargetPivot {
if (typeof window === "undefined") return { targetType: "", targetId: null };
const raw = window.location.hash.replace(/^#/, "");
const params = new URLSearchParams(raw);
const targetType = params.get("targetType")?.trim() ?? "";
const targetIdRaw = params.get("targetId")?.trim();
let targetId: number | null = null;
if (targetIdRaw) {
const n = Number(targetIdRaw);
if (Number.isFinite(n) && Number.isInteger(n) && n >= 1) {
targetId = n;
}
}
return { targetType, targetId };
}
// Updates the admin URL hash (which carries `section=audit-log`) so the
// active targetType/targetId filters survive reload and back/forward nav.
// Uses replaceState to avoid polluting browser history with every tweak.
function syncAuditHashTarget(pivot: AuditTargetPivot) {
if (typeof window === "undefined") return;
const raw = window.location.hash.replace(/^#/, "");
const params = new URLSearchParams(raw);
if (pivot.targetType) {
params.set("targetType", pivot.targetType);
} else {
params.delete("targetType");
}
if (pivot.targetId != null) {
params.set("targetId", String(pivot.targetId));
} else {
params.delete("targetId");
}
if (!params.has("section")) {
params.set("section", "audit-log");
}
const newHash = `#${params.toString()}`;
if (window.location.hash !== newHash) {
window.history.replaceState(null, "", newHash);
}
}
function AuditLogPanel() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -5012,6 +5126,15 @@ function AuditLogPanel() {
const [pageLimit, setPageLimit] = useState<number>(50);
const [exporting, setExporting] = useState<boolean>(false);
const [exportError, setExportError] = useState<string | null>(null);
// Deep-link target filters. Initialised from the URL hash so a user who
// pastes /admin#section=audit-log&targetType=group lands directly on a
// pre-filtered view. Updated by chip clicks and the explicit clear button.
const [targetTypeFilter, setTargetTypeFilter] = useState<string>(
() => parseAuditHashTarget().targetType,
);
const [targetIdFilter, setTargetIdFilter] = useState<number | null>(
() => parseAuditHashTarget().targetId,
);
const fromValid = !appliedFrom || DATE_RE.test(appliedFrom);
const toValid = !appliedTo || DATE_RE.test(appliedTo);
@@ -5029,6 +5152,8 @@ function AuditLogPanel() {
: {}),
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
...(targetTypeFilter ? { targetType: targetTypeFilter } : {}),
...(targetIdFilter != null ? { targetId: targetIdFilter } : {}),
};
const queryKey = getListAuditLogsQueryKey(params);
@@ -5060,6 +5185,9 @@ function AuditLogPanel() {
setAppliedFrom("");
setAppliedTo("");
setPageLimit(50);
setTargetTypeFilter("");
setTargetIdFilter(null);
syncAuditHashTarget({ targetType: "", targetId: null });
};
const toggleForcedOnly = () => {
@@ -5071,6 +5199,24 @@ function AuditLogPanel() {
});
};
// Chip click on a forced-delete row: pre-filter the panel to the chosen
// target_type (and optionally target_id). Resets pagination so the new
// result set starts fresh, and updates the URL hash so the filter
// survives reload / back-forward navigation.
const pivotToTarget = (pivot: AuditTargetPivot) => {
setTargetTypeFilter(pivot.targetType);
setTargetIdFilter(pivot.targetId);
setPageLimit(50);
syncAuditHashTarget(pivot);
};
const clearTargetFilter = () => {
setTargetTypeFilter("");
setTargetIdFilter(null);
setPageLimit(50);
syncAuditHashTarget({ targetType: "", targetId: null });
};
const loadMore = () => setPageLimit((n) => Math.min(n + 50, 200));
const handleExportCsv = async () => {
@@ -5086,6 +5232,8 @@ function AuditLogPanel() {
: {}),
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
...(targetTypeFilter ? { targetType: targetTypeFilter } : {}),
...(targetIdFilter != null ? { targetId: targetIdFilter } : {}),
});
const res = await fetch(url, {
method: "GET",
@@ -5116,6 +5264,15 @@ function AuditLogPanel() {
}
};
const activeTargetPillLabel = targetTypeFilter
? targetIdFilter != null
? t("admin.audit.filters.targetWithId", {
type: targetTypeFilter,
id: targetIdFilter,
})
: t("admin.audit.filters.targetType", { type: targetTypeFilter })
: null;
return (
<div className="space-y-3" data-testid="audit-log-panel">
<div className="glass-panel rounded-2xl p-3 space-y-3">
@@ -5136,6 +5293,24 @@ function AuditLogPanel() {
<Trash2 size={12} />
{t("admin.audit.filters.forcedOnly")}
</button>
{activeTargetPillLabel && (
<span
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border bg-emerald-100 text-emerald-800 border-emerald-200"
data-testid="audit-target-filter-pill"
>
<span className="truncate">{activeTargetPillLabel}</span>
<button
type="button"
onClick={clearTargetFilter}
aria-label={t("admin.audit.filters.clearTargetFilter")}
title={t("admin.audit.filters.clearTargetFilter")}
className="ms-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-emerald-200/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400"
data-testid="audit-clear-target-filter"
>
×
</button>
</span>
)}
</div>
<div className="grid gap-2 sm:grid-cols-2 md:grid-cols-4">
<div className="space-y-1">
@@ -5245,7 +5420,12 @@ function AuditLogPanel() {
) : (
<div className="space-y-2">
{entries.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} lang={lang} />
<AuditLogRow
key={entry.id}
entry={entry}
lang={lang}
onPivotToTarget={pivotToTarget}
/>
))}
</div>
)}