create-safest-tools 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/template/README.md +1 -1
  5. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  6. package/template/console/analytics/controller.ts +10 -2
  7. package/template/console/analytics/types.ts +7 -0
  8. package/template/console/appeals/AppealFollowup.tsx +171 -0
  9. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  10. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  11. package/template/console/appeals/appeal-corrections.css +29 -0
  12. package/template/console/appeals/controller.ts +40 -14
  13. package/template/console/appeals/events.ts +1 -0
  14. package/template/console/appeals/types.ts +6 -0
  15. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  16. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  17. package/template/console/command/CommandCentre.tsx +12 -6
  18. package/template/console/command/controller.ts +13 -9
  19. package/template/console/command/presentation.ts +1 -6
  20. package/template/console/command/store.ts +1 -0
  21. package/template/console/command/types.ts +1 -0
  22. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  23. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  24. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  25. package/template/console/configuration/types.ts +1 -1
  26. package/template/console/lib/priority.ts +13 -0
  27. package/template/console/operations/CaseEscalations.tsx +142 -0
  28. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  29. package/template/console/operations/case-escalations.css +20 -0
  30. package/template/console/people/PeopleWorkspace.tsx +8 -4
  31. package/template/console/people/PersonAccessDialog.tsx +40 -0
  32. package/template/console/people/types.ts +1 -0
  33. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  34. package/template/console/registry/IntegrationSetup.tsx +41 -0
  35. package/template/console/registry/IntegrationVerification.tsx +54 -0
  36. package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
  37. package/template/console/registry/RegistryDialog.tsx +34 -4
  38. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  39. package/template/console/registry/integration-verification.css +25 -0
  40. package/template/console/reports/CaseActions.tsx +81 -0
  41. package/template/console/reports/CaseContinuity.tsx +27 -0
  42. package/template/console/reports/CaseNotices.tsx +48 -0
  43. package/template/console/reports/CaseOwnership.tsx +78 -0
  44. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  45. package/template/console/reports/InvestigationBrief.tsx +29 -0
  46. package/template/console/reports/ReportDrawer.tsx +109 -67
  47. package/template/console/reports/ReportWorkspace.tsx +11 -12
  48. package/template/console/reports/controller.ts +96 -49
  49. package/template/console/reports/events.ts +9 -2
  50. package/template/console/reports/load-inbox.ts +17 -0
  51. package/template/console/reports/private-drafts.ts +32 -0
  52. package/template/console/reports/store.ts +2 -2
  53. package/template/console/reports/types.ts +41 -2
  54. package/template/console/reports/use-editing-access.ts +13 -0
  55. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  56. package/template/console/shell/WorkspaceShell.tsx +55 -29
  57. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  58. package/template/console/shell/controller.ts +22 -8
  59. package/template/console/shell/navigation.ts +30 -39
  60. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  61. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  62. package/template/console/workflows/PlaybookFields.tsx +109 -0
  63. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  64. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  65. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  66. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  67. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  68. package/template/console/workflows/templates.ts +3 -3
  69. package/template/console/workflows/types.ts +11 -0
  70. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  71. package/template/migrations/0003_action_confirmation.sql +24 -0
  72. package/template/migrations/0004_notice_recovery.sql +22 -0
  73. package/template/migrations/0005_appeal_continuity.sql +17 -0
  74. package/template/migrations/0006_case_ownership.sql +23 -0
  75. package/template/migrations/0007_integration_verification.sql +21 -0
  76. package/template/migrations/0008_case_escalations.sql +135 -0
  77. package/template/migrations/0009_participant_continuation.sql +61 -0
  78. package/template/migrations/0010_appeal_corrections.sql +41 -0
  79. package/template/npm-shrinkwrap.json +8036 -0
  80. package/template/package.json +25 -17
  81. package/template/public/_headers +16 -1
  82. package/template/public/console/auth-shell.js +18701 -11800
  83. package/template/public/console/release.json +1 -0
  84. package/template/public/console/workspace-features.css +2 -0
  85. package/template/public/embed/embed.css +3 -0
  86. package/template/public/embed/embed.js +19 -1
  87. package/template/public/embed/index.html +2 -0
  88. package/template/public/index.html +9 -6
  89. package/template/public/participant.css +2 -0
  90. package/template/public/participant.html +13 -0
  91. package/template/public/participant.js +111 -0
  92. package/template/public/public-report.js +21 -1
  93. package/template/public/report/index.html +1 -1
  94. package/template/public/styles.css +329 -10
  95. package/template/public/workspace.css +124 -0
  96. package/template/scripts/reports-plan.mjs +1 -1
  97. package/template/shared/case-escalations.ts +31 -0
  98. package/template/shared/integration-progress.ts +27 -0
  99. package/template/shared/notice-inputs.ts +3 -0
  100. package/template/shared/playbook-resources.ts +14 -0
  101. package/template/shared/playbooks.ts +249 -0
  102. package/template/src/agent-executor.ts +25 -8
  103. package/template/src/ai-observability.ts +14 -13
  104. package/template/src/ai-registry-validation.ts +1 -1
  105. package/template/src/appeal-continuity.ts +75 -0
  106. package/template/src/appeal-correction-state.ts +46 -0
  107. package/template/src/appeal-corrections.ts +112 -0
  108. package/template/src/appeal-decisions.ts +85 -0
  109. package/template/src/assistant.ts +3 -4
  110. package/template/src/case-escalations.ts +223 -0
  111. package/template/src/case-investigator.ts +179 -0
  112. package/template/src/component-executor.ts +3 -2
  113. package/template/src/configuration-registry.ts +4 -1
  114. package/template/src/database-readiness.ts +8 -3
  115. package/template/src/human-tasks.ts +152 -55
  116. package/template/src/index.ts +178 -18
  117. package/template/src/installation-admin.ts +2 -1
  118. package/template/src/integration-progress.ts +45 -0
  119. package/template/src/integration-verification.ts +249 -0
  120. package/template/src/investigation-evidence.ts +95 -0
  121. package/template/src/investigator-fixture.ts +30 -0
  122. package/template/src/notice-delivery.ts +19 -0
  123. package/template/src/notice-recovery.ts +81 -0
  124. package/template/src/operator-scope.ts +31 -0
  125. package/template/src/participant-access.ts +163 -0
  126. package/template/src/participant-case.ts +87 -0
  127. package/template/src/participant-customer-access.ts +93 -0
  128. package/template/src/participant-recovery.ts +43 -0
  129. package/template/src/participant-routes.ts +67 -0
  130. package/template/src/playbook-resources.ts +58 -0
  131. package/template/src/report-admin.ts +9 -2
  132. package/template/src/report-auth.ts +1 -28
  133. package/template/src/report-delivery.ts +136 -129
  134. package/template/src/report-email-template.ts +5 -2
  135. package/template/src/report-governance.ts +42 -45
  136. package/template/src/report-operations.ts +16 -63
  137. package/template/src/report-ownership.ts +64 -0
  138. package/template/src/report-repository.ts +16 -7
  139. package/template/src/report-types.ts +1 -0
  140. package/template/src/report-workflow.ts +181 -274
  141. package/template/src/workflow-actions.ts +248 -156
  142. package/template/src/workflow-compiler.ts +63 -12
  143. package/template/src/workflow-dynamic-runtime.ts +18 -7
  144. package/template/src/workflow-effects.ts +95 -18
  145. package/template/src/workflow-event-transport.ts +16 -0
  146. package/template/src/workflow-platform-types.ts +3 -0
  147. package/template/src/workflow-platform-validation.ts +23 -1
  148. package/template/src/workflow-repository.ts +131 -28
  149. package/template/src/workflow-runs.ts +9 -4
  150. package/template/src/workflow-simulator.ts +7 -4
  151. package/template/src/workspace-governance.ts +6 -4
  152. package/template/tsconfig.json +1 -0
  153. package/template/vite.console.config.ts +30 -2
  154. package/template/worker-configuration.d.ts +3 -2
package/README.md CHANGED
@@ -6,7 +6,7 @@ Safest Resolve accepts explicit reports from an embedded widget, a public page,
6
6
 
