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
@@ -1,13 +1,18 @@
1
- import { errorMessage, requestJson } from "../lib/http";
1
+ import { clearPrivateDrafts, removePrivateDraft } from "./private-drafts";
2
+ import { loadInbox } from "./load-inbox";
3
+ import { priorityName } from "../lib/priority";
4
+ import { ApiRequestError, errorMessage, requestJson } from "../lib/http";
5
+ import { readable } from "../lib/format";
2
6
  import { getQueueWorkspaceSnapshot, subscribeToQueueWorkspace } from "../queues/store";
3
7
  import type { QueueRecord } from "../queues/types";
4
8
  import { getShellSnapshot, setShellSnapshot, subscribeToShell } from "../shell/store";
5
- import { reportEvents } from "./events";
9
+ import { isReportSection, reportEvents } from "./events";
6
10
  import { getReportWorkspaceSnapshot, setReportWorkspaceSnapshot } from "./store";
7
11
  import type {
8
12
  HumanDecisionInput,
9
13
  PresenceAnalyst,
10
14
  ReportDetailRecord,
15
+ ReportSection,
11
16
  ReportFilters,
12
17
  ReporterMessageInput,
13
18
  ReportSummary,
@@ -17,6 +22,7 @@ import type {
17
22
  } from "./types";
18
23
 
19
24
  interface RawReport {
25
+ assignment?: RawReport["claim"];
20
26
  id: string;
21
27
  reference: string;
22
28
  targetType: string;
@@ -42,14 +48,14 @@ interface RawPresenceAnalyst {
42
48
  activeReportId?: string | null;
43
49
  }
44
50
 
45
- interface ReportsResponse { reports?: RawReport[] }
46
51
  interface ReportResponse { report: ReportDetailRecord }
47
52
  interface TimelineResponse { events?: ReportDetailRecord["timeline"] }
48
53
  interface WorkflowRunsResponse { runs?: WorkflowRunRecord[] }
49
54
  interface WorkflowActionsResponse { actions?: WorkflowActionRecord[] }
55
+ interface FindingsResponse { findings?: ReportDetailRecord["findings"] }
50
56
  interface PresenceResponse { analysts?: RawPresenceAnalyst[] }
51
57
 
52
- const defaultFilters = (): ReportFilters => ({ search: "", queueId: "", priority: "", age: "", state: "", sort: "oldest" });
58
+ const defaultFilters = (): ReportFilters => ({ search: "", queueId: "", priority: "", age: "", state: "needs_review", sort: "priority" });
53
59
  const initialSnapshot = (): ReportWorkspaceSnapshot => ({
54
60
  loading: true,
55
61
  refreshing: false,
@@ -61,7 +67,7 @@ const initialSnapshot = (): ReportWorkspaceSnapshot => ({
61
67
  reports: [],
62
68
  presence: [],
63
69
  actionCodes: [],
64
- permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false },
70
+ permissions: { reassignReports: false, manageConfiguration: false, claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
65
71
  detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
66
72
  });
67
73
 
@@ -69,6 +75,7 @@ let actorId = "";
69
75
  let rawReports: RawReport[] = [];
70
76
  let rawPresence: RawPresenceAnalyst[] = [];
71
77
  let detailRequestVersion = 0;
78
+ let listRequestVersion = 0;
72
79
  let presenceSessionId = crypto.randomUUID();
73
80
  let presenceSocket: WebSocket | null = null;
74
81
  let presenceReconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -78,19 +85,12 @@ let realtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
78
85
  let presenceReconnectAttempts = 0;
79
86
  let presenceRunning = false;
80
87
  let started = false;
81
-
82
- function readable(value: string): string {
83
- const copy = value.replaceAll("_", " ");
84
- if (!copy) return "";
85
- const sentence = `${copy.charAt(0).toUpperCase()}${copy.slice(1)}`;
86
- const acronyms: Record<string, string> = { ai: "AI", api: "API", csv: "CSV", d1: "D1", http: "HTTP", https: "HTTPS", id: "ID", json: "JSON", r2: "R2", sla: "SLA", url: "URL" };
87
- return sentence.replace(/\b(ai|api|csv|d1|http|https|id|json|r2|sla|url)\b/giu, (match) => acronyms[match.toLowerCase()] ?? match);
88
- }
89
-
90
- function priorityName(priority: number): "high" | "medium" | "low" {
91
- if (priority >= 70) return "high";
92
- if (priority >= 40) return "medium";
93
- return "low";
88
+ const caseMutationKeys = new Map<string, { fingerprint: string; key: string }>();
89
+ function caseMutationKey(kind: string, reportId: string, input: unknown): string {
90
+ const fingerprint = JSON.stringify([getShellSnapshot().session?.actor.id, reportId, input]);
91
+ const previous = caseMutationKeys.get(kind);
92
+ if (previous?.fingerprint === fingerprint) return previous.key;
93
+ const key = crypto.randomUUID(); caseMutationKeys.set(kind, { fingerprint, key }); return key;
94
94
  }
95
95
 
96
96
  function queues(): QueueRecord[] {
@@ -101,6 +101,10 @@ function visibleReports(filters: ReportFilters): RawReport[] {
101
101
  const query = filters.search.trim().toLowerCase();
102
102
  const now = Date.now();
103
103
  return rawReports.filter((report) => {
104
+ if (filters.state === "needs_review" && !["human_review", "human_review_requested"].includes(report.state)) return false;
105
+ const claimed = Boolean(report.assignment);
106
+ if (filters.ownership === "unassigned" && claimed) return false;
107
+ if (filters.ownership === "mine" && (!claimed || report.assignment?.reviewerId !== actorId)) return false;
104
108
  if (filters.priority && priorityName(report.priority) !== filters.priority) return false;
105
109
  const milliseconds = now - Date.parse(report.receivedAt);
106
110
  if (filters.age === "hour" && milliseconds > 3_600_000) return false;
@@ -130,12 +134,12 @@ function reportSummaries(filters: ReportFilters): ReportSummary[] {
130
134
  state: report.state,
131
135
  priority: Number(report.priority || 0),
132
136
  receivedAt: report.receivedAt,
133
- owner: report.claim ? {
134
- reviewerId: report.claim.reviewerId,
135
- mine: report.claim.reviewerId === session?.actor.id,
136
- displayName: report.claim.analyst?.displayName || "Unknown analyst",
137
- avatarUrl: report.claim.analyst?.avatarUrl || null,
138
- avatarColor: report.claim.analyst?.avatarColor || "#315d4d",
137
+ owner: report.assignment ? {
138
+ reviewerId: report.assignment.reviewerId,
139
+ mine: report.assignment.reviewerId === session?.actor.id,
140
+ displayName: report.assignment.analyst?.displayName || "Unknown analyst",
141
+ avatarUrl: report.assignment.analyst?.avatarUrl || null,
142
+ avatarColor: report.assignment.analyst?.avatarColor || "#315d4d",
139
143
  } : null,
140
144
  }));
141
145
  }
@@ -181,15 +185,19 @@ function publish(next?: Partial<ReportWorkspaceSnapshot>): void {
181
185
  ? configuration.allowed_action_codes
182
186
  : [],
183
187
  permissions: {
188
+ reassignReports: shell.session?.permissions.reassignReports === true,
189
+ manageConfiguration: shell.session?.permissions.manageConfiguration === true,
184
190
  claimReports: shell.session?.permissions.claimReports === true,
185
191
  messageReporters: shell.session?.permissions.messageReporters === true,
186
192
  decideReports: shell.session?.permissions.decideReports === true,
187
193
  readRuns: shell.session?.permissions.readRuns === true,
194
+ readEvidence: shell.session?.permissions.readEvidence === true,
188
195
  },
189
196
  });
190
197
  }
191
198
 
192
199
  async function loadReports(): Promise<void> {
200
+ const version = ++listRequestVersion;
193
201
  const session = getShellSnapshot().session;
194
202
  if (!session?.permissions.readReports) {
195
203
  rawReports = [];
@@ -201,29 +209,33 @@ async function loadReports(): Promise<void> {
201
209
  publish({ error: "", refreshing: !snapshot.loading });
202
210
  const params = new URLSearchParams({ limit: "100" });
203
211
  if (snapshot.filters.queueId) params.set("queue_id", snapshot.filters.queueId);
204
- if (snapshot.filters.state) params.set("state", snapshot.filters.state);
212
+ if (snapshot.filters.state && snapshot.filters.state !== "needs_review") params.set("state", snapshot.filters.state);
205
213
  try {
206
- const response = await requestJson<ReportsResponse>(`/v1/admin/reports?${params}`);
207
- rawReports = response.reports || [];
214
+ const reports = await loadInbox<RawReport>(params);
215
+ if (version !== listRequestVersion) return;
216
+ rawReports = reports;
208
217
  setShellSnapshot((shell) => ({
209
218
  ...shell,
210
219
  reportCount: rawReports.filter((report) => !["resolved_by_human", "resolved_by_ai", "closed"].includes(report.state)).length,
211
220
  }));
212
221
  } catch (cause) {
213
- publish({ error: errorMessage(cause, "Reports could not be loaded.") });
222
+ if (version === listRequestVersion) publish({ error: errorMessage(cause, "Reports could not be loaded.") });
214
223
  } finally {
215
- publish({ loading: false, refreshing: false });
224
+ if (version === listRequestVersion) publish({ loading: false, refreshing: false });
216
225
  }
217
226
  }
218
227
 
219
- async function openReport(reportId: string): Promise<void> {
220
- const version = ++detailRequestVersion;
228
+ async function openReport(reportId: string, review = false, destination?: { section: ReportSection; taskId?: string }): Promise<void> {
221
229
  const snapshot = getReportWorkspaceSnapshot();
230
+ if (snapshot.detail.reportId && snapshot.detail.reportId !== reportId && !window.dispatchEvent(new Event(reportEvents.beforeClose, { cancelable: true }))) return;
231
+ const version = ++detailRequestVersion;
222
232
  document.body.classList.add("report-open");
223
233
  publish({
224
234
  detail: {
225
235
  ...snapshot.detail,
226
236
  reportId,
237
+ reviewRequest: review ? (snapshot.detail.reviewRequest || 0) + 1 : snapshot.detail.reportId === reportId ? snapshot.detail.reviewRequest : 0,
238
+ destination: destination ? { ...destination, request: version } : snapshot.detail.reportId === reportId ? snapshot.detail.destination : undefined,
227
239
  loading: true,
228
240
  error: "",
229
241
  statusMessage: snapshot.detail.reportId === reportId ? snapshot.detail.statusMessage : "",
@@ -233,11 +245,17 @@ async function openReport(reportId: string): Promise<void> {
233
245
  try {
234
246
  const encodedId = encodeURIComponent(reportId);
235
247
  const canReadRuns = getShellSnapshot().session?.permissions.readRuns === true;
236
- const [reportResponse, timelineResponse, runsResponse, actionsResponse] = await Promise.all([
237
- requestJson<ReportResponse>(`/v1/admin/reports/${encodedId}`),
238
- requestJson<TimelineResponse>(`/v1/admin/reports/${encodedId}/timeline?limit=50`).catch(() => ({ events: [] })),
239
- canReadRuns ? requestJson<WorkflowRunsResponse>(`/v1/admin/reports/${encodedId}/workflow-runs`).catch(() => ({ runs: [] })) : Promise.resolve({ runs: [] }),
240
- requestJson<WorkflowActionsResponse>(`/v1/admin/reports/${encodedId}/actions`).catch(() => ({ actions: [] })),
248
+ const canReadEvidence = getShellSnapshot().session?.permissions.readEvidence === true;
249
+ const prior = snapshot.detail.report?.id === reportId ? snapshot.detail.report : null;
250
+ const partialLoadErrors: string[] = [];
251
+ const unavailable = <T>(label: string, fallback: T) => () => { partialLoadErrors.push(label); return fallback; };
252
+ const readOptions = () => ({ signal: AbortSignal.timeout(15_000) });
253
+ const [reportResponse, timelineResponse, runsResponse, actionsResponse, findingsResponse] = await Promise.all([
254
+ requestJson<ReportResponse>(`/v1/admin/reports/${encodedId}`, readOptions()),
255
+ requestJson<TimelineResponse>(`/v1/admin/reports/${encodedId}/timeline?limit=50`, readOptions()).catch(unavailable("activity", { events: prior?.timeline || [] })),
256
+ canReadRuns ? requestJson<WorkflowRunsResponse>(`/v1/admin/reports/${encodedId}/workflow-runs`, readOptions()).catch(unavailable("playbook progress", { runs: prior?.workflowRuns || [] })) : Promise.resolve({ runs: [] }),
257
+ requestJson<WorkflowActionsResponse>(`/v1/admin/reports/${encodedId}/actions`, readOptions()).catch(unavailable("customer action states", { actions: prior?.workflowActions || [] })),
258
+ canReadEvidence ? requestJson<FindingsResponse>(`/v1/admin/reports/${encodedId}/findings`, readOptions()).catch(unavailable("retrieved evidence", { findings: prior?.findings || [] })) : Promise.resolve({ findings: [] }),
241
259
  ]);
242
260
  if (version !== detailRequestVersion || getReportWorkspaceSnapshot().detail.reportId !== reportId) return;
243
261
  publish({
@@ -245,16 +263,18 @@ async function openReport(reportId: string): Promise<void> {
245
263
  ...getReportWorkspaceSnapshot().detail,
246
264
  report: {
247
265
  ...reportResponse.report,
266
+ partialLoadErrors: partialLoadErrors.sort(),
248
267
  timeline: timelineResponse.events || [],
249
268
  workflowRuns: runsResponse.runs || [],
250
269
  workflowActions: actionsResponse.actions || [],
270
+ findings: findingsResponse.findings || [],
251
271
  },
252
272
  },
253
273
  });
254
274
  sendPresenceActivity();
255
275
  } catch (cause) {
256
276
  if (version !== detailRequestVersion || getReportWorkspaceSnapshot().detail.reportId !== reportId) return;
257
- publish({ detail: { ...getReportWorkspaceSnapshot().detail, report: null, error: errorMessage(cause, "The report could not be opened.") } });
277
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, ...(cause instanceof ApiRequestError && [401, 403, 404].includes(cause.status) ? { report: null } : {}), error: errorMessage(cause, "The case could not refresh. Use Refresh case to retry.") } });
258
278
  } finally {
259
279
  if (version === detailRequestVersion && getReportWorkspaceSnapshot().detail.reportId === reportId) {
260
280
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, loading: false } });
@@ -262,7 +282,8 @@ async function openReport(reportId: string): Promise<void> {
262
282
  }
263
283
  }
264
284
 
265
- function closeReport(): void {
285
+ function closeReport(committed = false): void {
286
+ if (!committed && !window.dispatchEvent(new Event(reportEvents.beforeClose, { cancelable: true }))) return;
266
287
  detailRequestVersion += 1;
267
288
  const messageMutationVersion = getReportWorkspaceSnapshot().detail.messageMutationVersion;
268
289
  document.body.classList.remove("report-open");
@@ -276,25 +297,39 @@ async function claimReport(): Promise<void> {
276
297
  if (!reportId || !snapshot.permissions.claimReports || snapshot.detail.pendingAction) return;
277
298
  publish({ detail: { ...snapshot.detail, error: "", pendingAction: "claim" } });
278
299
  try {
279
- await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "POST" });
280
- await Promise.all([openReport(reportId), loadReports()]);
300
+ const result = await requestJson<{ claim: NonNullable<ReportDetailRecord["claim"]> }>(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "POST" });
301
+ const detail = getReportWorkspaceSnapshot().detail;
302
+ const analyst = { displayName: getShellSnapshot().session?.displayName || "You" };
303
+ const claim = { ...result.claim, analyst };
304
+ publish({ detail: { ...detail, report: detail.report ? { ...detail.report, claim, assignment: claim } : null,
305
+ reviewRequest: (detail.reviewRequest || 0) + 1, statusMessage: "" } });
306
+ void Promise.all([openReport(reportId), loadReports()]);
281
307
  } catch (cause) {
282
- publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The report could not be claimed.") } });
308
+ await openReport(reportId);
309
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "Review could not be started. Your private draft is unchanged.") } });
310
+
283
311
  } finally {
284
312
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
285
313
  }
286
314
  }
287
315
 
288
- async function releaseReport(): Promise<void> {
316
+ export async function changeCaseOwner(input: { reviewerId: string | null; displayName?: string; version: number; note: string }): Promise<boolean> {
289
317
  const snapshot = getReportWorkspaceSnapshot();
290
318
  const reportId = snapshot.detail.report?.id;
291
- if (!reportId || !snapshot.permissions.claimReports || snapshot.detail.pendingAction) return;
292
- publish({ detail: { ...snapshot.detail, error: "", pendingAction: "release" } });
319
+ if (!reportId || !snapshot.permissions.reassignReports || snapshot.detail.pendingAction) return false;
320
+ publish({ detail: { ...snapshot.detail, error: "", statusMessage: "", pendingAction: input.reviewerId ? "transfer" : "release" } });
293
321
  try {
294
- await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "DELETE" });
295
- await Promise.all([openReport(reportId), loadReports()]);
322
+ await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/ownership`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ reviewer_id: input.reviewerId, version: input.version, note: input.note }) });
323
+ const detail = getReportWorkspaceSnapshot().detail;
324
+ const assignment = input.reviewerId ? { reviewerId: input.reviewerId, version: input.version + 1, expiresAt: "1970-01-01T00:00:00.000Z", analyst: { displayName: input.displayName || "Assigned reviewer" } } : null;
325
+ publish({ detail: { ...detail, report: detail.report ? { ...detail.report, assignment, claim: null } : null,
326
+ statusMessage: `${input.reviewerId ? "Case transferred" : "Case returned to the queue"}. Handoff note shared; your drafts remain private.` } });
327
+ void Promise.all([openReport(reportId), loadReports()]);
328
+ return true;
296
329
  } catch (cause) {
297
- publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The claim could not be released.") } });
330
+ await openReport(reportId);
331
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "Ownership could not be changed. Your note and private draft are unchanged.") } });
332
+ return false;
298
333
  } finally {
299
334
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
300
335
  }
@@ -304,7 +339,7 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
304
339
  const snapshot = getReportWorkspaceSnapshot();
305
340
  const reportId = snapshot.detail.report?.id;
306
341
  if (!reportId || !snapshot.permissions.messageReporters || snapshot.detail.pendingAction) return;
307
- const idempotencyKey = crypto.randomUUID();
342
+ const idempotencyKey = caseMutationKey("message", reportId, input);
308
343
  publish({ detail: { ...snapshot.detail, error: "", statusMessage: "", pendingAction: "message" } });
309
344
  try {
310
345
  const response = await requestJson<{ delivery_state?: string; delivery_channel?: string | null }>(`/v1/admin/reports/${encodeURIComponent(reportId)}/messages`, {
@@ -312,6 +347,7 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
312
347
  headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
313
348
  body: JSON.stringify({ idempotency_key: idempotencyKey, body: input.body, await_reporter: input.awaitReporter }),
314
349
  });
350
+ caseMutationKeys.delete("message");
315
351
  const version = getReportWorkspaceSnapshot().detail.messageMutationVersion + 1;
316
352
  const statusMessage = response.delivery_state === "queued"
317
353
  ? response.delivery_channel === "email"
@@ -319,7 +355,8 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
319
355
  : "Participant update queued for the configured customer notification channel."
320
356
  : "The update was saved but not sent because this report has no available participant delivery channel.";
321
357
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, messageMutationVersion: version, statusMessage } });
322
- await Promise.all([openReport(reportId), loadReports()]);
358
+ // The mutation is acknowledged. Background refresh must not keep unrelated actions locked.
359
+ void Promise.all([openReport(reportId), loadReports()]);
323
360
  } catch (cause) {
324
361
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The message could not be created.") } });
325
362
  } finally {
@@ -331,7 +368,7 @@ async function recordDecision(input: HumanDecisionInput): Promise<void> {
331
368
  const snapshot = getReportWorkspaceSnapshot();
332
369
  const reportId = snapshot.detail.report?.id;
333
370
  if (!reportId || !snapshot.permissions.decideReports || snapshot.detail.pendingAction) return;
334
- const idempotencyKey = crypto.randomUUID();
371
+ const idempotencyKey = caseMutationKey("decision", reportId, input);
335
372
  publish({ detail: { ...snapshot.detail, error: "", pendingAction: "decision" } });
336
373
  try {
337
374
  await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/decisions`, {
@@ -348,8 +385,10 @@ async function recordDecision(input: HumanDecisionInput): Promise<void> {
348
385
  ...(input.actionCode ? { action_code: input.actionCode } : {}),
349
386
  }),
350
387
  });
351
- closeReport();
352
- await loadReports();
388
+ caseMutationKeys.delete("decision");
389
+ removePrivateDraft(getShellSnapshot().session?.actor.id || "", reportId);
390
+ closeReport(true);
391
+ void loadReports();
353
392
  } catch (cause) {
354
393
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The decision could not be recorded.") } });
355
394
  } finally {
@@ -382,6 +421,17 @@ async function heartbeatPresence(): Promise<void> {
382
421
  }
383
422
  }
384
423
 
424
+ async function reconcilePresence(): Promise<void> {
425
+ if (!getShellSnapshot().session || document.visibilityState === "hidden") return;
426
+ try {
427
+ const response = await requestJson<PresenceResponse>("/v1/admin/presence");
428
+ rawPresence = response.analysts || [];
429
+ publish();
430
+ } catch {
431
+ // Presence is advisory. Keep the last known snapshot until the stream recovers.
432
+ }
433
+ }
434
+
385
435
  function scheduleRealtimeRefresh(reportId?: string | null, immediate = false): void {
386
436
  if (!getShellSnapshot().session || document.visibilityState === "hidden") return;
387
437
  if (realtimeRefreshTimer) clearTimeout(realtimeRefreshTimer);
@@ -484,7 +534,10 @@ async function startPresence(): Promise<void> {
484
534
  void heartbeatPresence();
485
535
  }
486
536
  }, 15_000);
487
- reconciliationTimer = setInterval(() => scheduleRealtimeRefresh(), 30_000);
537
+ reconciliationTimer = setInterval(() => {
538
+ scheduleRealtimeRefresh();
539
+ void reconcilePresence();
540
+ }, 30_000);
488
541
  openPresenceSocket();
489
542
  }
490
543
 
@@ -493,9 +546,10 @@ function validFilters(value: unknown): value is ReportFilters {
493
546
  const filters = value as Partial<ReportFilters>;
494
547
  return typeof filters.search === "string"
495
548
  && typeof filters.queueId === "string"
496
- && ["", "high", "medium", "low"].includes(filters.priority ?? "invalid")
549
+ && ["", "urgent", "high", "medium", "low"].includes(filters.priority ?? "invalid")
497
550
  && ["", "hour", "day", "older"].includes(filters.age ?? "invalid")
498
551
  && typeof filters.state === "string"
552
+ && [undefined, "", "unassigned", "mine"].includes(filters.ownership)
499
553
  && ["oldest", "newest", "priority"].includes(filters.sort ?? "invalid");
500
554
  }
501
555
 
@@ -509,6 +563,7 @@ export function startReportController(): void {
509
563
  publish();
510
564
  return;
511
565
  }
566
+ if (actorId && actorId !== nextActorId) clearPrivateDrafts();
512
567
  actorId = nextActorId;
513
568
  detailRequestVersion += 1;
514
569
  stopPresence();
@@ -523,9 +578,14 @@ export function startReportController(): void {
523
578
  });
