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
@@ -0,0 +1,101 @@
1
+ import type { JsonObject, JsonValue, QueueSummary } from "./types";
2
+
3
+ interface BuilderProps { value: JsonObject; onChange: (value: JsonObject) => void; disabled: boolean }
4
+ const objects = (value: JsonValue | undefined): JsonObject[] => Array.isArray(value) ? value.filter((item): item is JsonObject => Boolean(item) && typeof item === "object" && !Array.isArray(item)) : [];
5
+ const string = (value: JsonValue | undefined): string => value == null ? "" : String(value);
6
+
7
+ function QueueChoice({ label, help, value, queues, onChange, disabled, humanOnly = false }: {
8
+ label: string; help: string; value: string; queues: QueueSummary[]; onChange: (value: string) => void; disabled: boolean; humanOnly?: boolean;
9
+ }) {
10
+ const choices = queues.filter(queue => queue.id === value || queue.status === "active" && (!humanOnly || queue.activePolicy?.managementMode === "human"));
11
+ return <label>{label}<small>{help}</small><select aria-label={label} required disabled={disabled} value={value} onChange={event => onChange(event.currentTarget.value)}>
12
+ <option value="" disabled>Choose a queue</option>
13
+ {value && !choices.some(queue => queue.id === value) ? <option value={value}>{value} · unavailable</option> : null}
14
+ {choices.map(queue => <option value={queue.id} key={queue.id}>{queue.name}{queue.status === "active" ? "" : ` · ${queue.status}`}</option>)}
15
+ </select></label>;
16
+ }
17
+
18
+ const fields = [["reason_code", "Report reason"], ["source", "Report source"], ["target_type", "Content type"], ["priority", "Case priority"], ["safeguard.requires_human", "Human review required"]];
19
+ const operators = [["eq", "is"], ["neq", "is not"], ["in", "is one of"], ["not_in", "is not one of"], ["contains", "contains"], ["exists", "has a value"], ["gt", "is greater than"], ["gte", "is at least"], ["lt", "is less than"], ["lte", "is at most"]];
20
+
21
+ function Condition({ value, onChange, onRemove, disabled, index }: { value: JsonObject; onChange: (value: JsonObject) => void; onRemove: () => void; disabled: boolean; index: number }) {
22
+ const field = string(value.field), operator = string(value.operator);
23
+ const list = operator === "in" || operator === "not_in";
24
+ const numeric = typeof value.value === "number" || Array.isArray(value.value) && value.value.some(item => typeof item === "number") || field === "priority" || ["gt", "gte", "lt", "lte"].includes(operator);
25
+ const boolean = typeof value.value === "boolean" || Array.isArray(value.value) && value.value.some(item => typeof item === "boolean") || field === "safeguard.requires_human";
26
+ const changeOperator = (next: string) => {
27
+ const { value: previous, ...rest } = value;
28
+ const initial = boolean ? true : numeric ? 0 : "";
29
+ onChange(next === "exists" ? { ...rest, operator: next } : { ...rest, operator: next, value: next === "in" || next === "not_in" ? Array.isArray(previous) ? previous : [previous ?? initial] : Array.isArray(previous) ? previous[0] ?? initial : previous ?? initial });
30
+ };
31
+ return <div className="routing-condition" role="group" aria-label={`Condition ${index + 1}`}>
32
+ <label>Field<select aria-label={`Condition ${index + 1} field`} value={fields.some(([key]) => key === field) ? field : "custom"} disabled={disabled} onChange={event => {
33
+ const next = event.currentTarget.value;
34
+ const initial = next === "priority" ? 0 : next === "safeguard.requires_human" ? true : "";
35
+ onChange({ ...value, field: next === "custom" ? "answer.details" : next, ...(operator === "exists" ? {} : { value: list ? [initial] : initial }) });
36
+ }}>{fields.map(([key, label]) => <option value={key} key={key}>{label}</option>)}<option value="custom">Answer, trusted fact, or enrichment</option></select></label>
37
+ {!fields.some(([key]) => key === field) ? <label>Field name<input aria-label={`Condition ${index + 1} field name`} value={field} disabled={disabled} required onChange={event => onChange({ ...value, field: event.currentTarget.value })} /></label> : null}
38
+ <label>Comparison<select aria-label={`Condition ${index + 1} comparison`} value={operator} disabled={disabled} onChange={event => changeOperator(event.currentTarget.value)}>{operators.map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
39
+ {operator === "exists" ? null : boolean ? <label>{list ? "Values" : "Value"}<select required aria-label={`Condition ${index + 1} value`} value={list ? [true, false].filter(item => Array.isArray(value.value) && value.value.includes(item)).join(",") : String(value.value)} disabled={disabled} onChange={event => onChange({ ...value, value: list ? event.currentTarget.value.split(",").map(item => item === "true") : event.currentTarget.value === "true" })}><option value="" disabled>Choose a value</option><option value="true">Yes</option><option value="false">No</option>{list ? <option value="true,false">Yes or no</option> : null}</select></label> : <label>{list ? "Values, separated by commas" : "Value"}<input aria-label={`Condition ${index + 1} value`} type={numeric && !list ? "number" : "text"} step="any" pattern={numeric && list ? "-?\\d+(?:\\.\\d+)?(?:\\s*,\\s*-?\\d+(?:\\.\\d+)?)*" : undefined} disabled={disabled} required value={Array.isArray(value.value) ? value.value.join(", ") : string(value.value)} onChange={event => {
40
+ const input = event.currentTarget.value;
41
+ onChange({ ...value, value: list ? input.split(",").map(item => item.trim()).map(item => numeric && item !== "" && Number.isFinite(Number(item)) ? Number(item) : item) : numeric && input !== "" ? Number(input) : input });
42
+ }} /></label>}
43
+ <button type="button" className="secondary" disabled={disabled} aria-label={`Remove condition ${index + 1}`} onClick={onRemove}>Remove</button>
44
+ </div>;
45
+ }
46
+
47
+ export function RoutingBuilder({ value, onChange, queues, disabled }: BuilderProps & { queues: QueueSummary[] }) {
48
+ const rules = objects(value.rules).sort((a, b) => Number(b.priority) - Number(a.priority) || string(a.id).localeCompare(string(b.id)));
49
+ const update = (index: number, next: JsonObject) => onChange({ ...value, rules: rules.map((rule, i) => i === index ? next : rule) });
50
+ const reorder = (index: number, destination: number) => {
51
+ const next = [...rules];
52
+ next.splice(destination, 0, next.splice(index, 1)[0]!);
53
+ onChange({ ...value, rules: next.map((rule, i) => ({ ...rule, priority: 100_000 - i })) });
54
+ };
55
+ return <div className="configuration-builder">
56
+ <section className="configuration-section"><h3>Where should cases go?</h3><p>Choose who handles new cases and who handles cases that need a safety review.</p>
57
+ <QueueChoice label="Default queue" help="Receives cases that do not match a rule below." value={string(value.default_queue_id)} queues={queues} onChange={next => onChange({ ...value, default_queue_id: next })} disabled={disabled} />
58
+ <QueueChoice label="Safety review queue" help="Human reviewers handle cases flagged by safeguards, before any routing rule or AI." value={string(value.safeguard_queue_id)} queues={queues} humanOnly onChange={next => onChange({ ...value, safeguard_queue_id: next })} disabled={disabled} />
59
+ </section>
60
+ <section className="configuration-section"><div className="configuration-section-heading"><div><h3>Routing rules</h3><p>Checked from top to bottom. The first matching rule chooses the queue.</p></div><button className="secondary" type="button" disabled={disabled || rules.length >= 100} onClick={() => onChange({ ...value, rules: [...rules, { id: `rule-${crypto.randomUUID()}`, priority: 0, queue_id: value.default_queue_id || "", match: "all", conditions: [{ field: "reason_code", operator: "eq", value: "" }] }].map((rule, i) => ({ ...rule, priority: 100_000 - i })) })}>Add rule</button></div>
61
+ {!rules.length ? <p className="configuration-empty">All cases go to the default queue, unless a safeguard requires human review.</p> : rules.map((rule, index) => <fieldset className="routing-rule" key={string(rule.id)}><legend>Rule {index + 1}</legend>
62
+ <div className="configuration-rule-actions"><button type="button" className="secondary" aria-label={`Move rule ${index + 1} up`} disabled={disabled || index === 0} onClick={() => reorder(index, index - 1)}>Move up</button><button type="button" className="secondary" aria-label={`Move rule ${index + 1} down`} disabled={disabled || index === rules.length - 1} onClick={() => reorder(index, index + 1)}>Move down</button><button type="button" className="secondary" disabled={disabled} aria-label={`Remove rule ${index + 1}`} onClick={() => onChange({ ...value, rules: rules.filter((_, i) => i !== index) })}>Remove rule</button></div>
63
+ <label>Match<select aria-label={`Rule ${index + 1} match`} value={string(rule.match)} disabled={disabled} onChange={event => update(index, { ...rule, match: event.currentTarget.value })}><option value="all">All of these conditions</option><option value="any">Any of these conditions</option></select></label>
64
+ {objects(rule.conditions).map((condition, i) => <Condition key={i} index={i} value={condition} disabled={disabled} onChange={next => update(index, { ...rule, conditions: objects(rule.conditions).map((item, j) => i === j ? next : item) })} onRemove={() => update(index, { ...rule, conditions: objects(rule.conditions).filter((_, j) => i !== j) })} />)}
65
+ {!objects(rule.conditions).length ? <p>This rule matches {rule.match === "all" ? "every case" : "no cases"}.</p> : null}
66
+ <button className="secondary" type="button" disabled={disabled || objects(rule.conditions).length >= 50} onClick={() => update(index, { ...rule, conditions: [...objects(rule.conditions), { field: "reason_code", operator: "eq", value: "" }] })}>Add condition</button>
67
+ <QueueChoice label={`Rule ${index + 1} queue`} help="Send matching cases to this queue." queues={queues} value={string(rule.queue_id)} disabled={disabled} onChange={next => update(index, { ...rule, queue_id: next })} />
68
+ </fieldset>)}
69
+ </section>
70
+ {objects(value.fixtures).length || objects(value.intake_enrichments).length ? <p className="configuration-retained">{objects(value.fixtures).length} saved routing examples and {objects(value.intake_enrichments).length} intake enrichments are retained. Use Advanced configuration to inspect them.</p> : null}
71
+ </div>;
72
+ }
73
+
74
+ export function PolicyBuilder({ value, onChange, disabled }: BuilderProps) {
75
+ const rules = objects(value.rules);
76
+ return <div className="configuration-builder"><label>Policy title<input required minLength={3} maxLength={160} disabled={disabled} value={string(value.title)} onChange={event => onChange({ ...value, title: event.currentTarget.value })} /></label>
77
+ <section className="configuration-section"><h3>Decision rules</h3><p>Give reviewers clear rules to apply when deciding a case.</p>
78
+ {rules.map((rule, index) => <fieldset key={index}><legend>Rule {index + 1}</legend><label>Rule title<input aria-label={`Rule ${index + 1} title`} required minLength={3} maxLength={200} disabled={disabled} value={string(rule.title)} onChange={event => onChange({ ...value, rules: rules.map((item, i) => i === index ? { ...item, title: event.currentTarget.value } : item) })} /></label>
79
+ <label>Guidance for reviewers<textarea aria-label={`Rule ${index + 1} guidance`} rows={3} disabled={disabled} value={string(rule.description)} onChange={event => onChange({ ...value, rules: rules.map((item, i) => i === index ? { ...item, description: event.currentTarget.value } : item) })} /></label>
80
+ <button className="secondary" type="button" disabled={disabled || rules.length === 1} onClick={() => onChange({ ...value, rules: rules.filter((_, i) => i !== index) })}>Remove rule {index + 1}</button></fieldset>)}
81
+ <button className="secondary" type="button" disabled={disabled || rules.length >= 500} onClick={() => onChange({ ...value, rules: [...rules, { code: `rule-${crypto.randomUUID()}`, title: "" }] })}>Add rule</button>
82
+ </section></div>;
83
+ }
84
+
85
+ export function MessageBuilder({ value, onChange, disabled }: BuilderProps) {
86
+ const variables = value.variables_schema && typeof value.variables_schema === "object" && !Array.isArray(value.variables_schema) ? value.variables_schema : {};
87
+ const setBody = (body: string) => {
88
+ const next = { ...variables };
89
+ for (const match of body.matchAll(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/gu)) if (!(match[1]! in next)) next[match[1]!] = { type: "string" };
90
+ onChange({ ...value, body_template: body, variables_schema: next });
91
+ };
92
+ return <div className="configuration-builder"><div className="form-grid">
93
+ <label>Send to<select disabled={disabled} value={string(value.audience)} onChange={event => onChange({ ...value, audience: event.currentTarget.value })}><option value="reporter">Person who reported</option><option value="affected_user">Person affected by the decision</option></select></label>
94
+ <label>When to use<select disabled={disabled} value={string(value.purpose)} onChange={event => onChange({ ...value, purpose: event.currentTarget.value })}><option value="follow_up">Request more information</option><option value="outcome">Share the case outcome</option><option value="affected_user_notice">Explain an action taken</option><option value="appeal_outcome">Share the appeal outcome</option></select></label>
95
+ </div><label>Language<small>Language code, such as en, fr, or en-GB.</small><input required pattern="[a-z]{2,3}(?:-[A-Z]{2})?" maxLength={6} disabled={disabled} value={string(value.locale)} onChange={event => onChange({ ...value, locale: event.currentTarget.value })} /></label>
96
+ <label>Message<textarea required rows={6} maxLength={10000} disabled={disabled} value={string(value.body_template)} onChange={event => setBody(event.currentTarget.value)} /></label>
97
+ <div className="message-variable-buttons"><span>Insert a detail:</span>{[...new Set(["report_reference", ...Object.keys(variables)])].map(name => <button className="secondary" type="button" disabled={disabled} key={name} onClick={() => setBody(`${string(value.body_template)} {{${name}}}`)}>{name.replaceAll("_", " ")}</button>)}</div>
98
+ <label className="checkbox"><input type="checkbox" disabled={disabled} checked={value.automation_allowed === true} onChange={event => onChange({ ...value, automation_allowed: event.currentTarget.checked })} /><span>Allow this template in automated messages</span></label>
99
+ <section className="message-preview" aria-label="Message preview"><h3>Preview</h3><p>{string(value.body_template).replace(/\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/gu, (_, name: string) => name === "report_reference" ? "RPT-EXAMPLE" : `[${name.replaceAll("_", " ")}]`) || "Your message will appear here."}</p><small>Example details are replaced with the recipient’s case details when sent.</small></section>
100
+ </div>;
101
+ }
@@ -4,6 +4,7 @@ import { getShellSnapshot, subscribeToShell } from "../shell/store";
4
4
  import { configurationActionPath, configurationDefault, configurationEndpoint, configurationKeySlug, configurationLabel } from "./api";
5
5
  import { announceConfigurationChanged, configurationEvents } from "./events";
6
6
  import { editableReportForm, FormBuilder, reportFormContent, type EditableReportForm } from "./FormBuilder";
7
+ import { MessageBuilder, PolicyBuilder, RoutingBuilder } from "./ConfigurationBuilders";
7
8
  import type {
8
9
  ConfigurationDetailResponse,
9
10
  ConfigurationDraft,
@@ -15,6 +16,7 @@ import type {
15
16
  ConfigurationVersion,
16
17
  JsonObject,
17
18
  QueueListResponse,
19
+ QueueSummary,
18
20
  } from "./types";
19
21
 
20
22
  interface EditorRequest { type: ConfigurationType; key: string; exists: boolean }
@@ -51,10 +53,12 @@ export function ConfigurationDialog() {
51
53
  const [published, setPublished] = useState<ConfigurationVersion | null>(null);
52
54
  const [form, setForm] = useState<EditableReportForm>(() => editableReportForm(configurationDefault("form")));
53
55
  const [contentJson, setContentJson] = useState("{}");
56
+ const [queues, setQueues] = useState<QueueSummary[]>([]);
54
57
  const [baseline, setBaseline] = useState("");
55
58
  const [validation, setValidation] = useState<ConfigurationValidation | null>(null);
56
59
  const [validationMessage, setValidationMessage] = useState("");
57
60
  const [loading, setLoading] = useState(false);
61
+ const [loadFailed, setLoadFailed] = useState(false);
58
62
  const [action, setAction] = useState<"" | "save" | "validate" | "publish">("");
59
63
  const [error, setError] = useState("");
60
64
  const busy = loading || Boolean(action);
@@ -65,7 +69,7 @@ export function ConfigurationDialog() {
65
69
  catch { return null; }
66
70
  }, [contentJson, form, visualForm]);
67
71
  const serialized = currentContent ? JSON.stringify(currentContent) : "";
68
- const dirty = Boolean(editor && !loading && (serialized !== baseline || (!keyLocked && key !== editor.key)));
72
+ const dirty = Boolean(editor && !loading && !loadFailed && (serialized !== baseline || (!keyLocked && key !== editor.key)));
69
73
 
70
74
  const clear = (): void => {
71
75
  setEditor(null); setKey(""); setKeyEdited(false); setKeyLocked(false); setDraft(null); setPublished(null);
@@ -90,16 +94,16 @@ export function ConfigurationDialog() {
90
94
  useEffect(() => {
91
95
  const dialog = dialogRef.current;
92
96
  if (!dialog) return;
93
- if (editor && !dialog.open) { dialog.showModal(); requestAnimationFrame(() => keyRef.current?.focus()); }
97
+ if (editor && !dialog.open) { dialog.showModal(); }
94
98
  if (!editor && dialog.open) dialog.close();
95
99
  }, [editor]);
96
100
  useEffect(() => {
97
101
  if (!editor) return;
98
102
  let active = true;
99
103
  const load = async (): Promise<void> => {
100
- setLoading(true); setError(""); setDraft(null); setPublished(null); setValidation(null); setValidationMessage(""); setBaseline("");
104
+ setLoading(true); setLoadFailed(false); setError(""); setDraft(null); setPublished(null); setValidation(null); setValidationMessage(""); setBaseline("");
101
105
  try {
102
- const queueResponse = editor.type === "routing_rules" ? await requestJson<QueueListResponse>("/v1/admin/queues").catch(() => ({ queues: [] })) : { queues: [] };
106
+ const queueResponse = editor.type === "routing_rules" ? await requestJson<QueueListResponse>("/v1/admin/queues") : { queues: [] };
103
107
  const fallback = configurationDefault(editor.type, queueResponse.queues || []);
104
108
  let content = fallback;
105
109
  let nextDraft: ConfigurationDraft | null = null;
@@ -111,6 +115,7 @@ export function ConfigurationDialog() {
111
115
  content = nextDraft?.content || nextPublished?.content || fallback;
112
116
  }
113
117
  if (!active) return;
118
+ setQueues(queueResponse.queues || []);
114
119
  const resolvedKey = editor.type === "routing_rules" ? "default" : editor.key;
115
120
  setKey(resolvedKey); setKeyEdited(Boolean(resolvedKey)); setKeyLocked(editor.type === "routing_rules" || editor.exists);
116
121
  setDraft(nextDraft); setPublished(nextPublished); setContentJson(JSON.stringify(content, null, 2));
@@ -121,7 +126,7 @@ export function ConfigurationDialog() {
121
126
  setValidationMessage(nextDraft.validation.valid ? "Stored check passed." : `${nextDraft.validation.errors?.length || 0} stored problems.`);
122
127
  } else if (nextPublished) setValidationMessage(`Based on published version ${nextPublished.version}. Save to create an editable draft.`);
123
128
  else setValidationMessage("Not saved yet.");
124
- } catch (cause) { if (active) setError(errorMessage(cause, "The configuration draft could not be opened.")); }
129
+ } catch (cause) { if (active) { setLoadFailed(true); setError(errorMessage(cause, "The configuration draft could not be opened.")); } }
125
130
  finally { if (active) setLoading(false); }
126
131
  };
127
132
  void load();
@@ -129,8 +134,9 @@ export function ConfigurationDialog() {
129
134
  }, [editor]);
130
135
 
131
136
  const save = async (): Promise<ConfigurationSaveResponse | null> => {
137
+ if (loadFailed || dialogRef.current?.querySelector("form")?.reportValidity() === false) return null;
132
138
  if (!editor || !currentContent) { setError("Configuration JSON must be a valid object."); return null; }
133
- const resolvedKey = type === "routing_rules" ? "default" : key.trim();
139
+ const resolvedKey = type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, "");
134
140
  if (!resolvedKey) { setError("An internal name is required."); keyRef.current?.focus(); return null; }
135
141
  if (draft && serialized === baseline) return { draftId: draft.id, revision: draft.revision, validation: draft.validation };
136
142
  setAction("save"); setError("");
@@ -157,7 +163,7 @@ export function ConfigurationDialog() {
157
163
  if (!saved || !editor) return null;
158
164
  setAction("validate"); setError("");
159
165
  try {
160
- const result = await requestJson<ConfigurationValidationResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : key, "validate"), { method: "POST" });
166
+ const result = await requestJson<ConfigurationValidationResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, ""), "validate"), { method: "POST" });
161
167
  setValidation(result.validation); setValidationMessage(result.validation.valid ? "Ready to publish." : "Changes needed.");
162
168
  setDraft((current) => current ? { ...current, validation: result.validation, validationStatus: result.validation.valid ? "valid" : "invalid" } : current);
163
169
  return { validation: result.validation, saved };
@@ -174,7 +180,7 @@ export function ConfigurationDialog() {
174
180
  if (!window.confirm("Publish this immutable configuration version for future reports and workflow runs?")) return;
175
181
  setAction("publish"); setError("");
176
182
  try {
177
- const result = await requestJson<ConfigurationPublishResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : key, "publish"), { method: "POST", headers: mutationHeaders(), body: JSON.stringify({ expected_revision: checked.saved.revision }) });
183
+ const result = await requestJson<ConfigurationPublishResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, ""), "publish"), { method: "POST", headers: mutationHeaders(), body: JSON.stringify({ expected_revision: checked.saved.revision }) });
178
184
  setPublished({ id: result.versionId, version: result.version, content: currentContent || draft?.content || {}, publishedAt: new Date().toISOString() });