7
7
  ## Quick start
8
8
 
9
- You need Node.js 20.12+, a Cloudflare account with Workers, Dynamic Workflows, Worker Loaders, D1, R2, Queues, Durable Objects, and Workers AI available, plus an HTTPS hostname for the reports service.
9
+ You need Node.js 22.13+, a Cloudflare account with Workers, Dynamic Workflows, Worker Loaders, D1, R2, Queues, Durable Objects, and Workers AI available, plus an HTTPS hostname for the reports service.
10
10
 
11
11
  ```bash
12
12
  npx create-safest-tools safest-resolve \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Create customer-owned abuse-reporting infrastructure on Cloudflare",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,7 +26,7 @@
26
26
  "test": "node --test test/*.test.mjs"
27
27
  },
28
28
  "engines": {
29
- "node": ">=20.12.0"
29
+ "node": ">=22.13.0"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
package/src/config.mjs CHANGED
@@ -124,8 +124,8 @@ export function buildWranglerConfiguration(config) {
124
124
  class_name: "DynamicWorkflow",
125
125
  limits: { steps: 1000 },
126
126
  }],
127
- durable_objects: { bindings: [{ name: "WORKSPACE_PRESENCE", class_name: "WorkspacePresence" }] },
128
- exports: { WorkspacePresence: { type: "durable-object", storage: "sqlite" } },
127
+ durable_objects: { bindings: [{ name: "WORKSPACE_PRESENCE", class_name: "WorkspacePresence" }, { name: "CASE_INVESTIGATOR", class_name: "CaseInvestigator" }] },
128
+ exports: { WorkspacePresence: { type: "durable-object", storage: "sqlite" }, CaseInvestigator: { type: "durable-object", storage: "sqlite" } },
129
129
  d1_databases: [{ binding: "DB", database_name: config.resources.databaseName, migrations_dir: "migrations" }],
130
130
  queues: {
131
131
  producers: [
@@ -4,7 +4,7 @@ Safest Resolve handles abuse, safety, fraud, and policy reports in the Cloudflar
4
4
 
5
5
  The service does not proxy or inspect normal application traffic. The customer application remains responsible for the content references it provides and for carrying out any approved action.
6
6
 
7
- This project deploys one Worker, one D1 database, four private R2 buckets, a Dynamic Workflow, report and delivery Queues, an operations dead-letter Queue, a presence Durable Object, static assets, a Worker Loader, and a Workers AI binding. The Cloudflare account owner owns every secret, webhook, log, and retained row.
7
+ This project deploys one Worker, one D1 database, four private R2 buckets, a Dynamic Workflow, report and delivery Queues, an operations dead-letter Queue, presence and case-investigator Durable Objects, static assets, a Worker Loader, and a Workers AI binding. The Cloudflare account owner owns every secret, webhook, log, and retained row.
8
8
 
9
9
  Before deployment:
10
10
 
@@ -1,15 +1,18 @@
1
1
  import { useSyncExternalStore } from "react";
2
2
  import { PageHeader } from "../components/PageHeader";
3
3
  import { readable } from "../lib/format";
4
+ import { dispatchReportFilters } from "../reports/events";
5
+ import { dispatchNavigation } from "../shell/events";
6
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
4
7
  import { dispatchAnalyticsPeriod, dispatchAnalyticsRefresh, dispatchAuditVerification, dispatchReportExport } from "./events";
5
8
  import { getAnalyticsWorkspaceSnapshot, subscribeToAnalyticsWorkspace } from "./store";
6
9
  import type { AnalyticsSeriesRow } from "./types";
7
10
 
8
- function metricTotal(series: AnalyticsSeriesRow[], metricKey: string, dimensionKey: string): number {
9
- return series.filter((row) => row.metricKey === metricKey && row.dimensionKey === dimensionKey)
10
- .reduce((sum, row) => sum + Number(row.value || 0), 0);
11
+ function totals(series: AnalyticsSeriesRow[], metric: string, dimension: string): Array<[string, number]> {
12
+ const values = new Map<string, number>();
13
+ for (const row of series) if (row.metricKey === metric && row.dimensionKey === dimension) values.set(row.dimensionValue, (values.get(row.dimensionValue) || 0) + Number(row.value || 0));
14
+ return [...values].sort((a,b) => b[1]-a[1]);
11
15
  }
12
-
13
16
  function readableDuration(seconds: number | null): string {
14
17
  if (seconds === null) return "—";
15
18
  if (seconds < 60) return `${seconds}s`;
@@ -17,39 +20,49 @@ function readableDuration(seconds: number | null): string {
17
20
  if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
18
21
  return `${Math.round(seconds / 86400)}d`;
19
22
  }
20
-
23
+ function Breakdown({ rows, empty }: { rows: Array<[string, number]>; empty: string }) {
24
+ return <div className="analytics-list">{rows.length ? rows.map(([label,count]) => <div key={label}><span>{label}</span><strong>{count}</strong></div>) : <p>{empty}</p>}</div>;
25
+ }
21
26
  export function AnalyticsWorkspace() {
22
27
  const snapshot = useSyncExternalStore(subscribeToAnalyticsWorkspace, getAnalyticsWorkspaceSnapshot, getAnalyticsWorkspaceSnapshot);
28
+ const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
23
29
  const analytics = snapshot.analytics;
24
- const received = analytics ? metricTotal(analytics.series, "reports_received", "source") : 0;
25
- const decisions = analytics ? metricTotal(analytics.series, "decisions", "maker_type") : 0;
26
- const backlog = analytics?.backlog.reduce((sum, row) => sum + Number(row.count || 0), 0) ?? 0;
27
- const reasons = new Map<string, number>();
28
- for (const row of analytics?.series ?? []) {
29
- if (row.metricKey === "reports_received" && row.dimensionKey === "reason") reasons.set(row.dimensionValue, (reasons.get(row.dimensionValue) ?? 0) + Number(row.value || 0));
30
- }
31
- const reasonRows = [...reasons.entries()].sort((left, right) => right[1] - left[1]);
32
-
33
- return (
34
- <div className="page-stack" data-react-slice="analytics-workspace">
35
- <PageHeader titleId="analytics-title" title="Reports and outcomes" description="Review report volume, decisions, backlogs, and audit checks.">
36
- <div className="surface-buttons">
37
- {snapshot.canVerifyAudit ? <button className="secondary" type="button" disabled={snapshot.auditPending} onClick={dispatchAuditVerification}>{snapshot.auditPending ? "Verifying…" : "Verify audit"}</button> : null}
38
- {snapshot.canExport ? <button className="secondary" type="button" disabled={snapshot.exportPending} onClick={dispatchReportExport}>{snapshot.exportPending ? "Exporting…" : "Export CSV"}</button> : null}
39
- <button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canRead} onClick={dispatchAnalyticsRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button>
40
- </div>
41
- </PageHeader>
42
- <section className="surface analytics-surface" aria-labelledby="analytics-title">
43
- <div className="surface-head analytics-toolbar"><div><h2>Last {snapshot.days} days</h2><p>Counts describe reports and recorded decisions, not verified incidents.</p></div><label>Period<select value={snapshot.days} onChange={(event) => dispatchAnalyticsPeriod(Number(event.currentTarget.value))}><option value="7">7 days</option><option value="30">30 days</option><option value="90">90 days</option><option value="180">180 days</option><option value="365">365 days</option></select></label></div>
44
- <div className="analytics-metrics" aria-busy={snapshot.loading || snapshot.refreshing}>
45
- <article><span>Reports received</span><strong>{snapshot.loading ? "—" : received}</strong><small>Allegations, not confirmed incidents</small></article>
46
- <article><span>Decisions recorded</span><strong>{snapshot.loading ? "—" : decisions}</strong><small>Human and bounded AI</small></article>
47
- <article><span>Median resolution</span><strong>{snapshot.loading ? "—" : readableDuration(analytics?.resolutionSeconds.median ?? null)}</strong><small>{snapshot.loading ? "P95 —" : `P95 ${readableDuration(analytics?.resolutionSeconds.p95 ?? null)}`}</small></article>
48
- <article><span>Open backlog</span><strong>{snapshot.loading ? "—" : backlog}</strong><small>Across visible operational states</small></article>
49
- </div>
50
- {snapshot.loading ? <div className="skeleton-block" role="status" aria-label="Loading reports and outcomes" /> : <div className="analytics-body"><div><h3>Reports by allegation</h3><div className="analytics-list">{reasonRows.length ? reasonRows.map(([reason, count]) => <div key={reason}><span>{readable(reason)}</span><strong>{count}</strong></div>) : <p>No report reasons were recorded in this period.</p>}</div></div><div><h3>Safeguards</h3><p className="audit-verification">{snapshot.auditVerification}</p><ul>{(analytics?.limitations ?? []).map((limitation) => <li key={limitation}>{limitation}</li>)}</ul></div></div>}
51
- <p className="error surface-error" role="alert">{snapshot.error}</p>
52
- </section>
53
- </div>
54
- );
30
+ const series = analytics?.series || [];
31
+ const received = totals(series, "reports_received", "source").reduce((sum,[,count]) => sum+count,0);
32
+ const makers = totals(series, "decisions", "maker_type");
33
+ const decisions = makers.reduce((sum,[,count]) => sum+count,0);
34
+ const backlog = analytics?.backlog.reduce((sum,row) => sum+Number(row.count || 0),0) ?? 0;
35
+ const coverage = analytics?.evidenceCoverage;
36
+ const number = (value: number | undefined) => snapshot.loading || !analytics ? "—" : value ?? 0;
37
+ return <div className="page-stack insights-workspace" data-react-slice="analytics-workspace">
38
+ <PageHeader titleId="analytics-title" title="Insights" description="See what needs attention, what was decided, and which outcomes were confirmed.">
39
+ <div className="surface-buttons">
40
+ {snapshot.canVerifyAudit ? <button className="secondary" type="button" disabled={snapshot.auditPending} onClick={dispatchAuditVerification}>{snapshot.auditPending ? "Verifying…" : "Verify audit"}</button> : null}
41
+ {snapshot.canExport ? <button className="secondary" type="button" disabled={snapshot.exportPending} onClick={dispatchReportExport}>{snapshot.exportPending ? "Exporting…" : "Export CSV"}</button> : null}
42
+ <button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canRead} onClick={dispatchAnalyticsRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button>
43
+ </div>
44
+ </PageHeader>
45
+ <section className="surface analytics-surface" aria-labelledby="analytics-title">
46
+ <div className="surface-head analytics-toolbar"><div><h2>Last {snapshot.days} days</h2><p>{analytics?.queueScope ? "Cases in your permitted queues." : "Cases across your workspace."} {analytics ? `Updated ${new Date(analytics.generatedAt).toLocaleTimeString()}.` : "Loading recorded outcomes."}</p></div><label>Period<select value={snapshot.days} onChange={event => dispatchAnalyticsPeriod(Number(event.currentTarget.value))}><option value="7">7 days</option><option value="30">30 days</option><option value="90">90 days</option><option value="180">180 days</option><option value="365">365 days</option></select></label></div>
47
+ <div className="analytics-metrics" aria-busy={snapshot.loading || snapshot.refreshing}>
48
+ <article><span>Cases received</span><strong>{number(received)}</strong><small>Recorded allegations</small></article>
49
+ <article><span>Decisions recorded</span><strong>{number(decisions)}</strong><small>{makers.length ? makers.map(([maker,count]) => `${count} ${readable(maker).toLowerCase()}`).join(" · ") : "No decisions in this period"}</small></article>
50
+ <article><span>Median resolution</span><strong>{snapshot.loading ? "—" : readableDuration(analytics?.resolutionSeconds.median ?? null)}</strong><small>{analytics?.resolutionSeconds.count ?? 0} resolved cases · P95 {readableDuration(analytics?.resolutionSeconds.p95 ?? null)}</small></article>
51
+ <article><span>Open backlog</span><strong>{number(backlog)}</strong><small>Current work, across all dates</small></article>
52
+ </div>
53
+ <p className="error surface-error" role="alert">{snapshot.error}</p>
54
+ </section>
55
+ {snapshot.loading ? <div className="skeleton-block" role="status" aria-label="Loading Insights" /> : analytics ? <>
56
+ <div className="insights-grid">
57
+ <section className="surface insights-panel"><h2>Evidence and uncertainty</h2><p>Of {coverage?.receivedCases ?? 0} cases received in this period.</p><Breakdown rows={[["With available retrieved evidence",coverage?.withRetrievedEvidence ?? 0],["With unavailable, invalid or expired findings",coverage?.withEvidenceGaps ?? 0],["With recorded uncertainty",coverage?.withRecordedUncertainty ?? 0]]} empty="No cases in this period." /><small>These groups can overlap. A stored finding is evidence to review, not proof of a violation.</small></section>
58
+ <section className="surface insights-panel"><h2>Decision outcomes</h2><p>Includes each recorded decision, including revised decisions.</p><Breakdown rows={totals(series,"decisions","outcome").map(([code,count]) => [readable(code),count])} empty="No decisions have been recorded in this period." /></section>
59
+ <section className="surface insights-panel"><h2>Customer actions</h2><p>Proposals and customer acknowledgements remain distinct.</p><Breakdown rows={(analytics?.actions || []).map(row => [`${row.pipeline === "playbook" ? "Playbook" : "Direct review"} · ${readable(row.state)}`,row.count])} empty="No customer actions were proposed in this period." />{shell.session?.permissions.readOperations ? <button type="button" className="text-button" onClick={() => dispatchNavigation("operations")}>Inspect delivery and recovery</button> : null}</section>
60
+ <section className="surface insights-panel"><h2>Participant notices</h2><p>Delivery is tracked separately for each audience.</p><Breakdown rows={(analytics?.notices || []).map(row => [`${row.audience === "affected_user" ? "Affected person" : "Reporter"} · ${row.state === "suppressed" ? "No delivery channel" : readable(row.state)}`,row.count])} empty="No participant notices were created in this period." /><small>Delivered means handed to the configured provider. Reading is not measured.</small></section>
61
+ <section className="surface insights-panel"><h2>Independent appeals</h2><p>Appeals filed in this period, by their current state.</p><Breakdown rows={(analytics?.appeals || []).map(row => [readable(row.state),row.count])} empty="No appeals were filed in this period." />{shell.session?.permissions.reviewAppeals ? <button type="button" className="text-button" onClick={() => dispatchNavigation("appeals")}>Open appeals</button> : null}</section>
62
+ <section className="surface insights-panel"><h2>Quality review coverage</h2><p>Completed reviews out of selected samples, by selection reason.</p><div className="analytics-list">{analytics?.audits?.length ? analytics.audits.map(row => <div key={row.reason}><span>{readable(row.reason)}</span><strong>{row.reviewed} / {row.selected}</strong></div>) : <p>No quality samples were selected in this period.</p>}</div><small>Sampling and appeal outcomes do not establish an overall accuracy score.</small>{shell.session?.permissions.reviewQuality ? <button type="button" className="text-button" onClick={() => dispatchNavigation("ai-quality")}>Review samples</button> : null}</section>
63
+ </div>
64
+ <section className="surface insights-panel"><h2>Work waiting now</h2><p>Open a queue to work on the cases behind these counts.</p><div className="insights-backlog">{analytics?.backlog.length ? analytics.backlog.map(row => <button className="secondary" type="button" key={`${row.queueId}:${row.state}`} onClick={() => { dispatchReportFilters({ search:"", queueId:row.queueId || "", priority:"", age:"", state:row.state, sort:"oldest", ownership:"" }); dispatchNavigation("reports"); }}><span>{row.queueName || "Unassigned"}<small>{readable(row.state)} · oldest {readableDuration(row.oldestAgeSeconds ?? null)}</small></span><strong>{row.count}</strong></button>) : <p>No open backlog in your queue scope.</p>}</div></section>
65
+ <section className="surface insights-panel"><h2>How to read these numbers</h2><Breakdown rows={totals(series,"reports_received","reason").map(([reason,count]) => [readable(reason),count])} empty="No allegations recorded in this period." /><p className="audit-verification">{snapshot.auditVerification}</p><ul>{(analytics?.limitations || []).map(limitation => <li key={limitation}>{limitation}</li>)}</ul></section>
66
+ </> : <section className="surface insights-panel"><h2>Insights are unavailable</h2><p>Use Refresh to try loading the recorded outcomes again.</p></section>}
67
+ </div>;
55
68
  }
@@ -31,7 +31,9 @@ const initialSnapshot = (): AnalyticsWorkspaceSnapshot => ({
31
31
  });
32
32
 
33
33
  let actorId = "";
34
+ let accessKey = "";
34
35
  let started = false;
36
+ let requestVersion = 0;
35
37
 
36
38
  function withPermissions(snapshot: AnalyticsWorkspaceSnapshot): AnalyticsWorkspaceSnapshot {
37
39
  const permissions = getShellSnapshot().session?.permissions;
@@ -44,10 +46,12 @@ function withPermissions(snapshot: AnalyticsWorkspaceSnapshot): AnalyticsWorkspa
44
46
  }
45
47
 
46
48
  function reset(): void {
49
+ requestVersion += 1;
47
50
  setAnalyticsWorkspaceSnapshot(initialSnapshot());
48
51
  }
49
52
 
50
53
  async function loadAnalytics(): Promise<void> {
54
+ const version = ++requestVersion;
51
55
  const session = getShellSnapshot().session;
52
56
  if (!session?.permissions.readAnalytics) {
53
57
  setAnalyticsWorkspaceSnapshot(withPermissions({ ...initialSnapshot(), loading: false }));
@@ -57,11 +61,13 @@ async function loadAnalytics(): Promise<void> {
57
61
  setAnalyticsWorkspaceSnapshot(withPermissions({ ...current, error: "", refreshing: !current.loading }));
58
62
  try {
59
63
  const result = await requestJson<AnalyticsResponse>(`/v1/admin/analytics?days=${current.days}`);
64
+ if (version !== requestVersion) return;
60
65
  setAnalyticsWorkspaceSnapshot((snapshot) => withPermissions({ ...snapshot, analytics: result.analytics }));
61
66
  } catch (cause) {
67
+ if (version !== requestVersion) return;
62
68
  setAnalyticsWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "Analytics could not be loaded.") }));
63
69
  } finally {
64
- setAnalyticsWorkspaceSnapshot((snapshot) => withPermissions({ ...snapshot, loading: false, refreshing: false }));
70
+ if (version === requestVersion) setAnalyticsWorkspaceSnapshot((snapshot) => withPermissions({ ...snapshot, loading: false, refreshing: false }));
65
71
  }
66
72
  }
67
73
 
@@ -112,11 +118,13 @@ export function startAnalyticsController(): void {
112
118
  subscribeToShell(() => {
113
119
  const session = getShellSnapshot().session;
114
120
  const nextActorId = session?.actor.id || "";
115
- if (nextActorId === actorId) {
121
+ const nextAccessKey = JSON.stringify([nextActorId, session?.queueScope, session?.permissions.readAnalytics]);
122
+ if (nextActorId === actorId && nextAccessKey === accessKey) {
116
123
  setAnalyticsWorkspaceSnapshot((snapshot) => withPermissions(snapshot));
117
124
  return;
118
125
  }
119
126
  actorId = nextActorId;
127
+ accessKey = nextAccessKey;
120
128
  reset();
121
129
  if (session) void loadAnalytics();
122
130
  });
@@ -8,6 +8,7 @@ export interface AnalyticsSeriesRow {
8
8
 
9
9
  export interface AnalyticsBacklogRow {
10
10
  queueId?: string | null;
11
+ queueName?: string | null;
11
12
  state: string;
12
13
  count: number;
13
14
  oldestAgeSeconds?: number | null;
@@ -20,6 +21,12 @@ export interface AnalyticsRecord {
20
21
  series: AnalyticsSeriesRow[];
21
22
  backlog: AnalyticsBacklogRow[];
22
23
  resolutionSeconds: { count: number; median: number | null; p90: number | null; p95: number | null };
24
+ queueScope?: string[] | null;
25
+ evidenceCoverage: { receivedCases: number; withRetrievedEvidence: number; withEvidenceGaps: number; withRecordedUncertainty: number };
26
+ actions: Array<{ pipeline: string; state: string; count: number }>;
27
+ notices: Array<{ audience: string; state: string; count: number }>;
28
+ appeals: Array<{ state: string; count: number }>;
29
+ audits: Array<{ reason: string; selected: number; reviewed: number }>;
23
30
  limitations: string[];
24
31
  }
25
32
 
@@ -0,0 +1,171 @@
1
+ import { useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { dateTime, readable, safeCustomerUrl } from "../lib/format";
3
+ import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
4
+ import { dispatchAppealRefresh } from "./events";
5
+ import type { AppealRecord } from "./types";
6
+ import { useAppealDraftGuard } from "./useAppealDraftGuard";
7
+ import "./appeal-corrections.css";
8
+
9
+ type Field = { type: string; title?: string; description?: string; enum?: Array<string | number>; minLength?: number; maxLength?: number; minimum?: number; maximum?: number };
10
+ type Capability = { id: string; name: string; actionCode: string; inputSchema: { properties: Record<string, Field>; required?: string[] } };
11
+ type Original = { sourceKey: string; operationId: string; actionCode: string; state: string; target: Record<string, unknown>; capabilityIds: string[] };
12
+ type Correction = { id: string; sourceKey: string; mode: string; assignedOperatorName: string; assignedOperatorId: string; dueAt: string; instructions: string; verifiedAt: string | null; verifiedBy: string | null; evidenceReference: string | null; evidenceNote: string | null; state: string | null; acceptedAt: string | null; lastErrorCode: string | null; deliveryTerminal: number; operationId: string | null; actionCode: string | null };
13
+ type Snapshot = { canApprove: boolean; originals: Original[]; capabilities: Capability[]; owners: Array<{ id: string; displayName: string }>; corrections: Correction[] };
14
+ const localDeadline = () => { const date = new Date(Date.now() + 86400000); return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16); };
15
+ const endpoint = (appealId: string) => `/v1/admin/appeals/${encodeURIComponent(appealId)}/corrections`;
16
+
17
+ function customerItemUrl(value: unknown): URL | null {
18
+ if (typeof value !== "string") return null;
19
+ try {
20
+ const safeUrl = safeCustomerUrl(value);
21
+ if (!safeUrl) return null;
22
+ const url = new URL(safeUrl);
23
+ return !url.username && !url.password ? url : null;
24
+ } catch { return null; }
25
+ }
26
+
27
+ function customerFacts(value: unknown, path: string[] = [], depth = 0): Array<{ label: string; value: string }> {
28
+ if (value === null || value === undefined || depth > 4) return [];
29
+ if (typeof value === "object") {
30
+ return Object.entries(value).slice(0, 20).flatMap(([key, child]) => customerFacts(child, [...path, Array.isArray(value) ? `Item ${Number(key) + 1}` : readable(key)], depth + 1)).slice(0, 20);
31
+ }
32
+ if (!["string", "number", "boolean"].includes(typeof value)) return [];
33
+ return [{ label: path.join(" / "), value: typeof value === "boolean" ? value ? "Yes" : "No" : String(value) }];
34
+ }
35
+
36
+ function CorrectionTarget({ target }: { target: Original["target"] }) {
37
+ const itemUrl = customerItemUrl(target.customer_url);
38
+ const fields = [
39
+ { label: "Item type", value: typeof target.type === "string" ? readable(target.type) : "" },
40
+ { label: "Item reference", value: typeof target.reference === "string" ? target.reference : "" },
41
+ { label: "Owner reference", value: typeof target.owner_reference === "string" ? target.owner_reference : "" },
42
+ ].filter(field => field.value);
43
+ const facts = customerFacts(target.signed_facts);
44
+ return <div className="appeal-target-summary" role="group" aria-label="Original customer item">
45
+ <dl className="appeal-target-fields">{fields.map(field => <div key={field.label}><dt>{field.label}</dt><dd>{field.value}</dd></div>)}</dl>
46
+ {itemUrl && <p className="appeal-target-link"><a href={itemUrl.href} target="_blank" rel="noopener noreferrer" aria-label="Open customer item (opens in a new tab)">Open customer item</a><span>{itemUrl.host}</span></p>}
47
+ {facts.length > 0 && <details className="appeal-target-facts"><summary>Customer context</summary><dl className="appeal-target-fields">{facts.map((fact, index) => <div key={`${fact.label}:${index}`}><dt>{fact.label}</dt><dd>{fact.value}</dd></div>)}</dl></details>}
48
+ </div>;
49
+ }
50
+
51
+ function CorrectionPlan({ appealId, original, snapshot, actorId, refresh }: { appealId: string; original: Original; snapshot: Snapshot; actorId: string; refresh: () => void }) {
52
+ const capabilities = snapshot.capabilities.filter(item => original.capabilityIds.includes(item.id));
53
+ const [method, setMethod] = useState(capabilities[0]?.id ?? "manual");
54
+ const [owner, setOwner] = useState(snapshot.owners.some(person => person.id === actorId) ? actorId : "");
55
+ const [deadline, setDeadline] = useState(localDeadline);
56
+ const [instructions, setInstructions] = useState("");
57
+ const [payload, setPayload] = useState<Record<string, string | number | boolean>>({});
58
+ const [approved, setApproved] = useState(false);
59
+ const [pending, setPending] = useState(false);
60
+ const [error, setError] = useState("");
61
+ const key = useRef(crypto.randomUUID());
62
+ const capability = capabilities.find(item => item.id === method);
63
+ useAppealDraftGuard(true, Boolean(instructions || Object.keys(payload).length), pending);
64
+ // React clears currentTarget after the handler. Capture primitive values before
65
+ // enqueueing a functional update, which may run after the event has returned.
66
+ const changePayload = (name: string, value: string, type: string) => {
67
+ const converted = type === "boolean" ? value === "true" : type === "string" ? value : Number(value);
68
+ setPayload(previous => {
69
+ const next = { ...previous };
70
+ if (value === "" && type !== "string") delete next[name];
71
+ else next[name] = converted;
72
+ return next;
73
+ });
74
+ };
75
+
76
+ const submit = async (event: FormEvent) => {
77
+ event.preventDefault(); if (pending) return; setPending(true); setError("");
78
+ try {
79
+ await requestJson(endpoint(appealId), { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": key.current }, body: JSON.stringify({ source_key: original.sourceKey, mode: capability ? "automated" : "manual", component_version_id: capability?.id, payload: capability ? payload : undefined, assigned_operator_id: owner, due_at: new Date(deadline).toISOString(), instructions, approved }) });
80
+ setInstructions(""); setPayload({}); refresh();
81
+ } catch (cause) {
82
+ if (cause instanceof ApiRequestError && cause.status >= 400 && cause.status < 500) key.current = crypto.randomUUID();
83
+ setError(errorMessage(cause, "The correction could not be approved. Your entries are still here."));
84
+ } finally { setPending(false); }
85
+ };
86
+ return <form className="appeal-correction-form" onSubmit={event => void submit(event)}>
87
+ <label>How will this be corrected?<select value={method} disabled={pending} onChange={event => { setMethod(event.currentTarget.value); setPayload({}); setApproved(false); }}>
88
+ {capabilities.map(item => <option key={item.id} value={item.id}>{item.name}</option>)}<option value="manual">Arrange and verify a manual correction</option>
89
+ </select></label>
90
+ {!capabilities.length && <p>No compatible automated correction is configured for this action. Assign someone to complete it in the customer application.</p>}
91
+ {capability && <><p>The customer application will receive <strong>{readable(capability.actionCode)}</strong> for the original target shown above. The original operation is included automatically.</p>
92
+ {Object.entries(capability.inputSchema.properties).filter(([name]) => name !== "original_operation_id").map(([name, field]) => <label key={`${capability.id}:${name}`}>{field.title || readable(name)}
93
+ {field.type === "boolean" ? <select value={String(payload[name] ?? "")} required={capability.inputSchema.required?.includes(name)} disabled={pending} onChange={event => changePayload(name, event.currentTarget.value, "boolean")}><option value="">Choose…</option><option value="true">Yes</option><option value="false">No</option></select>
94
+ : field.enum ? <select value={String(payload[name] ?? "")} required={capability.inputSchema.required?.includes(name)} disabled={pending} onChange={event => changePayload(name, event.currentTarget.value, field.type)}><option value="">Choose…</option>{field.enum.map(value => <option key={value} value={value}>{readable(String(value))}</option>)}</select>
95
+ : <input type={field.type === "string" ? "text" : "number"} step={field.type === "integer" ? 1 : "any"} value={String(payload[name] ?? "")} required={capability.inputSchema.required?.includes(name)} minLength={field.minLength} maxLength={field.maxLength} min={field.minimum} max={field.maximum} disabled={pending} onChange={event => changePayload(name, event.currentTarget.value, field.type)} />}
96
+ {field.description && <small>{field.description}</small>}
97
+ </label>)}
98
+ </>}
99
+ <label>Responsible person<select value={owner} required disabled={pending} onChange={event => setOwner(event.currentTarget.value)}><option value="">Choose a person…</option>{snapshot.owners.map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label>
100
+ <label>Complete by<input type="datetime-local" required value={deadline} disabled={pending} onChange={event => setDeadline(event.currentTarget.value)} /></label>
101
+ <label>{capability ? "Why this correction is approved" : "What the responsible person needs to do"}<textarea minLength={3} maxLength={4000} required value={instructions} disabled={pending} onChange={event => setInstructions(event.currentTarget.value)} /></label>
102
+ <label className="appeal-correction-approval"><input type="checkbox" checked={approved} required disabled={pending} onChange={event => setApproved(event.currentTarget.checked)} /><span>I reviewed the original target and approve this specific {capability ? "customer change" : "manual correction assignment"}.</span></label>
103
+ <button className="primary" type="submit" disabled={pending || !approved}>{pending ? "Saving correction…" : capability ? "Approve and send correction" : "Assign manual correction"}</button>
104
+ {error && <p className="error" role="alert">{error}</p>}
105
+ </form>;
106
+ }
107
+
108
+ function CorrectionAssignment({ appealId, correction, owners, refresh }: { appealId: string; correction: Correction; owners: Snapshot["owners"]; refresh: () => void }) {
109
+ const [owner, setOwner] = useState(owners.some(person => person.id === correction.assignedOperatorId) ? correction.assignedOperatorId : "");
110
+ const [deadline, setDeadline] = useState(localDeadline); const [note, setNote] = useState(""); const [manual, setManual] = useState(false);
111
+ const [pending, setPending] = useState(false); const [error, setError] = useState(""); const key = useRef(crypto.randomUUID());
112
+ const submit = async (event: FormEvent) => {
113
+ event.preventDefault(); if (pending) return; setPending(true); setError("");
114
+ try { await requestJson(`${endpoint(appealId)}/${encodeURIComponent(correction.id)}/assignment`, {method: "POST", headers: {...mutationHeaders(), "idempotency-key": key.current}, body: JSON.stringify({assigned_operator_id: owner, due_at: new Date(deadline).toISOString(), note, manual_recovery: manual})}); setNote(""); refresh(); }
115
+ catch(cause) { if (cause instanceof ApiRequestError && cause.status >= 400 && cause.status < 500) key.current = crypto.randomUUID(); setError(errorMessage(cause, "The assignment could not be updated.")); }
116
+ finally { setPending(false); }
117
+ };
118
+ return <details><summary>Change responsibility or recovery plan</summary><form className="appeal-correction-form" onSubmit={event => void submit(event)}>
119
+ <label>New responsible person<select value={owner} required disabled={pending} onChange={event => setOwner(event.currentTarget.value)}><option value="">Choose a person…</option>{owners.map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label>
120
+ <label>New deadline<input type="datetime-local" required value={deadline} disabled={pending} onChange={event => setDeadline(event.currentTarget.value)} /></label>
121
+ <label>Handoff and next steps<textarea required minLength={3} maxLength={4000} value={note} disabled={pending} onChange={event => setNote(event.currentTarget.value)} /></label>
122
+ {correction.mode === "automated" && correction.state === "failed" && correction.deliveryTerminal === 1 && <label className="appeal-correction-approval"><input type="checkbox" checked={manual} disabled={pending} onChange={event => setManual(event.currentTarget.checked)} /><span>Assign manual recovery. The stopped automated attempt stays in the history; an independent reviewer must verify the actual customer correction.</span></label>}
123
+ <button type="submit" disabled={pending}>{pending ? "Saving assignment…" : "Save correction assignment"}</button>{error && <p className="error" role="alert">{error}</p>}
124
+ </form></details>;
125
+ }
126
+
127
+ function CorrectionProgress({ appealId, correction, canApprove, owners, refresh }: { appealId: string; correction: Correction; canApprove: boolean; owners: Snapshot["owners"]; refresh: () => void }) {
128
+ const [reference, setReference] = useState(""); const [note, setNote] = useState("");
129
+ const [pending, setPending] = useState(false); const [error, setError] = useState(""); const key = useRef(crypto.randomUUID());
130
+ useAppealDraftGuard(true, Boolean(reference || note), pending);
131
+ const mutate = async (kind: "verify" | "retry") => {
132
+ if (pending) return; setPending(true); setError("");
133
+ try { await requestJson(`${endpoint(appealId)}/${encodeURIComponent(correction.id)}/${kind}`, { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": key.current }, body: JSON.stringify({ reference, note }) }); setReference(""); setNote(""); refresh(); }
134
+ catch (cause) { if (cause instanceof ApiRequestError && cause.status >= 400 && cause.status < 500) key.current = crypto.randomUUID(); setError(errorMessage(cause, "The correction could not be updated.")); }
135
+ finally { setPending(false); }
136
+ };
137
+ const status = correction.verifiedAt ? correction.mode === "automated" ? "Confirmed by customer application" : "Verified by independent reviewer" : correction.mode === "manual" ? "Manual correction outstanding" : correction.state === "failed" ? "Customer correction needs attention" : correction.acceptedAt ? "Accepted — awaiting customer confirmation" : "Awaiting customer completion";
138
+ return <div className="appeal-correction-progress"><p className="appeal-correction-action"><strong>Correction:</strong> {correction.mode === "manual" ? "Manual customer correction" : correction.actionCode ? readable(correction.actionCode) : "Approved customer change"}</p><h5>{status}</h5>
139
+ <p><strong>{correction.assignedOperatorName}</strong> is responsible · Due {dateTime(correction.dueAt)}</p><p>{correction.instructions}</p>
140
+ {correction.verifiedAt ? <><p>Verified {dateTime(correction.verifiedAt)}</p>{correction.mode === "manual" ? <><p>Human verification: {correction.evidenceNote}</p><p>Evidence reference: {correction.evidenceReference}</p></> : <p>The customer returned a matching applied receipt for this correction.</p>}</>
141
+ : correction.mode === "manual" ? canApprove ? <form className="appeal-correction-form" onSubmit={event => { event.preventDefault(); void mutate("verify"); }}><p>After the responsible person completes the work, independently check the customer application and record what proves the correction.</p><label>Customer evidence reference<input minLength={3} maxLength={500} required value={reference} disabled={pending} onChange={event => setReference(event.currentTarget.value)} /></label><label>How you verified the correction<textarea minLength={3} maxLength={4000} required value={note} disabled={pending} onChange={event => setNote(event.currentTarget.value)} /></label><button className="primary" type="submit" disabled={pending}>{pending ? "Saving verification…" : "Record verified correction"}</button></form> : <p>The independent appeal reviewer must verify the completed work.</p>
142
+ : <><p>The appeal remains open until the customer confirms this change and the participant notice is handed to its channel.</p>{correction.lastErrorCode && <p role="status">{readable(correction.lastErrorCode)}</p>}{canApprove && correction.state === "failed" && correction.deliveryTerminal === 1 && <button type="button" disabled={pending} onClick={() => void mutate("retry")}>{pending ? "Requesting retry…" : "Retry the same approved correction"}</button>}</>}
143
+ {correction.operationId && <details className="appeal-operation-details"><summary>{correction.mode === "manual" ? "Previous automated attempt" : "Correction operation reference"}</summary>{correction.mode === "manual" && correction.actionCode && <p>{readable(correction.actionCode)}</p>}<p className="appeal-operation-reference">{correction.operationId}</p></details>}
144
+ {!correction.verifiedAt && canApprove && <CorrectionAssignment appealId={appealId} correction={correction} owners={owners} refresh={refresh} />}
145
+ {error && <p className="error" role="alert">{error}</p>}
146
+ </div>;
147
+ }
148
+
149
+ export function AppealFollowup({ appeal, actorId }: { appeal: AppealRecord; actorId: string }) {
150
+ const [snapshot, setSnapshot] = useState<Snapshot | null>(null); const [error, setError] = useState(""); const [revision, setRevision] = useState(0);
151
+ const needsCorrection = appeal.decisions?.some(decision => decision.correctionRequired);
152
+ const refresh = () => { setRevision(value => value + 1); dispatchAppealRefresh(); };
153
+ useEffect(() => {
154
+ if (!needsCorrection) return;
155
+ let current = true;
156
+ requestJson<Snapshot>(endpoint(appeal.id)).then(result => { if (current) { setSnapshot(result); setError(""); } }).catch(cause => { if (current) setError(errorMessage(cause, "The correction status could not be loaded.")); });
157
+ return () => { current = false; };
158
+ }, [appeal.id, needsCorrection, appeal.decisions, revision]);
159
+ return <>{appeal.decisions?.map(decision => <section className="surface appeal-followup" key={decision.id} aria-label="Appeal verdict and follow-up">
160
+ <h3>{readable(decision.outcome)}</h3><p>{decision.rationale}</p>
161
+ <dl><dt>Participant notice</dt><dd>{decision.noticeState === "delivered" ? "Handed to channel" : decision.noticeState ? readable(decision.noticeState) : "No delivery record"}</dd><dt>Follow-up</dt><dd>{decision.completionState === "completed" ? "Complete" : decision.completionState === "legacy" ? "Historical verdict" : "Still outstanding"}</dd></dl>
162
+ {decision.correctionRequired ? <><h4>Correct the customer outcome</h4><p>The verdict preserves the original decision and action. Each original customer action needs its own confirmed correction.</p>
163
+ {!snapshot && !error && <p role="status">Loading customer actions…</p>}
164
+ {snapshot?.originals.map(original => { const correction = snapshot.corrections.find(item => item.sourceKey === original.sourceKey); return <section className="appeal-correction-card" key={original.sourceKey} aria-label={`Correction for ${readable(original.actionCode)}`}><h4>Original action: {readable(original.actionCode)}</h4><CorrectionTarget target={original.target} /><details className="appeal-operation-details"><summary>Original operation reference</summary><p className="appeal-operation-reference">{original.operationId}</p></details>
165
+ {correction ? <CorrectionProgress appealId={appeal.id} correction={correction} canApprove={snapshot.canApprove} owners={snapshot.owners} refresh={refresh} /> : snapshot.canApprove ? <CorrectionPlan appealId={appeal.id} original={original} snapshot={snapshot} actorId={actorId} refresh={refresh} /> : <p>The independent reviewer needs to approve a correction and assign a responsible person.</p>}
166
+ </section>; })}
167
+ {snapshot && !snapshot.originals.length && <p>No original customer action is available here. Open the original case to inspect historical follow-up before completing this appeal.</p>}
168
+ </> : null}
169
+ <p>Open the original case to inspect or retry the participant notice.</p>{error && <p className="error" role="alert">{error}</p>}
170
+ </section>)}</>;
171
+ }
@@ -4,6 +4,12 @@ import { PageHeader } from "../components/PageHeader";
4
4
  import { dispatchAppealDecision, dispatchAppealRefresh, dispatchClaimAppeal, dispatchCloseAppeal, dispatchOpenAppeal } from "./events";
5
5
  import { getAppealWorkspaceSnapshot, subscribeToAppealWorkspace } from "./store";
6
6
  import type { AppealOutcome, AppealRecord } from "./types";
7
+ import { RecordAppealDialog } from "./RecordAppealDialog";
8
+ import { dispatchOpenReport } from "../reports/events";
9
+ import { getAppealWorkspaceSnapshot as getCurrentAppeals } from "./store";
10
+ import { useAppealDraftGuard } from "./useAppealDraftGuard";
11
+ import { AppealFollowup } from "./AppealFollowup";
12
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
7
13
 
8
14
  function AppealRow({ appeal }: { appeal: AppealRecord }) {
9
15
  return (
@@ -17,23 +23,25 @@ function AppealRow({ appeal }: { appeal: AppealRecord }) {
17
23
  }
18
24
 
19
25
  export function AppealWorkspace() {
26
+ const [recording, setRecording] = useState(false);
20
27
  const snapshot = useSyncExternalStore(subscribeToAppealWorkspace, getAppealWorkspaceSnapshot, getAppealWorkspaceSnapshot);
21
28
  return (
22
29
  <div className="page-stack" data-react-slice="appeal-workspace">
23
30
  <PageHeader titleId="appeals-title" title="Appeals" description="Review contested decisions with a separate human owner.">
24
- <div className="surface-buttons"><button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canReview} onClick={dispatchAppealRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
31
+ <div className="surface-buttons">{snapshot.canReview ? <button className="primary" type="button" onClick={() => setRecording(true)}>Record participant appeal</button> : null}<button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canReview} onClick={dispatchAppealRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
25
32
  </PageHeader>
26
33
  <section className="surface appeals-surface" aria-labelledby="appeals-title">
27
34
  <div className="appeal-list" aria-busy={snapshot.loading || snapshot.refreshing}>
28
35
  {snapshot.loading ? <div className="skeleton-block" role="status" aria-label="Loading appeals" /> : snapshot.appeals.length ? snapshot.appeals.map((appeal) => <AppealRow appeal={appeal} key={appeal.id} />) : <div className="empty"><span aria-hidden="true">✓</span><h3>No appeals in this view</h3><p>New appeals will appear here when a participant contests a decision.</p></div>}
29
36
  </div>
30
37
  <p className="error surface-error" role="alert">{snapshot.error}</p>
31
- </section>
38
+ </section><RecordAppealDialog open={recording} onClose={() => setRecording(false)} />
32
39
  </div>
33
40
  );
34
41
  }
35
42
 
36
43
  export function AppealDialog() {
44
+ const shell=useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
37
45
  const snapshot = useSyncExternalStore(subscribeToAppealWorkspace, getAppealWorkspaceSnapshot, getAppealWorkspaceSnapshot);
38
46
  const dialogRef = useRef<HTMLDialogElement>(null);
39
47
  const rationaleRef = useRef<HTMLTextAreaElement>(null);
@@ -43,7 +51,15 @@ export function AppealDialog() {
43
51
  const appeal = snapshot.detail.appeal;
44
52
  const activeClaim = Boolean(appeal?.reviewerId && appeal.claimExpiresAt && Date.parse(appeal.claimExpiresAt) > Date.parse(snapshot.generatedAt));
45
53
  const mine = Boolean(activeClaim && appeal?.reviewerId === snapshot.actorId);
46
- const disabled = !mine || snapshot.detail.pendingAction === "decision";
54
+ const complete = Boolean(appeal && !["submitted", "human_review"].includes(appeal.state));
55
+ const disabled = complete || !mine || !shell.session?.permissions.decideAppeals || Boolean(snapshot.detail.pendingAction);
56
+ useAppealDraftGuard(Boolean(snapshot.detail.appealId), !complete && Boolean(rationale || participantNotice || outcome !== "upheld"), Boolean(snapshot.detail.pendingAction));
57
+ useEffect(()=>{
58
+ if(!snapshot.detail.appealId)return;
59
+ const timer=window.setInterval(()=>{if(!document.hidden&&!getCurrentAppeals().detail.pendingAction)dispatchAppealRefresh();},10000);
60
+ return()=>window.clearInterval(timer);
61
+ },[snapshot.detail.appealId]);
62
+ const openOriginal=()=>{dispatchCloseAppeal();if(!getCurrentAppeals().detail.appealId&&appeal?.reportId)dispatchOpenReport(appeal.reportId);};
47
63
 
48
64
  useEffect(() => {
49
65
  const dialog = dialogRef.current;
@@ -66,7 +82,20 @@ export function AppealDialog() {
66
82
  return (
67
83
  <dialog ref={dialogRef} className="detail-dialog appeal-dialog" aria-labelledby="appeal-reference" onCancel={(event) => { event.preventDefault(); dispatchCloseAppeal(); }} data-react-slice="appeal-dialog">
68
84
  <div className="detail-head"><div><h2 id="appeal-reference">{appeal ? `${appeal.reportReference} appeal` : "Appeal"}</h2><p>{appeal ? readable(appeal.state) : "Loading appeal…"}</p></div><button className="icon-button" type="button" aria-label="Close appeal" onClick={dispatchCloseAppeal}>×</button></div>
69
- {appeal ? <><p>{appeal.reason}</p><div className="claim-bar"><p>{mine ? "This appeal is claimed by you." : activeClaim ? "Another reviewer currently owns this appeal." : "Appeals require a separate human reviewer."}</p>{!activeClaim && snapshot.canReview ? <button className="primary" type="button" disabled={snapshot.detail.pendingAction === "claim"} onClick={dispatchClaimAppeal}>{snapshot.detail.pendingAction === "claim" ? "Claiming…" : "Claim appeal"}</button> : null}</div><form onSubmit={submit}><label>Outcome<select value={outcome} disabled={disabled} onChange={(event) => setOutcome(event.currentTarget.value as AppealOutcome)}><option value="upheld">Uphold</option><option value="modified">Modify</option><option value="reversed">Reverse</option></select></label><label>Internal rationale<textarea ref={rationaleRef} maxLength={8000} required disabled={disabled} value={rationale} onChange={(event) => setRationale(event.currentTarget.value)} /></label><label>Participant notice<textarea maxLength={4000} required disabled={disabled} value={participantNotice} onChange={(event) => setParticipantNotice(event.currentTarget.value)} /></label><button className="primary" type="submit" disabled={disabled}>{snapshot.detail.pendingAction === "decision" ? "Recording…" : "Record appeal decision"}</button></form></> : null}
85
+ {appeal ? <>
86
+ <section className="case-guidance"><h3>Participant’s challenge</h3><p>{appeal.reason}</p></section>
87
+ {appeal.originalDecisionCode ? <section className="surface"><h3>Original decision</h3><p>{readable(appeal.originalDecisionCode)} · {appeal.originalPolicyCode}</p><p>{appeal.originalRationale}</p>{appeal.reportId ? <button type="button" className="secondary" onClick={openOriginal}>Open original report</button> : null}</section> : null}
88
+ {complete ? <AppealFollowup key={appeal.id} appeal={appeal} actorId={shell.session?.permissions.decideAppeals?snapshot.actorId:""} /> : <>
89
+ <div className="claim-bar"><p>{mine ? "This appeal is claimed by you." : activeClaim ? "Another reviewer currently owns this appeal." : "Appeals require a separate human reviewer."}</p>{!activeClaim && snapshot.canReview ? <button className="primary" type="button" disabled={snapshot.detail.pendingAction === "claim"} onClick={dispatchClaimAppeal}>{snapshot.detail.pendingAction === "claim" ? "Claiming…" : "Claim appeal"}</button> : null}</div>
90
+ <form onSubmit={submit}><label>Outcome<select value={outcome} disabled={disabled} onChange={event=>setOutcome(event.currentTarget.value as AppealOutcome)}><option value="upheld">Uphold</option><option value="modified">Modify</option><option value="reversed">Reverse</option></select></label>
91
+ {outcome!=="upheld"?<p>A changed verdict may require a correction in the customer application. The original action remains recorded; verify any correction in the follow-up after submitting.</p>:null}
92
+ <label>Internal rationale<textarea ref={rationaleRef} maxLength={8000} required disabled={disabled} value={rationale} onChange={event=>setRationale(event.currentTarget.value)} /></label>
93
+ <label>Participant notice<textarea maxLength={4000} required disabled={disabled} value={participantNotice} onChange={event=>setParticipantNotice(event.currentTarget.value)} /></label>
94
+ <p>This notice is sent only to the participant who appealed. Keep other participants’ identities and internal notes out of it.</p>
95
+ <button className="primary" type="submit" disabled={disabled}>{snapshot.detail.pendingAction === "decision" ? "Recording…" : "Record appeal decision"}</button>
96
+ </form>
97
+ </>}
98
+ </> : null}
70
99
  <p className="error" role="alert">{snapshot.detail.error}</p>
71
100
  </dialog>
72
101
  );
@@ -0,0 +1,42 @@
1
+ import { useEffect, useId, useRef, useState, type FormEvent } from "react";
2
+ import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
3
+ import { dispatchReportRefresh } from "../reports/events";
4
+ import { dispatchAppealRefresh } from "./events";
5
+
6
+ export function RecordAppealDialog({ open, onClose, reportReference = "", onRecorded }: { open: boolean; onClose: () => void; reportReference?: string; onRecorded?: () => void }) {
7
+ const titleId = useId();
8
+ const ref = useRef<HTMLDialogElement>(null);
9
+ const key = useRef(crypto.randomUUID());
10
+ const [reference, setReference] = useState("");
11
+ const [audience, setAudience] = useState("reporter");
12
+ const [reason, setReason] = useState("");
13
+ const [pending, setPending] = useState(false);
14
+ const [error, setError] = useState("");
15
+ useEffect(() => {
16
+ if (open) { key.current = crypto.randomUUID(); setReference(reportReference); setReason(""); setAudience("reporter"); setError(""); ref.current?.showModal(); }
17
+ else ref.current?.close();
18
+ }, [open, reportReference]);
19
+ const dirty = Boolean(reason || audience !== "reporter" || reference !== reportReference);
20
+ const close = () => { if (!pending && (!dirty || window.confirm("Discard this unsent appeal?"))) onClose(); };
21
+ useEffect(() => {
22
+ const unload = (event: BeforeUnloadEvent) => { if (open && (dirty || pending)) { event.preventDefault(); event.returnValue = ""; } };
23
+ window.addEventListener("beforeunload",unload); return () => window.removeEventListener("beforeunload",unload);
24
+ },[open,dirty,pending]);
25
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
26
+ event.preventDefault(); if (pending) return; setPending(true); setError("");
27
+ try {
28
+ await requestJson("/v1/admin/appeals", { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": key.current }, body: JSON.stringify({ report_reference: reference, audience, reason }) });
29
+ dispatchAppealRefresh(); dispatchReportRefresh(); onRecorded?.(); onClose();
30
+ } catch (cause) { setError(errorMessage(cause, "The appeal could not be recorded.")); }
31
+ finally { setPending(false); }
32
+ };
33
+ return <dialog ref={ref} className="editor-dialog" aria-labelledby={titleId} onKeyDown={event=>event.stopPropagation()} onCancel={(event) => { event.preventDefault(); event.stopPropagation(); close(); }}>
34
+ <div className="detail-head"><div><h2 id={titleId}>Record participant appeal</h2><p>Record a contest received from a participant by email or your support channel. A separate reviewer will assess the original decision.</p></div><button type="button" className="icon-button" disabled={pending} aria-label="Close appeal form" onClick={close}>×</button></div>
35
+ <form className="editor-form" onSubmit={(event) => void submit(event)}>
36
+ <label>Report reference<input required readOnly={Boolean(reportReference)} maxLength={100} placeholder="RPT-…" disabled={pending} value={reference} onChange={(event) => setReference(event.currentTarget.value)} /></label>
37
+ <label>Participant<select value={audience} disabled={pending} onChange={(event) => setAudience(event.currentTarget.value)}><option value="reporter">Reporter</option><option value="affected_user">Affected user</option></select></label>
38
+ <label>Reason for appeal<textarea required minLength={3} maxLength={8000} disabled={pending} value={reason} onChange={(event) => setReason(event.currentTarget.value)} /></label>
39
+ <p className="error" role="alert">{error}</p><div className="editor-footer"><button type="button" className="secondary" disabled={pending} onClick={close}>Cancel</button><button type="submit" className="primary" disabled={pending}>{pending ? "Recording…" : "Record appeal"}</button></div>
40
+ </form>
41
+ </dialog>;
42
+ }
@@ -0,0 +1,29 @@
1
+ .appeal-dialog > .detail-head { background: var(--paper, #fff); }
2
+ .appeal-correction-card { border: 1px solid var(--line, #d8dfdc); border-radius: 12px; padding: 16px; margin-block: 16px; min-width: 0; }
3
+ .appeal-correction-form { display: grid; gap: 16px; min-width: 0; }
4
+ .appeal-correction-form label:not(.appeal-correction-approval) { display: grid; gap: 6px; font-size: .875rem; }
5
+ .appeal-correction-form input:not([type="checkbox"]), .appeal-correction-form select, .appeal-correction-form textarea { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; font-size: 1rem; min-height: 44px; }
6
+ .appeal-correction-form textarea { min-height: 96px; }
7
+ .appeal-correction-approval { display: flex; gap: 10px; align-items: flex-start; font-weight: normal; }
8
+ .appeal-correction-approval input { width: 20px; height: 20px; flex: 0 0 20px; margin-top: 2px; }
9
+ .appeal-correction-card dd, .appeal-operation-reference { overflow-wrap: anywhere; }
10
+ .appeal-operation-reference { font-size: 0.875rem; }
11
+ .appeal-correction-progress h5 { font-size: 1rem; margin-block: 16px 8px; }
12
+ @media (max-width: 600px) { .appeal-correction-card { padding: 12px; } .appeal-correction-form button { width: 100%; min-height: 44px; white-space: normal; } }
13
+
14
+ /* Target identifiers need the full card width. A shared two-column dl otherwise
15
+ lets a long URL consume one column and squeezes the owner into a few letters. */
16
+ .appeal-correction-card .appeal-target-summary { min-width: 0; margin-block: 16px; }
17
+ .appeal-correction-card .appeal-target-fields { display: block; margin: 0; }
18
+ .appeal-correction-card .appeal-target-fields > div { display: block; min-width: 0; margin-block: 0 12px; }
19
+ .appeal-correction-card .appeal-target-fields dt { display: block; float: none; font-size: .8125rem; color: var(--muted, #5c6862); margin: 0 0 4px; }
20
+ .appeal-correction-card .appeal-target-fields dd { display: block; margin: 0; min-width: 0; max-width: 100%; font-size: .9375rem; line-height: 1.5; overflow-wrap: anywhere; word-break: normal; }
21
+ .appeal-target-link { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; margin-block: 12px; }
22
+ .appeal-target-link a { display: inline-flex; align-items: center; min-height: 44px; }
23
+ .appeal-target-link > span { color: var(--muted, #5c6862); font-size: .8125rem; overflow-wrap: anywhere; min-width: 0; }
24
+ .appeal-target-facts summary, .appeal-operation-details summary { display: list-item; min-height: 44px; padding-block: 10px; margin-inline-start: 1.1em; box-sizing: border-box; cursor: pointer; }
25
+ .appeal-target-facts[open] summary { margin-bottom: 8px; }
26
+
27
+ .appeal-correction-action { margin-top: 20px; font-size: 1rem; }
28
+ .appeal-operation-details { margin-block: 8px; font-size: .875rem; }
29
+ .appeal-operation-details p { margin-block: 4px 12px; }