524
579
  subscribeToQueueWorkspace(() => publish());
525
580
  window.addEventListener(reportEvents.open, (event) => {
526
- if (event instanceof CustomEvent && typeof event.detail?.reportId === "string") void openReport(event.detail.reportId);
581
+ if (!(event instanceof CustomEvent) || typeof event.detail?.reportId !== "string") return;
582
+ const destination = isReportSection(event.detail.section) ? {
583
+ section: event.detail.section,
584
+ ...(typeof event.detail.taskId === "string" && event.detail.taskId.length <= 100 ? { taskId: event.detail.taskId } : {}),
585
+ } : undefined;
586
+ void openReport(event.detail.reportId, event.detail.review === true, destination);
527
587
  });
528
- window.addEventListener(reportEvents.close, closeReport);
588
+ window.addEventListener(reportEvents.close, () => closeReport());
529
589
  window.addEventListener(reportEvents.refresh, () => { void loadReports(); });
530
590
  window.addEventListener(reportEvents.filters, (event) => {
531
591
  if (!(event instanceof CustomEvent) || !validFilters(event.detail?.filters)) return;
@@ -535,7 +595,6 @@ export function startReportController(): void {
535
595
  if (filters.queueId !== previous.queueId || filters.state !== previous.state) void loadReports();
536
596
  });
537
597
  window.addEventListener(reportEvents.claim, () => { void claimReport(); });
538
- window.addEventListener(reportEvents.release, () => { void releaseReport(); });
539
598
  window.addEventListener(reportEvents.message, (event) => {
540
599
  if (!(event instanceof CustomEvent) || typeof event.detail?.body !== "string" || typeof event.detail?.awaitReporter !== "boolean") return;
541
600
  void sendMessage(event.detail as ReporterMessageInput);
@@ -545,10 +604,16 @@ export function startReportController(): void {
545
604
  void recordDecision(event.detail as HumanDecisionInput);
546
605
  });
547
606
  window.addEventListener("keydown", (event) => {
548
- if (event.key === "Escape" && getReportWorkspaceSnapshot().detail.reportId) closeReport();
607
+ if (event.key === "Escape" && !event.defaultPrevented && !document.querySelector(".case-handoff-dialog[open]") && getReportWorkspaceSnapshot().detail.reportId) closeReport();
608
+ });
609
+ window.addEventListener("focus", () => {
610
+ scheduleRealtimeRefresh(null, true);
611
+ void reconcilePresence();
549
612
  });
550
- window.addEventListener("focus", () => scheduleRealtimeRefresh(null, true));
551
613
  document.addEventListener("visibilitychange", () => {
552
- if (document.visibilityState === "visible") scheduleRealtimeRefresh(null, true);
614
+ if (document.visibilityState === "visible") {
615
+ scheduleRealtimeRefresh(null, true);
616
+ void reconcilePresence();
617
+ }
553
618
  });
554
619
  }
@@ -1,4 +1,4 @@
1
- import type { HumanDecisionInput, ReportFilters, ReporterMessageInput, ReportWorkspaceSnapshot } from "./types";
1
+ import type { HumanDecisionInput, ReportFilters, ReporterMessageInput, ReportWorkspaceSnapshot, ReportSection } from "./types";
2
2
 
3
3
  export const reportEvents = {
4
4
  state: "safest:reports:state",
@@ -6,6 +6,7 @@ export const reportEvents = {
6
6
  filters: "safest:reports:filters",
7
7
  open: "safest:reports:open",
8
8
  close: "safest:reports:close",
9
+ beforeClose: "safest:reports:before-close",
9
10
  claim: "safest:reports:claim",
10
11
  release: "safest:reports:release",
11
12
  message: "safest:reports:message",
@@ -18,7 +19,13 @@ function dispatch<TDetail>(type: string, detail?: TDetail): void {
18
19
 
19
20
  export function dispatchReportRefresh(): void { dispatch(reportEvents.refresh); }
20
21
  export function dispatchReportFilters(filters: ReportFilters): void { dispatch(reportEvents.filters, { filters }); }
21
- export function dispatchOpenReport(reportId: string): void { dispatch(reportEvents.open, { reportId }); }
22
+ export function dispatchOpenReport(reportId: string, review = false): void { dispatch(reportEvents.open, { reportId, review }); }
23
+ export function dispatchOpenReportSection(reportId: string, section: ReportSection, taskId?: string): void {
24
+ dispatch(reportEvents.open, { reportId, section, taskId });
25
+ }
26
+ export function isReportSection(value: unknown): value is ReportSection {
27
+ return typeof value === "string" && ["overview", "evidence", "review", "messages", "outcome", "activity"].includes(value);
28
+ }
22
29
  export function dispatchCloseReport(): void { dispatch(reportEvents.close); }
23
30
  export function dispatchClaimReport(): void { dispatch(reportEvents.claim); }
24
31
  export function dispatchReleaseReport(): void { dispatch(reportEvents.release); }
@@ -0,0 +1,17 @@
1
+ import { requestJson } from "../lib/http";
2
+
3
+ // The API returns at most 100 reports. Follow its opaque cursor so search,
4
+ // sorting, and summary counts include the entire authorized result set.
5
+ export async function loadInbox<T>(query = new URLSearchParams()): Promise<T[]> {
6
+ const params = new URLSearchParams(query);
7
+ params.set("limit", "100");
8
+ const reports: T[] = [];
9
+ let cursor: string | null = null;
10
+ do {
11
+ if (cursor) params.set("cursor", cursor);
12
+ const page = await requestJson<{ reports?: T[]; next_cursor?: string | null }>(`/v1/admin/reports?${params}`);
13
+ reports.push(...(page.reports || []));
14
+ cursor = page.next_cursor || null;
15
+ } while (cursor);
16
+ return reports;
17
+ }
@@ -0,0 +1,52 @@
1
+ export interface PresencePresentationAnalyst {
2
+ actorId: string;
3
+ displayName: string;
4
+ activity: string;
5
+ activeReportId: string | null;
6
+ }
7
+
8
+ const activityPriority: Record<string, number> = {
9
+ editing: 0,
10
+ viewing: 1,
11
+ online: 2,
12
+ away: 3,
13
+ };
14
+
15
+ export function orderedPresence<TAnalyst extends PresencePresentationAnalyst>(analysts: TAnalyst[]): TAnalyst[] {
16
+ return [...analysts].sort((left, right) => {
17
+ const activityDifference = (activityPriority[left.activity] ?? 4) - (activityPriority[right.activity] ?? 4);
18
+ return activityDifference || left.displayName.localeCompare(right.displayName) || left.actorId.localeCompare(right.actorId);
19
+ });
20
+ }
21
+
22
+ export function presenceSummaryLabel(analysts: PresencePresentationAnalyst[]): string {
23
+ if (!analysts.length) return "No reviewers online";
24
+ if (analysts.length === 1) {
25
+ const firstName = analysts[0]?.displayName.trim().split(/\s+/u)[0];
26
+ return `${firstName || "1 reviewer"} is online`;
27
+ }
28
+ return `${analysts.length} reviewers online`;
29
+ }
30
+
31
+ export function partitionPresence<TAnalyst extends PresencePresentationAnalyst>(analysts: TAnalyst[]): { reviewing: TAnalyst[]; elsewhere: TAnalyst[] } {
32
+ return analysts.reduce<{ reviewing: TAnalyst[]; elsewhere: TAnalyst[] }>((groups, analyst) => {
33
+ groups[analyst.activeReportId ? "reviewing" : "elsewhere"].push(analyst);
34
+ return groups;
35
+ }, { reviewing: [], elsewhere: [] });
36
+ }
37
+
38
+ export function otherReviewersOnReport<TAnalyst extends PresencePresentationAnalyst>(analysts: TAnalyst[], reportId: string, actorId: string): TAnalyst[] {
39
+ return orderedPresence(analysts.filter((analyst) => analyst.activeReportId === reportId && analyst.actorId !== actorId));
40
+ }
41
+
42
+ export function reviewersOnReport<TAnalyst extends PresencePresentationAnalyst>(analysts: TAnalyst[], reportId: string): TAnalyst[] {
43
+ return orderedPresence(analysts.filter((analyst) => analyst.activeReportId === reportId));
44
+ }
45
+
46
+ export function reportPresenceLabel(analysts: PresencePresentationAnalyst[]): string {
47
+ if (analysts.length === 1) {
48
+ const analyst = analysts[0];
49
+ return `${analyst.displayName} is ${analyst.activity === "editing" ? "editing" : "viewing"} this report`;
50
+ }
51
+ return `${analysts.length} other reviewers are on this report`;
52
+ }
@@ -0,0 +1,32 @@
1
+ const prefix = "safest:private-case-draft:";
2
+ export interface PrivateCaseDraft {
3
+ message: string; awaitReporter: boolean; decisionCode: string; policyCode: string;
4
+ rationale: string; reporterNotice: string; affectedNotice: string; actionCode: string; evidence: string[];
5
+ taskDecision?: string; findings?: string[];
6
+ }
7
+ const memory = new Map<string, PrivateCaseDraft>();
8
+ const key = (actorId: string, reportId: string) => `${prefix}${encodeURIComponent(actorId)}:${encodeURIComponent(reportId)}`;
9
+ export function readPrivateDraft(actorId: string, reportId: string): PrivateCaseDraft | null {
10
+ const id = key(actorId, reportId);
11
+ if (memory.has(id)) return memory.get(id)!;
12
+ try {
13
+ const stored = JSON.parse(sessionStorage.getItem(id) || "null");
14
+ if (stored && Date.now() - stored.savedAt < 86_400_000 && typeof stored.draft?.message === "string" && Array.isArray(stored.draft.evidence)) return stored.draft;
15
+ sessionStorage.removeItem(id);
16
+ } catch { /* A browser can deny storage; in-memory drafts still work. */ }
17
+ return null;
18
+ }
19
+ export function savePrivateDraft(actorId: string, reportId: string, draft: PrivateCaseDraft): void {
20
+ const id = key(actorId, reportId);
21
+ if (![draft.message,draft.rationale,draft.reporterNotice,draft.affectedNotice,draft.actionCode,draft.taskDecision].some(Boolean)) { removePrivateDraft(actorId,reportId); return; }
22
+ memory.set(id,draft);
23
+ try { sessionStorage.setItem(id,JSON.stringify({ savedAt:Date.now(),draft })); } catch { /* Keep the in-memory copy. */ }
24
+ }
25
+ export function removePrivateDraft(actorId: string, reportId: string): void {
26
+ const id = key(actorId,reportId); memory.delete(id);
27
+ try { sessionStorage.removeItem(id); } catch { /* Storage may be unavailable. */ }
28
+ }
29
+ export function clearPrivateDrafts(): void {
30
+ memory.clear();
31
+ try { Object.keys(sessionStorage).filter(id => id.startsWith(prefix)).forEach(id => sessionStorage.removeItem(id)); } catch { /* Storage may be unavailable. */ }
32
+ }
@@ -9,12 +9,12 @@ let snapshot: ReportWorkspaceSnapshot = {
9
9
  error: "",
10
10
  generatedAt: new Date().toISOString(),
11
11
  actorId: "",
12
- filters: { search: "", queueId: "", priority: "", age: "", state: "", sort: "oldest" },
12
+ filters: { search: "", queueId: "", priority: "", age: "", state: "needs_review", sort: "priority" },
13
13
  queues: [],
14
14
  reports: [],
15
15
  presence: [],
16
16
  actionCodes: [],
17
- permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false },
17
+ permissions: { reassignReports: false, manageConfiguration: false, claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
18
18
  detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
19
19
  };
20
20