create-safest-tools 0.6.0 → 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 (172) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/src/update.mjs +10 -1
  5. package/template/README.md +2 -2
  6. package/template/console/README.md +2 -2
  7. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  8. package/template/console/analytics/controller.ts +10 -2
  9. package/template/console/analytics/types.ts +7 -0
  10. package/template/console/appeals/AppealFollowup.tsx +171 -0
  11. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  12. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  13. package/template/console/appeals/appeal-corrections.css +29 -0
  14. package/template/console/appeals/controller.ts +40 -14
  15. package/template/console/appeals/events.ts +1 -0
  16. package/template/console/appeals/types.ts +6 -0
  17. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  18. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  19. package/template/console/command/CommandCentre.tsx +101 -64
  20. package/template/console/command/controller.ts +80 -70
  21. package/template/console/command/events.ts +1 -2
  22. package/template/console/command/presentation.ts +29 -0
  23. package/template/console/command/store.ts +4 -4
  24. package/template/console/command/types.ts +26 -21
  25. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  26. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  27. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  28. package/template/console/configuration/types.ts +1 -1
  29. package/template/console/lib/priority.ts +13 -0
  30. package/template/console/operations/CaseEscalations.tsx +142 -0
  31. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  32. package/template/console/operations/case-escalations.css +20 -0
  33. package/template/console/people/PeopleWorkspace.tsx +8 -4
  34. package/template/console/people/PersonAccessDialog.tsx +40 -0
  35. package/template/console/people/types.ts +1 -0
  36. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  37. package/template/console/registry/IntegrationSetup.tsx +41 -0
  38. package/template/console/registry/IntegrationVerification.tsx +54 -0
  39. package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
  40. package/template/console/registry/RegistryDialog.tsx +56 -27
  41. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  42. package/template/console/registry/integration-verification.css +25 -0
  43. package/template/console/reports/CaseActions.tsx +81 -0
  44. package/template/console/reports/CaseContinuity.tsx +27 -0
  45. package/template/console/reports/CaseNotices.tsx +48 -0
  46. package/template/console/reports/CaseOwnership.tsx +78 -0
  47. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  48. package/template/console/reports/EnrichmentFindings.tsx +148 -0
  49. package/template/console/reports/InvestigationBrief.tsx +29 -0
  50. package/template/console/reports/ReportDrawer.tsx +122 -67
  51. package/template/console/reports/ReportWorkspace.tsx +112 -23
  52. package/template/console/reports/controller.ts +125 -60
  53. package/template/console/reports/events.ts +9 -2
  54. package/template/console/reports/load-inbox.ts +17 -0
  55. package/template/console/reports/presence-presentation.ts +52 -0
  56. package/template/console/reports/private-drafts.ts +32 -0
  57. package/template/console/reports/store.ts +2 -2
  58. package/template/console/reports/types.ts +50 -2
  59. package/template/console/reports/use-editing-access.ts +13 -0
  60. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  61. package/template/console/shell/WorkspaceShell.tsx +55 -29
  62. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  63. package/template/console/shell/controller.ts +23 -16
  64. package/template/console/shell/navigation.ts +30 -39
  65. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  66. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  67. package/template/console/workflows/PlaybookFields.tsx +109 -0
  68. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  69. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  70. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  71. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  72. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  73. package/template/console/workflows/templates.ts +3 -3
  74. package/template/console/workflows/types.ts +11 -0
  75. package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
  76. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  77. package/template/migrations/0003_action_confirmation.sql +24 -0
  78. package/template/migrations/0004_notice_recovery.sql +22 -0
  79. package/template/migrations/0005_appeal_continuity.sql +17 -0
  80. package/template/migrations/0006_case_ownership.sql +23 -0
  81. package/template/migrations/0007_integration_verification.sql +21 -0
  82. package/template/migrations/0008_case_escalations.sql +135 -0
  83. package/template/migrations/0009_participant_continuation.sql +61 -0
  84. package/template/migrations/0010_appeal_corrections.sql +41 -0
  85. package/template/npm-shrinkwrap.json +8036 -0
  86. package/template/package.json +31 -22
  87. package/template/public/_headers +16 -1
  88. package/template/public/console/auth-shell.js +22151 -14847
  89. package/template/public/console/release.json +1 -0
  90. package/template/public/console/workspace-features.css +2 -0
  91. package/template/public/embed/embed.css +3 -0
  92. package/template/public/embed/embed.js +19 -1
  93. package/template/public/embed/index.html +2 -0
  94. package/template/public/index.html +9 -6
  95. package/template/public/participant.css +2 -0
  96. package/template/public/participant.html +13 -0
  97. package/template/public/participant.js +111 -0
  98. package/template/public/public-report.js +21 -1
  99. package/template/public/report/index.html +1 -1
  100. package/template/public/styles.css +599 -83
  101. package/template/public/workspace.css +124 -0
  102. package/template/scripts/reports-plan.mjs +1 -1
  103. package/template/shared/case-escalations.ts +31 -0
  104. package/template/shared/integration-progress.ts +27 -0
  105. package/template/shared/notice-inputs.ts +3 -0
  106. package/template/shared/playbook-resources.ts +14 -0
  107. package/template/shared/playbooks.ts +249 -0
  108. package/template/src/agent-executor.ts +25 -8
  109. package/template/src/ai-observability.ts +14 -13
  110. package/template/src/ai-registry-validation.ts +1 -1
  111. package/template/src/appeal-continuity.ts +75 -0
  112. package/template/src/appeal-correction-state.ts +46 -0
  113. package/template/src/appeal-corrections.ts +112 -0
  114. package/template/src/appeal-decisions.ts +85 -0
  115. package/template/src/assistant.ts +3 -4
  116. package/template/src/case-escalations.ts +223 -0
  117. package/template/src/case-investigator.ts +179 -0
  118. package/template/src/component-executor.ts +5 -4
  119. package/template/src/configuration-registry.ts +4 -1
  120. package/template/src/connection-oauth.ts +12 -3
  121. package/template/src/database-readiness.ts +14 -0
  122. package/template/src/human-tasks.ts +152 -55
  123. package/template/src/index.ts +191 -29
  124. package/template/src/installation-admin.ts +2 -1
  125. package/template/src/integration-progress.ts +45 -0
  126. package/template/src/integration-verification.ts +249 -0
  127. package/template/src/investigation-evidence.ts +95 -0
  128. package/template/src/investigator-fixture.ts +30 -0
  129. package/template/src/notice-delivery.ts +19 -0
  130. package/template/src/notice-recovery.ts +81 -0
  131. package/template/src/operator-scope.ts +31 -0
  132. package/template/src/participant-access.ts +163 -0
  133. package/template/src/participant-case.ts +87 -0
  134. package/template/src/participant-customer-access.ts +93 -0
  135. package/template/src/participant-recovery.ts +43 -0
  136. package/template/src/participant-routes.ts +67 -0
  137. package/template/src/platform-registry-validation.ts +25 -2
  138. package/template/src/platform-registry.ts +10 -7
  139. package/template/src/playbook-resources.ts +58 -0
  140. package/template/src/report-admin.ts +9 -2
  141. package/template/src/report-auth.ts +1 -28
  142. package/template/src/report-better-auth.ts +18 -6
  143. package/template/src/report-delivery.ts +149 -148
  144. package/template/src/report-email-inbound.ts +33 -22
  145. package/template/src/report-email-template.ts +5 -2
  146. package/template/src/report-email.ts +7 -30
  147. package/template/src/report-governance.ts +42 -45
  148. package/template/src/report-http.ts +37 -2
  149. package/template/src/report-operations.ts +30 -67
  150. package/template/src/report-operator-accounts.ts +7 -6
  151. package/template/src/report-ownership.ts +64 -0
  152. package/template/src/report-presence.ts +15 -9
  153. package/template/src/report-repository.ts +16 -7
  154. package/template/src/report-turnstile.ts +7 -3
  155. package/template/src/report-types.ts +1 -0
  156. package/template/src/report-workflow.ts +183 -249
  157. package/template/src/workflow-actions.ts +248 -156
  158. package/template/src/workflow-compiler.ts +63 -12
  159. package/template/src/workflow-dynamic-runtime.ts +20 -9
  160. package/template/src/workflow-effects.ts +117 -39
  161. package/template/src/workflow-event-transport.ts +16 -0
  162. package/template/src/workflow-expressions.ts +30 -0
  163. package/template/src/workflow-loader-code.ts +21 -0
  164. package/template/src/workflow-platform-types.ts +3 -0
  165. package/template/src/workflow-platform-validation.ts +23 -1
  166. package/template/src/workflow-repository.ts +131 -28
  167. package/template/src/workflow-runs.ts +61 -13
  168. package/template/src/workflow-simulator.ts +7 -4
  169. package/template/src/workspace-governance.ts +6 -4
  170. package/template/tsconfig.json +1 -0
  171. package/template/vite.console.config.ts +30 -2
  172. 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 \