179
185
  setValidationMessage(`Published version ${result.version}. Future reports and workflow runs can use it; historical work does not change.`);
180
186
  announceConfigurationChanged();
@@ -186,14 +192,23 @@ export function ConfigurationDialog() {
186
192
  if (!keyLocked && !keyEdited && next.title !== form.title) setKey(configurationKeySlug(next.title, ""));
187
193
  setForm(next); setValidation(null); setValidationMessage("Unsaved changes.");
188
194
  };
195
+ const updateContent = (next: JsonObject): void => {
196
+ if (type === "policy" && !keyLocked && !keyEdited && next.title !== currentContent?.title) setKey(String(next.title || ""));
197
+ setContentJson(JSON.stringify(next, null, 2)); setValidation(null); setValidationMessage("Unsaved changes.");
198
+ };
189
199
  const title = `${editor?.exists ? "Edit" : "New"} ${configurationLabel(type).toLowerCase()}`;
190
- const subtitle = loading ? "Loading configuration…" : `${key || "Unsaved draft"}${draft ? ` · Draft revision ${draft.revision}` : published ? ` · Based on published v${published.version}` : ""}`;
200
+ const subtitle = loading ? "Loading…" : draft ? "Draft · Changes are not live until published" : published ? `Published version ${published.version} · Create an updated version` : "Draft · Changes are not live until published";
191
201
  return <dialog ref={dialogRef} className="editor-dialog configuration-dialog" aria-labelledby="configuration-dialog-title" onCancel={(event) => { event.preventDefault(); close(); }} data-react-slice="configuration-dialog">
