import { DescriptionList } from "@govoplan/core-webui"; import { useEffect, useRef, useState } from "react"; import { AdminPageLayout, adminErrorMessage, Button, Card, ConfirmDialog, DataGrid, DocumentationHelpLink, mergeDeltaRows, RetentionPolicyScopeManager, runRetentionPolicy, StatusBadge, useDeltaWatermarks, type ApiSettings, type DataGridColumn, type DeltaDeletedItem, type PrivacyRetentionPolicyScope, type RetentionPolicyTargetOption, type RetentionRunResponse } from "@govoplan/core-webui"; import { RefreshCw } from "lucide-react"; import { fetchGroupsDelta, fetchUsersDelta, type GroupListDeltaResponse, type GroupSummary, type UserAdminItem, type UserListDeltaResponse } from "../../api/adminTargets"; type Props = { settings: ApiSettings; scopeType: Extract; canWrite: boolean; }; type DeltaResponse = { deleted: DeltaDeletedItem[]; watermark?: string | null; has_more: boolean; full: boolean; }; interface RetentionCountTree { [key: string]: number | RetentionCountTree; } type RetentionCountRow = { id: string; area: string; measure: string; count: number; }; const RETENTION_DOCUMENTATION = { contextId: "policy.retention", documentationType: "admin" as const }; const RETENTION_RESULT_COLUMNS: DataGridColumn[] = [ { id: "area", header: "Area", value: (row) => row.area, width: "minmax(180px, 1fr)", filterType: "list", sortable: true }, { id: "measure", header: "Outcome", value: (row) => row.measure, width: "minmax(220px, 1.4fr)", filterType: "text", sortable: true }, { id: "count", header: "Records", value: (row) => row.count, width: "120px", align: "right", sortable: true } ]; const copy: Record = { system: { title: "System retention", description: "Instance-wide privacy retention policy and lower-level override limits.", policyTitle: "System retention policy", policyDescription: "Set concrete system retention values. Override switches define whether lower levels may narrow each value." }, tenant: { title: "Tenant retention", description: "Tenant-level privacy and retention limits for the active tenant.", policyTitle: "Tenant retention policy", policyDescription: "Tenant limits may only narrow the system policy where the parent policy allows overrides." }, user: { title: "User retention", description: "User-scoped retention limits for campaigns owned by a user.", targetLabel: "User", policyTitle: "User retention policy", policyDescription: "User limits may only narrow inherited system and tenant policy." }, group: { title: "Group retention", description: "Group-scoped retention limits for campaigns owned by a group.", targetLabel: "Group", policyTitle: "Group retention policy", policyDescription: "Group limits may only narrow inherited system and tenant policy." } }; export default function RetentionPoliciesPanel({ settings, scopeType, canWrite }: Props) { const [targets, setTargets] = useState([]); const usersRef = useRef([]); const groupsRef = useRef([]); const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks(); const [loadingTargets, setLoadingTargets] = useState(scopeType === "user" || scopeType === "group"); const [targetError, setTargetError] = useState(""); const [busy, setBusy] = useState(false); const [success, setSuccess] = useState(""); const [runError, setRunError] = useState(""); const [confirmRetentionRun, setConfirmRetentionRun] = useState(false); const [retentionResult, setRetentionResult] = useState(null); useEffect(() => { usersRef.current = []; groupsRef.current = []; resetDeltaWatermark(); void loadTargets(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, resetDeltaWatermark]); async function loadTargets() { if (scopeType !== "user" && scopeType !== "group") { setTargets([]); setLoadingTargets(false); setTargetError(""); return; } setLoadingTargets(true); setTargetError(""); try { if (scopeType === "user") { const users = await loadDeltaRows( usersRef.current, "policy:retention-users", getDeltaWatermark, setDeltaWatermark, (since) => fetchUsersDelta(settings, { since }), (response) => response.users, (user) => user.id, "access_user", sortUsers ); usersRef.current = users; setTargets(users.map((user) => ({ id: user.id, label: user.display_name || user.email, secondary: user.display_name ? user.email : null }))); } else { const groups = await loadDeltaRows( groupsRef.current, "policy:retention-groups", getDeltaWatermark, setDeltaWatermark, (since) => fetchGroupsDelta(settings, { since }), (response) => response.groups, (group) => group.id, "access_group", sortGroups ); groupsRef.current = groups; setTargets(groups.map((group) => ({ id: group.id, label: group.name, secondary: group.slug }))); } } catch (err) { setTargets([]); setTargetError(adminErrorMessage(err)); } finally { setLoadingTargets(false); } } async function runRetention(dryRun: boolean) { setBusy(true); setRunError(""); setSuccess(""); try { const response = await runRetentionPolicy(settings, dryRun); setRetentionResult(response); setSuccess(dryRun ? "Retention dry run completed." : "Retention policy applied."); setConfirmRetentionRun(false); } catch (err) { setRunError(adminErrorMessage(err)); } finally { setBusy(false); } } const labels = copy[scopeType]; const resultRows = flattenRetentionCounts(retentionResult?.result.counts); const actionDisabledReason = busy ? "A retention operation is already running." : !canWrite ? "Your account may inspect retention policy but cannot run retention operations." : undefined; return ( <> {(scopeType === "user" || scopeType === "group") && ( )} } > {scopeType === "system" && (
} >

Run the saved effective retention policy against retained platform data.

{retentionResult && (
Operation
Policy scope
{humanize(retentionResult.result.effective_policy_scope || "system")}
Reported outcomes
{resultRows.length}
row.id} emptyText="No retained records currently match the effective policy." />
)}
)}
setConfirmRetentionRun(false)} onConfirm={() => void runRetention(false)} /> ); } function flattenRetentionCounts( counts: RetentionRunResponse["result"]["counts"] | undefined ): RetentionCountRow[] { const rows: RetentionCountRow[] = []; const visit = (value: number | RetentionCountTree, path: string[]) => { if (typeof value === "number") { const [area = "retention", ...measureParts] = path; rows.push({ id: path.join("."), area: humanize(area), measure: humanize(measureParts.join(" ") || "records"), count: value }); return; } for (const [key, child] of Object.entries(value)) visit(child, [...path, key]); }; if (counts) visit(counts as unknown as RetentionCountTree, []); return rows; } function humanize(value: string): string { return value .split(/[._\s-]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); } async function loadDeltaRows( current: TItem[], key: string, getDeltaWatermark: (key: string) => string | null, setDeltaWatermark: (key: string, watermark: string | null | undefined) => void, fetchDelta: (since: string | null) => Promise, rowsFromResponse: (response: TResponse) => TItem[], getKey: (item: TItem) => string, deletedResourceType: string, sort?: (left: TItem, right: TItem) => number ): Promise { let nextWatermark = getDeltaWatermark(key); let merged = current; let hasMore = false; do { const response = await fetchDelta(nextWatermark); const rows = rowsFromResponse(response); merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort }); nextWatermark = response.watermark ?? null; hasMore = response.has_more; } while (hasMore); setDeltaWatermark(key, nextWatermark); return merged; } function sortUsers(left: UserAdminItem, right: UserAdminItem): number { return left.email.localeCompare(right.email); } function sortGroups(left: GroupSummary, right: GroupSummary): number { return left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug); }