@@ -18,7 +18,7 @@ npx create-safest-tools safest-resolve \
18
18
  --email-from reports@example.com
19
19
  ```
20
20
 
21
- The `--email-from` address is a real, receivable mailbox used for receipts, follow-ups, outcomes, and replies from reporters or affected users. Use a short base address such as `reports@example.com` on a domain onboarded under Cloudflare Email Service → Email Sending, then enable Email Routing for that domain. Setup creates an exact Worker route for this address, enables plus-addressing for signed case replies, and does not enable catch-all mail. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address. Every customer-facing email automatically uses the organization name, logo, colors, type, and spacing saved under Configuration → Branding.
21
+ The `--email-from` address is a real, receivable mailbox used for receipts, follow-ups, outcomes, and replies from reporters or affected users. Use a short base address such as `reports@example.com` on a domain onboarded under Cloudflare Email Service → Email Sending, then enable Email Routing for that domain. Setup creates an exact Worker route for this address, enables plus-addressing for signed case replies, and does not enable catch-all mail. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address. Every customer-facing email automatically uses the organization name, logo, colors, type, and spacing saved under Settings → Branding.
22
22
 
23
23
  The command creates a local project and prints a read-only infrastructure plan. It does not change Cloudflare unless `--deploy` is supplied or the generated project’s `npm run setup` command is run and explicitly confirmed.
24
24
 
@@ -46,7 +46,7 @@ npm run setup
46
46
 
47
47
  `setup:plan` lists exact resource names and the reporter email route, then exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and verifies Workers Paid from the account's Workers usage model. Standard accounts need no separate billing token; only legacy or ambiguous account models use the temporary Billing Read fallback. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation. After confirmation it verifies Email Routing, enables subaddressing, and deploys only the exact reporter-address route. If Cloudflare interrupts the deployment, run `npm run setup` again; it detects the unfinished installation, reuses the saved authentication choices and exact resources, suppresses expected already-exists errors, and continues through the owner-link step.
48
48
 
49
- A new installation applies one current D1 schema baseline. Later releases add only forward-compatible upgrade migrations.
49
+ A new 0.6.1 installation applies one current D1 schema baseline. Later releases add only forward-compatible upgrade migrations. Because 0.6.1 replaces the earlier development migration chain with that baseline, the updater refuses to modify a deployed pre-0.6.1 project in place. Back up that deployment and create a separately named installation instead. Undeployed scaffolds may cross the boundary normally.
50
50
 
51
51
  After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes. The raw token stays in the URL fragment, D1 stores only its digest, and the installer never writes it to disk. Open it to create the Safest owner account. If it expires, run `npm run owner:setup`. For later break-glass recovery, authenticate Wrangler and run `npm run owner:recover`.
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.6.0",
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: [
package/src/update.mjs CHANGED
@@ -6,6 +6,7 @@ import { buildWranglerConfiguration } from "./config.mjs";
6
6
 
7
7
  const MANIFEST_PATH = ".safest-managed.json";
8
8
  const MANIFEST_SCHEMA_VERSION = 1;
9
+ const FRESH_SCHEMA_BASELINE_VERSION = "0.6.1";
9
10
  const REBUILDABLE_PATHS = new Set(["public/console/auth-shell.js"]);
10
11
  const CUSTOMER_RUNTIME_VAR_KEYS = new Set([
11
12
  "ALLOWED_ACTION_CODES",
@@ -185,6 +186,15 @@ export async function planProjectUpdate({ directory, templateDirectory }) {
185
186
  if (compareVersions(targetVersion, manifest.installedVersion) < 0) {
186
187
  throw new Error(`refusing to downgrade Safest Resolve from ${manifest.installedVersion} to ${targetVersion}`);
187
188
  }
189
+ const deployed = (currentWrangler.d1_databases ?? []).some(({ database_id: id }) => typeof id === "string" && id.length > 0);
190
+ if (deployed
191
+ && compareVersions(manifest.installedVersion, FRESH_SCHEMA_BASELINE_VERSION) < 0
192
+ && compareVersions(targetVersion, FRESH_SCHEMA_BASELINE_VERSION) >= 0) {
193
+ throw new Error(
194
+ `Safest Resolve ${FRESH_SCHEMA_BASELINE_VERSION} starts a new fresh-install schema lineage. `
195
+ + `Back up this ${manifest.installedVersion} deployment, then create and verify a separately named installation instead of updating it in place.`,
196
+ );
197
+ }
188
198
 
189
199
  const added = [];
190
200
  const updated = [];
@@ -205,7 +215,6 @@ export async function planProjectUpdate({ directory, templateDirectory }) {
205
215
  if (!wranglerMatchesManifest(currentWrangler, manifest.wranglerHash)) conflicts.push("wrangler.jsonc");
206
216
 
207
217
  const desiredWrangler = mergeWranglerRuntimeState(buildWranglerConfiguration(reportsConfiguration), currentWrangler);
208
- const deployed = (currentWrangler.d1_databases ?? []).some(({ database_id: id }) => typeof id === "string" && id.length > 0);
209
218
  const uniqueConflicts = [...new Set(conflicts)].sort();
210
219
  return {
211
220
  action: "update",
@@ -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
 
@@ -14,7 +14,7 @@ Before deployment:
14
14
  4. Type the exact installation confirmation when setup requests it. After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes and is never written to disk.
15
15
  5. Open the owner link, create the Safest owner account, then invite administrators and analysts from People. Nobody needs a Cloudflare account to sign in. Names and workspace roles are always displayed separately.
16
16
 
17
- Setup creates D1, four private R2 buckets, and three Queues, applies one current D1 schema baseline, and deploys the Worker with Dynamic Workflows. Later releases add only forward-compatible upgrade migrations. After owner setup, verify the `database`, `workflow_runtime`, `workers_ai`, `object_storage`, and `queues` setup features, then require an authenticated `GET /ready` response of 200. Public `GET /health` is liveness only. Better Auth owns password hashing, OAuth accounts, sessions, and one-time password-reset tokens in D1. Upgrades always back up D1 before migrations and deployment.
17
+ Setup creates D1, four private R2 buckets, and three Queues, applies the current fresh-install D1 baseline, and deploys the Worker with Dynamic Workflows. Version 0.6.1 begins this schema lineage; the updater blocks deployed pre-0.6.1 projects from crossing it in place and directs operators to a verified backup plus separately named installation. Later releases add only forward-compatible migrations. After owner setup, verify the `database`, `workflow_runtime`, `workers_ai`, `object_storage`, and `queues` setup features, then require an authenticated `GET /ready` response of 200. Public `GET /health` is liveness only. Better Auth owns password hashing, OAuth accounts, sessions, and one-time password-reset tokens in D1. Upgrades always back up D1 before migrations and deployment.
18
18
 
19
19
  If the setup link expires, run `npm run owner:setup`. If the owner loses access later, authenticate Wrangler as the Cloudflare account owner and run `npm run owner:recover`; the resulting single-use link creates a 12-hour break-glass session and is recorded in the audit log.
20
20
 
@@ -6,10 +6,10 @@ The console uses React, TypeScript, and Vite. Each product area keeps its DOM, t
6
6
 
7
7
  - **Account access:** sign-in, infrastructure-owner bootstrap, password reset, invitation acceptance, and optional profile-picture upload.
8
8
  - **Workspace shell:** header, responsive navigation, current route, health, analyst identity, and permission-aware navigation.
9
- - **Command centre:** metrics, work that needs attention, queue pressure, recent workflow runs, and setup actions.
9
+ - **Command centre:** an SLA-aware review queue, reviewer availability, queue health, operational health, and the next best action.
10
10
  - **Reports and appeals:** queue filters, presence, claims, participant messages, decisions, delivery history, workflow evidence, and separate appeal ownership.
11
11
  - **Queues and workflows:** queue policy publication, routing rollout, workflow drafts, graph editing, checks, fixture tests, publishing, and queue pins.
12
- - **Components and configuration:** component and connection records, outbound allowlists, intake forms, routing rules, decision policies, message templates, and the visual form builder.
12
+ - **Tools, connections, and configuration:** reusable workflow capabilities, governed outbound routes, intake forms, routing rules, decision policies, message templates, and the visual form builder. Runtime APIs and database tables retain the stable `component` name for backward compatibility.
13
13
  - **Operations and quality:** AI samples, reviewer corrections, incidents, retries, dead-letter evidence, analytics, audit verification, and CSV export.
14
14
  - **People and settings:** invitations, password-reset emails, roles, personal profiles, deployment checks, branding, and reporting channels.
15
15
  - **Ask Safest:** scoped conversations, citations, read-only activity, and links back to reports.
@@ -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
+ }