192
- <div className="detail-head"><div><h2 id="configuration-dialog-title">{title}</h2><p>{subtitle}</p></div><button className="icon-button" type="button" aria-label="Close" disabled={busy} onClick={close}>×</button></div>
193
- {loading ? <div className="configuration-dialog-loading"><div className="skeleton-block" role="status" aria-label="Loading configuration editor" /></div> : <form className="editor-form configuration-form" onSubmit={(event: FormEvent<HTMLFormElement>) => { event.preventDefault(); void save(); }}>
194
- <label>Internal name <small>Used to identify this configuration in Safest.</small><input ref={keyRef} pattern="[a-z0-9]+(?:-[a-z0-9]+)*" required readOnly={keyLocked} disabled={busy} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label>
195
- {visualForm ? <FormBuilder value={form} onChange={updateForm} disabled={busy} /> : <label>Configuration JSON<textarea className="code-input configuration-code" spellCheck={false} required disabled={busy} value={contentJson} onChange={(event) => { setContentJson(event.currentTarget.value); setValidation(null); setValidationMessage("Unsaved changes."); }} /></label>}
202
+ <div className="detail-head"><div><h2 id="configuration-dialog-title">{title}</h2><p>{subtitle}</p></div><button className="icon-button" autoFocus type="button" aria-label="Close" disabled={busy} onClick={close}>×</button></div>
203
+ {loading ? <div className="configuration-dialog-loading"><div className="skeleton-block" role="status" aria-label="Loading configuration editor" /></div> : loadFailed ? <div className="configuration-fields"><p className="error" role="alert">{error}</p><button className="secondary" type="button" onClick={() => setEditor(current => current ? { ...current } : current)}>Try again</button></div> : <form className="editor-form configuration-form" onSubmit={(event: FormEvent<HTMLFormElement>) => { event.preventDefault(); void save(); }}>
204
+ <div className="configuration-fields">
205
+ {!keyLocked ? <label>Configuration name<small>A name your team will recognise.</small><input ref={keyRef} required disabled={busy} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label> : null}
206
+ {visualForm ? <FormBuilder value={form} onChange={updateForm} disabled={busy} /> : <>
207
+ {currentContent ? type === "routing_rules" ? <RoutingBuilder value={currentContent} onChange={updateContent} disabled={busy} queues={queues} /> : type === "policy" ? <PolicyBuilder value={currentContent} onChange={updateContent} disabled={busy} /> : <MessageBuilder value={currentContent} onChange={updateContent} disabled={busy} /> : <p className="error" role="alert">Correct the advanced configuration to continue editing.</p>}
208
+ <details className="configuration-advanced"><summary>Advanced configuration</summary><p>For custom fields, saved routing examples, and intake enrichments. Changes here update the form above.</p><label>Configuration JSON<textarea className="code-input configuration-code" spellCheck={false} required disabled={busy} value={contentJson} onChange={(event) => { setContentJson(event.currentTarget.value); setValidation(null); setValidationMessage("Unsaved changes."); }} /></label></details>
209
+ </>}
196
210
  <ValidationSummary validation={validation} message={validationMessage} />
211
+ </div>
197
212
  <div className="editor-footer"><p className="error" role="alert">{error}</p><div><button className="secondary" type="button" disabled={busy} onClick={close}>Cancel</button><button className="secondary" type="submit" disabled={busy || !dirty}>{action === "save" ? "Saving…" : "Save draft"}</button><button className="secondary" type="button" disabled={busy} onClick={() => void validate()}>{action === "validate" ? "Checking…" : "Check"}</button><button className="primary" type="button" disabled={busy} onClick={() => void publish()}>{action === "publish" ? "Publishing…" : "Publish"}</button></div></div>
198
213
  </form>}
199
214
  </dialog>;
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { PageHeader } from "../components/PageHeader";
3
3
  import { readable, relativeTime } from "../lib/format";
4
4
  import { errorMessage, requestJson } from "../lib/http";
@@ -16,7 +16,7 @@ const tabs: Array<{ type: ConfigurationType; label: string }> = [
16
16
 
17
17
  function ConfigurationCard({ item, type }: { item: ConfigurationSummary; type: ConfigurationType }) {
18
18
  const updated = item.draft?.updatedAt || item.latestPublishedAt || "";
19
- const title = item.configurationKey === "default" ? "Workspace routing" : readable(item.configurationKey);
19
+ const title = item.configurationKey === "default" ? "Workspace routing" : readable(item.configurationKey.replaceAll("-", " "));
20
20
  return <article className="object-card configuration-card">
21
21
  <div className="object-card-head"><span className="object-icon" aria-hidden="true">⌗</span><span className={`badge${item.draft?.validationStatus === "invalid" ? " urgent" : ""}`}>{item.draft ? "Draft" : "Published"}</span></div>
22
22
  <h3>{title}</h3><p>{item.draft ? `Revision ${item.draft.revision} · ${readable(item.draft.validationStatus)}` : "No unpublished changes."}</p>
@@ -33,19 +33,27 @@ export function ConfigurationWorkspace() {
33
33
  const [loading, setLoading] = useState(false);
34
34
  const [refreshing, setRefreshing] = useState(false);
35
35
  const [error, setError] = useState("");
36
+ const requestId = useRef(0);
37
+ const selectType = (next: ConfigurationType): void => {
38
+ if (next === type) return;
39
+ requestId.current += 1;
40
+ setConfigurations([]); setError(""); setLoading(true); setType(next);
41
+ };
36
42
 
37
43
  const load = useCallback(async (refresh = false): Promise<void> => {
44
+ const id = ++requestId.current;
38
45
  if (!canManage) { setConfigurations([]); return; }
39
46
  if (refresh) setRefreshing(true); else setLoading(true);
40
47
  setError("");
41
48
  try {
42
49
  const result = await requestJson<ConfigurationListResponse>(`/v1/admin/${configurationEndpoint(type)}`);
50
+ if (id !== requestId.current) return;
43
51
  setConfigurations(result.configurations || []);
44
- } catch (cause) { setError(errorMessage(cause, "Configuration could not be loaded.")); }
45
- finally { setLoading(false); setRefreshing(false); }
52
+ } catch (cause) { if (id === requestId.current) setError(errorMessage(cause, "Configuration could not be loaded.")); }
53
+ finally { if (id === requestId.current) { setLoading(false); setRefreshing(false); } }
46
54
  }, [canManage, type]);
47
55
 
48
- useEffect(() => { if (shell.currentView === "configuration") void load(); }, [shell.currentView, shell.session?.actor.id, load]);
56
+ useEffect(() => { if (shell.currentView === "configuration") void load(); return () => { requestId.current += 1; }; }, [shell.currentView, shell.session?.actor.id, load]);
49
57
  useEffect(() => {
50
58
  const changed = (): void => { if (shell.currentView === "configuration") void load(true); };
51
59
  window.addEventListener(configurationEvents.changed, changed);
@@ -56,10 +64,15 @@ export function ConfigurationWorkspace() {
56
64
  const create = (): void => openConfigurationEditor(type, type === "routing_rules" ? "default" : "", type === "routing_rules" && Boolean(routing));
57
65
  return <div className="page-stack" data-react-slice="configuration-workspace">
58
66
  <PageHeader titleId="configuration-title" title="Configuration" description="Manage intake forms, routing, policies, and messages.">
59
- <div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button><button className="primary" type="button" disabled={!canManage} onClick={create}>{type === "routing_rules" && routing ? "Open routing" : "Create draft"}</button></div>
67
+ <div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button><button className="primary" type="button" disabled={!canManage || loading || refreshing} onClick={create}>{type === "routing_rules" && routing ? "Open routing" : "Create draft"}</button></div>
60
68
  </PageHeader>
61
- <div className="segmented-tabs configuration-tabs" role="tablist" aria-label="Configuration type">{tabs.map((tab) => <button className={type === tab.type ? "active" : ""} type="button" role="tab" aria-selected={type === tab.type} onClick={() => setType(tab.type)} key={tab.type}>{tab.label}</button>)}</div>
62
- {type === "routing_rules" ? <div className="safety-boundary routing-boundary"><strong>Safeguards run before AI.</strong><span>The router sees only approved fields and queues. Uncertain results go to General triage.</span></div> : null}
69
+ <div className="segmented-tabs configuration-tabs" role="tablist" aria-label="Configuration type">{tabs.map((tab, index) => <button className={type === tab.type ? "active" : ""} type="button" role="tab" aria-selected={type === tab.type} tabIndex={type === tab.type ? 0 : -1} onClick={() => selectType(tab.type)} onKeyDown={event => {
70
+ const next = event.key === "ArrowRight" ? (index + 1) % tabs.length : event.key === "ArrowLeft" ? (index + tabs.length - 1) % tabs.length : event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : -1;
71
+ if (next < 0) return;
72
+ event.preventDefault(); selectType(tabs[next]!.type);
73
+ event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('button[role="tab"]')[next]?.focus();
74
+ }} key={tab.type}>{tab.label}</button>)}</div>
75
+ {type === "routing_rules" ? <div className="safety-boundary routing-boundary"><strong>Safety review comes first.</strong><span>Safeguards send sensitive cases to human reviewers. Other cases follow your routing rules and default queue.</span></div> : null}
63
76
  <div className="object-grid configuration-list" aria-busy={loading || refreshing}>{loading ? <div className="skeleton-card" /> : configurations.length ? configurations.map((item) => <ConfigurationCard item={item} type={type} key={item.configurationKey} />) : error ? null : <div className="empty-product"><span aria-hidden="true">⌗</span><h2>No {configurationLabel(type).toLowerCase()} yet</h2><p>Create, check, and publish an immutable version.</p>{canManage ? <button className="primary" type="button" onClick={create}>Create draft</button> : null}</div>}</div>
64
77
  <p className="error surface-error" role="alert">{error}</p>
65
78
  </div>;
@@ -75,5 +75,5 @@ export interface ReportFormContent {
75
75
  consent_notice: string;
76
76
  }
77
77
 
78
- export interface QueueSummary { id: string; name: string; status: string }
78
+ export interface QueueSummary { id: string; name: string; status: string; activePolicy?: { managementMode: string } | null }
79
79
  export interface QueueListResponse { queues: QueueSummary[] }
@@ -0,0 +1,13 @@
1
+ export type Priority = "urgent" | "high" | "medium" | "low";
2
+
3
+ export function priorityName(priority: number): Priority {
4
+ if (priority >= 80) return "urgent";
5
+ if (priority >= 60) return "high";
6
+ if (priority >= 40) return "medium";
7
+ return "low";
8
+ }
9
+
10
+ export function priorityLabel(priority: number): string {
11
+ const name = priorityName(priority);
12
+ return name[0].toUpperCase() + name.slice(1);
13
+ }
@@ -0,0 +1,142 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore, type FormEvent } from 'react';
2
+ import { getShellSnapshot, subscribeToShell } from '../shell/store';
3
+ import { errorMessage, mutationHeaders, requestJson } from '../lib/http';
4
+ import { dispatchOpenReportSection } from '../reports/events';
5
+ import { dispatchOpenAppeal } from '../appeals/events';
6
+ import { dispatchNavigation } from '../shell/events';
7
+ import type { CaseEscalationItem, CaseEscalationList, CaseEscalationPolicy, EscalationContact } from '../../shared/case-escalations';
8
+ import './case-escalations.css';
9
+
10
+ interface Settings { policies: CaseEscalationPolicy[]; contacts: EscalationContact[]; queues: { id:string;name:string }[] }
11
+ interface RecoveryTask { id:string;state:string;queueId:string|null;taskType:string;context?:{title?:string;instructions?:string} }
12
+ const stamp = (value: string) => new Date(value).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
13
+ function recover(item: CaseEscalationItem, recovery: CaseEscalationItem['recovery'][number]) {
14
+ if (recovery.type === 'people') dispatchNavigation('people');
15
+ else if (recovery.type === 'appeal') dispatchOpenAppeal(item.sourceId);
16
+ else if (item.reportId) dispatchOpenReportSection(item.reportId,recovery.section ?? 'overview',recovery.type === 'task' ? item.sourceId : undefined);
17
+ }
18
+
19
+ function TaskRecovery({ item,onClose,onChanged }: { item:CaseEscalationItem;onClose:()=>void;onChanged:()=>void }) {
20
+ const shell = useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
21
+ const [task,setTask] = useState<RecoveryTask|null>(null),[queues,setQueues] = useState<Settings['queues']>([]);
22
+ const [queue,setQueue] = useState(''),[reason,setReason] = useState(''),[action,setAction] = useState('reassign');
23
+ const [error,setError] = useState(''),[busy,setBusy] = useState(false);
24
+ const [retry,setRetry] = useState(0);
25
+ const canAssign = Boolean(shell.session?.permissions.assignTasks),canCancel = Boolean(shell.session?.permissions.operateRuns);
26
+ useEffect(() => {
27
+ const controller = new AbortController();setError('');
28
+ Promise.all([requestJson<{task:RecoveryTask}>(`/v1/admin/tasks/${encodeURIComponent(item.sourceId)}`,{signal:controller.signal}),canAssign ? requestJson<Settings>('/v1/admin/case-escalation-policies',{signal:controller.signal}) : Promise.resolve({queues:[]})])
29
+ .then(([result,settings]) => {if (!controller.signal.aborted) {setTask(result.task);setQueue(result.task.queueId ?? '');setQueues(settings.queues);setAction(canAssign ? 'reassign' : 'cancel');}})
30
+ .catch(cause => {if (!controller.signal.aborted) setError(errorMessage(cause,'The task could not be loaded.'));});
31
+ return () => controller.abort();
32
+ },[item.sourceId,canAssign,retry]);
33
+ async function submit(event:FormEvent) {
34
+ event.preventDefault();setBusy(true);setError('');
35
+ try {await requestJson(`/v1/admin/tasks/${encodeURIComponent(item.sourceId)}/${action}`,{method:'POST',headers:mutationHeaders(),body:JSON.stringify({reason, ...(action === 'reassign' ? {queue_id:queue} : {})})});onChanged();onClose();}
36
+ catch(cause) {setError(errorMessage(cause,'The task changed or could not be recovered.'));} finally {setBusy(false);}
37
+ }
38
+ return <form className="case-escalation-settings" aria-label="Task recovery" onSubmit={event => {void submit(event);}}>
39
+ <h3>{task?.context?.title ?? 'Recover playbook task'}</h3>{task?.context?.instructions ? <p>{task.context.instructions}</p> : null}
40
+ {task ? <><p>Current state: {task.state}. Reassignment releases current editing access. Cancellation ends this task and sends the playbook a cancellation event; it does not complete the case.</p><label>Recovery action<select value={action} disabled={busy} onChange={event => setAction(event.currentTarget.value)}>{canAssign ? <option value="reassign">Return to an eligible queue</option> : null}{canCancel ? <option value="cancel">Cancel this task</option> : null}</select></label>{action === 'reassign' ? <label>Destination queue<select required disabled={busy} value={queue} onChange={event => setQueue(event.currentTarget.value)}><option value="">Choose a queue</option>{queues.map(value => <option key={value.id} value={value.id}>{value.name}</option>)}</select></label> : null}<label>Recovery reason<textarea required minLength={3} maxLength={2000} rows={3} disabled={busy} value={reason} onChange={event => setReason(event.currentTarget.value)} /></label></> : !error ? <p role="status">Loading task…</p> : null}
41
+ {error ? <p className="error" role="alert">{error}{!task ? <button className="text-button" type="button" onClick={() => setRetry(value => value + 1)}>Retry loading task</button> : null}</p> : null}
42
+ <div className="case-escalation-buttons"><button type="button" className="secondary" disabled={busy} onClick={onClose}>Close task recovery</button><button type="submit" className="primary" disabled={busy || !task || !['open','claimed'].includes(task.state) || !reason.trim()}>{busy ? 'Saving…' : action === 'cancel' ? 'Cancel task with reason' : 'Reassign task with reason'}</button></div>
43
+ </form>;
44
+ }
45
+
46
+ function EscalationSettings({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
47
+ const [settings,setSettings] = useState<Settings | null>(null);
48
+ const [queue,setQueue] = useState('');
49
+ const [primary,setPrimary] = useState('');
50
+ const [backup,setBackup] = useState('');
51
+ const [acknowledgement,setAcknowledgement] = useState(60);
52
+ const [followUp,setFollowUp] = useState(240);
53
+ const [error,setError] = useState('');
54
+ const [busy,setBusy] = useState(false);
55
+ const [retry,setRetry] = useState(0);
56
+ useEffect(() => {
57
+ const controller = new AbortController();
58
+ requestJson<Settings>('/v1/admin/case-escalation-policies',{signal:controller.signal}).then(result => {setSettings(result);setError('');if (!result.policies.some(value => value.queueId === null) && result.queues[0]) setQueue(result.queues[0].id);}).catch(cause => {if (!controller.signal.aborted) setError(errorMessage(cause,'Recovery contacts could not be loaded.'));});
59
+ return () => controller.abort();
60
+ },[retry]);
61
+ useEffect(() => {
62
+ const policy = settings?.policies.find(value => value.queueId === (queue || null));
63
+ setPrimary(policy?.primaryOperatorId ?? '');setBackup(policy?.backupOperatorId ?? '');setAcknowledgement(policy?.acknowledgementMinutes ?? 60);setFollowUp(policy?.followUpMinutes ?? 240);
64
+ },[settings,queue]);
65
+ async function save(event: FormEvent) {
66
+ event.preventDefault();setBusy(true);setError('');
67
+ try {
68
+ const policy = settings?.policies.find(value => value.queueId === (queue || null));
69
+ await requestJson('/v1/admin/case-escalation-policies',{method:'PUT',headers:mutationHeaders(),body:JSON.stringify({queueId:queue || null,primaryOperatorId:primary || null,backupOperatorId:backup || null,acknowledgementMinutes:acknowledgement,followUpMinutes:followUp,revision:policy?.revision ?? 0})});
70
+ onSaved();onClose();
71
+ } catch(cause) {setError(errorMessage(cause,'Recovery contacts could not be saved.'));} finally {setBusy(false);}
72
+ }
73
+ return <form className="case-escalation-settings" aria-label="Recovery contacts" onSubmit={event => {void save(event);}}>
74
+ <h3>Recovery contacts and deadlines</h3><p>The coordinator arranges recovery. Case ownership and approval permissions stay with their current holders.</p>
75
+ {!settings && !error ? <p role="status">Loading recovery contacts…</p> : settings ? <>
76
+ <label>Queue<select value={queue} disabled={busy} onChange={event => setQueue(event.currentTarget.value)}>{settings.policies.some(value => value.queueId === null) ? <option value="">Installation default</option> : null}{settings.queues.map(value => <option key={value.id} value={value.id}>{value.name}</option>)}</select></label>
77
+ <div className="case-escalation-setting-grid">
78
+ <label>Primary contact<select value={primary} disabled={busy} onChange={event => setPrimary(event.currentTarget.value)}><option value="">Case owner, then eligible administrators</option>{settings.contacts.map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label>
79
+ <label>Backup contact<select value={backup} disabled={busy} onChange={event => setBackup(event.currentTarget.value)}><option value="">Eligible owners and administrators</option>{settings.contacts.filter(person => person.id !== primary).map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label>
80
+ <label>Acknowledge within (minutes)<input type="number" min={5} max={10080} required disabled={busy} value={acknowledgement} onChange={event => setAcknowledgement(Number(event.currentTarget.value))} /></label>
81
+ <label>Follow up within (minutes)<input type="number" min={5} max={10080} required disabled={busy} value={followUp} onChange={event => setFollowUp(Number(event.currentTarget.value))} /></label>
82
+ </div><p>If a target passes, the item escalates to the backup and stays open until its source is repaired. Named contacts must have access to the queue.</p>
83
+ </> : null}
84
+ {error ? <p role="alert" className="error">{error}{!settings ? <> <button type="button" className="text-button" onClick={() => setRetry(value => value + 1)}>Retry</button></> : null}</p> : null}
85
+ <div className="case-escalation-buttons"><button type="button" className="secondary" disabled={busy} onClick={onClose}>Cancel</button><button className="primary" type="submit" disabled={busy || !settings}>{busy ? 'Saving…' : 'Save recovery contacts'}</button></div>
86
+ </form>;
87
+ }
88
+
89
+ export function CaseEscalations({ compact = false }: { compact?: boolean }) {
90
+ const shell = useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
91
+ const active = shell.currentView === (compact ? 'command' : 'operations') && Boolean(shell.session?.permissions.readReports || shell.session?.permissions.readOperations);
92
+ const [data,setData] = useState<CaseEscalationList | null>(null);
93
+ const [error,setError] = useState('');
94
+ const [busy,setBusy] = useState('');
95
+ const [refreshing,setRefreshing] = useState(false);
96
+ const [settings,setSettings] = useState(false);
97
+ const [expanded,setExpanded] = useState(false);
98
+ const [status,setStatus] = useState('');
99
+ const [recoveryTask,setRecoveryTask] = useState<CaseEscalationItem|null>(null);
100
+ const load = useCallback(async (signal?: AbortSignal,cursor = '') => {
101
+ setRefreshing(true);
102
+ try {
103
+ const next = await requestJson<CaseEscalationList>(`/v1/admin/case-escalations${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`,{signal});
104
+ if (signal?.aborted) return;
105
+ setData(previous => cursor && previous ? {...next,items:[...previous.items,...next.items.filter(item => !previous.items.some(old => old.id === item.id))]} : next);setError('');
106
+ } catch(cause) {if (!signal?.aborted) setError(errorMessage(cause,'Recovery work could not be loaded.'));} finally {if (!signal?.aborted) setRefreshing(false);}
107
+ },[]);
108
+ useEffect(() => {
109
+ if (!active) return;
110
+ const controller = new AbortController();setData(null);setStatus('');void load(controller.signal);
111
+ const refresh = () => {if (document.visibilityState === 'visible') void load(controller.signal);};
112
+ const timer = window.setInterval(refresh,60_000);window.addEventListener('focus',refresh);
113
+ return () => {controller.abort();window.clearInterval(timer);window.removeEventListener('focus',refresh);setSettings(false);};
114
+ },[active,shell.session?.actor.id,load]);
115
+ if (!active) return null;
116
+ const items = compact && !expanded ? data?.items.slice(0,3) : data?.items;
117
+ async function acknowledge(item: CaseEscalationItem) {
118
+ setBusy(item.id);setStatus('');setError('');
119
+ try {
120
+ await requestJson(`/v1/admin/case-escalations/${encodeURIComponent(item.id)}/acknowledge`,{method:'POST',headers:mutationHeaders(),body:JSON.stringify({revision:item.revision})});
121
+ setStatus('You are responsible for recovery. Open the affected work below to repair the cause.');await load();
122
+ } catch(cause) {setError(errorMessage(cause,'Responsibility could not be recorded.'));} finally {setBusy('');}
123
+ }
124
+ return <section className="surface case-escalations" aria-label="Cases needing recovery" aria-busy={refreshing}>
125
+ <div className="surface-head"><div><h2>Cases needing recovery{data ? ` (${data.total})` : ''}</h2><p>{data ? `${data.unacknowledged} awaiting acknowledgement · ${data.escalated} escalated to backup` : 'Checking stalled cases and participant delivery.'}</p></div><div className="case-escalation-buttons"><button type="button" className="secondary" disabled={refreshing || Boolean(busy)} onClick={() => {void load();}}>Refresh recovery</button>{shell.session?.permissions.manageConfiguration ? <button type="button" className="text-button" onClick={() => setSettings(value => !value)}>Recovery contacts</button> : null}</div></div>
126
+ {settings ? <EscalationSettings onClose={() => setSettings(false)} onSaved={() => {setStatus('Recovery contacts saved.');void load();}} /> : null}
127
+ {status ? <p role="status" className="case-escalation-status">{status}</p> : null}
128
+ {error ? <p role="alert" className="error">{error} <button type="button" className="text-button" onClick={() => {void load();}}>Retry loading recovery</button></p> : null}
129
+ {!data && refreshing ? <p role="status">Loading recovery work…</p> : null}
130
+ {data && !data.total ? <p className="case-escalation-empty">No detected cases need recovery. Open work continues against its configured deadlines.</p> : null}
131
+ <div className="case-escalation-list">{items?.map(item => <article className="case-escalation-item" key={item.id} aria-label={`${item.title}${item.reportReference ? `: ${item.reportReference}` : ''}`}>
132
+ <div className="case-escalation-item-head"><div><h3>{item.title}</h3><p>{item.reportReference ?? (item.reportId ? 'Case reference unavailable' : 'Workspace task')}{item.queueName ? ` · ${item.queueName}` : ''}</p></div><span className={`badge${item.level ? ' urgent' : ''}`}>{item.state === 'acknowledged' ? 'Recovery in progress' : item.level ? 'Backup needed' : 'Needs acknowledgement'}</span></div>
133
+ <p>{item.description}</p>
134
+ <dl><div><dt>Responsible</dt><dd>{item.acknowledgedBy?.displayName ?? item.responsible?.displayName ?? 'Eligible owners and administrators'}</dd></div><div><dt>Backup</dt><dd>{item.backup?.displayName ?? 'Eligible owners and administrators'}</dd></div><div><dt>Source target</dt><dd>{stamp(item.sourceDueAt)}</dd></div><div><dt>{item.state === 'acknowledged' ? 'Follow up by' : 'Acknowledge by'}</dt><dd>{stamp(item.responseDueAt)}</dd></div></dl>
135
+ {item.contactWarning ? <p className="case-escalation-warning">{item.contactWarning}</p> : null}
136
+ {item.acknowledgedAt ? <p>Acknowledged {stamp(item.acknowledgedAt)}. This item clears when the underlying problem is repaired.</p> : null}
137
+ <div className="case-escalation-buttons">{item.canAcknowledge ? <button type="button" className="primary" disabled={Boolean(busy)} onClick={() => {void acknowledge(item);}}>{busy === item.id ? 'Recording…' : 'Take responsibility for recovery'}</button> : null}{item.recovery.map(link => <button key={link.type} className="secondary" type="button" onClick={() => link.type === 'task_recovery' ? setRecoveryTask(item) : recover(item,link)}>{link.label}</button>)}</div>
138
+ {recoveryTask?.id === item.id ? <TaskRecovery item={item} onClose={() => setRecoveryTask(null)} onChanged={() => {setStatus('Task recovery recorded. Its source will be checked again.');void load();}} /> : null}
139
+ </article>)}</div>
140
+ {compact && !expanded && (data?.total ?? 0) > 3 ? <button type="button" className="text-button" onClick={() => setExpanded(true)}>Show all recovery work</button> : data?.nextCursor ? <button type="button" className="secondary" disabled={refreshing} onClick={() => {void load(undefined,data.nextCursor!);}}>Load more recovery work</button> : null}
141
+ </section>;
142
+ }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
2
2
  import { PageHeader } from "../components/PageHeader";
3
+ import { CaseEscalations } from './CaseEscalations';
3
4
  import { readable, relativeTime } from "../lib/format";
4
5
  import {
5
6
  dispatchAcknowledgeOperationsIncident,
@@ -60,6 +61,7 @@ export function OperationsWorkspace() {
60
61
  <PageHeader titleId="operations-title" title="Operations" description="Review failed deliveries, exhausted retries, and other incidents.">
61
62
  <div className="surface-buttons"><button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canRead} onClick={dispatchOperationsRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
62
63
  </PageHeader>
64
+ <CaseEscalations />
63
65
  <section className="surface operations-surface" aria-labelledby="operations-title">
64
66
  <div className="operation-metrics" aria-busy={snapshot.loading || snapshot.refreshing}>
65
67
  <Metric label="Routing failures" value={snapshot.loading ? "—" : routingFailures} detail="Failed or dead-letter jobs" urgent={routingFailures > 0} />
@@ -0,0 +1,20 @@
1
+ .case-escalations { margin-block: 1rem; min-width: 0; padding: 1.25rem; }
2
+ .case-escalations .surface-head { gap: 1rem; flex-wrap: wrap; padding: 0 0 1rem; margin-bottom: 1rem; }
3
+ .case-escalations p { color: var(--muted, #5c6862); line-height: 1.5; }
4
+ .case-escalation-list { display: grid; gap: 1rem; }
5
+ .case-escalation-item { border: 1px solid var(--line, #d8dfda); border-radius: .75rem; padding: 1rem; min-width: 0; overflow-wrap: anywhere; }
6
+ .case-escalation-item-head { display: flex; justify-content: space-between; align-items: flex-start; gap: .75rem; flex-wrap: wrap; }
7
+ .case-escalation-item h3 { margin: 0; font-size: 1rem; }
8
+ .case-escalation-item-head p { margin: .3rem 0 0; font-size: .875rem; }
9
+ .case-escalation-item dl { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; }
10
+ .case-escalation-item dt { font-size: .8rem; color: var(--muted, #5c6862); }
11
+ .case-escalation-item dd { margin: .2rem 0 0; font-size: .9rem; }
12
+ .case-escalation-buttons { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
13
+ .case-escalation-buttons button { min-height: 44px; white-space: normal; }
14
+ .case-escalation-warning { border-inline-start: 3px solid #af7820; padding-inline-start: .75rem; }
15
+ .case-escalation-settings { padding: 1rem; margin-block: 1rem; border: 1px solid var(--line, #d8dfda); border-radius: .75rem; }
16
+ .case-escalation-settings label { display: grid; gap: .35rem; min-width: 0; font-size: .875rem; }
17
+ .case-escalation-setting-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; margin-block: .75rem; }
18
+ .case-escalation-settings select,.case-escalation-settings input,.case-escalation-settings textarea { width: 100%; min-width: 0; min-height: 44px; font-size: 1rem; }
19
+ .case-escalation-status { padding: .75rem; background: var(--brand-soft, #e8f1e9); border-radius: .5rem; }
20
+ @media (max-width: 560px) { .case-escalations { padding: 1rem; } .case-escalation-setting-grid { grid-template-columns: minmax(0,1fr); } .case-escalation-buttons { align-items: stretch; } .case-escalation-buttons button { flex: 1 1 auto; } }