create-safest-tools 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/template/README.md +1 -1
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +12 -6
- package/template/console/command/controller.ts +13 -9
- package/template/console/command/presentation.ts +1 -6
- package/template/console/command/store.ts +1 -0
- package/template/console/command/types.ts +1 -0
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
- package/template/console/registry/RegistryDialog.tsx +34 -4
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +109 -67
- package/template/console/reports/ReportWorkspace.tsx +11 -12
- package/template/console/reports/controller.ts +96 -49
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +41 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +22 -8
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +25 -17
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +18701 -11800
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +329 -10
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +3 -2
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/database-readiness.ts +8 -3
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +178 -18
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-delivery.ts +136 -129
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-operations.ts +16 -63
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +181 -274
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +18 -7
- package/template/src/workflow-effects.ts +95 -18
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +9 -4
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { errorMessage, requestJson } from "../lib/http";
|
|
1
|
+
import { ApiRequestError, errorMessage, requestJson } from "../lib/http";
|
|
2
2
|
import { dispatchReportRefresh } from "../reports/events";
|
|
3
3
|
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
4
4
|
import { appealEvents } from "./events";
|
|
@@ -19,7 +19,11 @@ const initialSnapshot = (): AppealWorkspaceSnapshot => ({
|
|
|
19
19
|
});
|
|
20
20
|
|
|
21
21
|
let actorId = "";
|
|
22
|
+
let accessSignature = "";
|
|
22
23
|
let started = false;
|
|
24
|
+
let listVersion = 0;
|
|
25
|
+
let detailVersion = 0;
|
|
26
|
+
let decisionRetry: {appealId:string;actorId:string;key:string} | null = null;
|
|
23
27
|
|
|
24
28
|
function permissions(snapshot: AppealWorkspaceSnapshot): AppealWorkspaceSnapshot {
|
|
25
29
|
const session = getShellSnapshot().session;
|
|
@@ -27,6 +31,7 @@ function permissions(snapshot: AppealWorkspaceSnapshot): AppealWorkspaceSnapshot
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
async function loadAppeals(): Promise<void> {
|
|
34
|
+
const version = ++listVersion;
|
|
30
35
|
const session = getShellSnapshot().session;
|
|
31
36
|
if (!session?.permissions.reviewAppeals) {
|
|
32
37
|
setAppealWorkspaceSnapshot(permissions({ ...initialSnapshot(), loading: false }));
|
|
@@ -36,26 +41,39 @@ async function loadAppeals(): Promise<void> {
|
|
|
36
41
|
setAppealWorkspaceSnapshot(permissions({ ...current, error: "", refreshing: !current.loading }));
|
|
37
42
|
try {
|
|
38
43
|
const result = await requestJson<AppealListResponse>("/v1/admin/appeals?limit=100");
|
|
44
|
+
if (version !== listVersion || session.actor.id !== getShellSnapshot().session?.actor.id) return;
|
|
39
45
|
setAppealWorkspaceSnapshot((snapshot) => {
|
|
40
46
|
const appeals = result.appeals || [];
|
|
41
47
|
const selected = snapshot.detail.appealId ? appeals.find((appeal) => appeal.id === snapshot.detail.appealId) || null : null;
|
|
42
|
-
return permissions({ ...snapshot, appeals, detail: selected ? { ...snapshot.detail, appeal: selected } : snapshot.detail });
|
|
48
|
+
return permissions({ ...snapshot, appeals, detail: selected ? { ...snapshot.detail, appeal: {...snapshot.detail.appeal,...selected} } : snapshot.detail });
|
|
43
49
|
});
|
|
44
50
|
} catch (cause) {
|
|
45
|
-
setAppealWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "Appeals could not be loaded.") }));
|
|
51
|
+
if (version === listVersion) setAppealWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "Appeals could not be loaded.") }));
|
|
46
52
|
} finally {
|
|
47
|
-
setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, loading: false, refreshing: false }));
|
|
53
|
+
if (version === listVersion) setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, loading: false, refreshing: false }));
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
function openAppeal(appealId: string): void {
|
|
57
|
+
async function openAppeal(appealId: string, refresh = false): Promise<void> {
|
|
58
|
+
const current=getAppealWorkspaceSnapshot();
|
|
59
|
+
if (!current.canReview) return;
|
|
60
|
+
if (current.detail.appealId && current.detail.appealId !== appealId && !window.dispatchEvent(new Event(appealEvents.beforeClose,{cancelable:true}))) return;
|
|
61
|
+
const version=++detailVersion;
|
|
52
62
|
setAppealWorkspaceSnapshot((snapshot) => {
|
|
53
63
|
const appeal = snapshot.appeals.find((candidate) => candidate.id === appealId) || null;
|
|
54
|
-
return
|
|
64
|
+
return refresh ? snapshot : permissions({ ...snapshot, detail: { appealId, appeal: snapshot.detail.appealId===appealId?snapshot.detail.appeal:appeal, error: "", pendingAction: null } });
|
|
55
65
|
});
|
|
66
|
+
try {
|
|
67
|
+
const result = await requestJson<{ appeal: AppealRecord }>(`/v1/admin/appeals/${encodeURIComponent(appealId)}`);
|
|
68
|
+
if(version===detailVersion) setAppealWorkspaceSnapshot((snapshot) => snapshot.detail.appealId === appealId ? { ...snapshot, detail: { ...snapshot.detail, appeal: result.appeal } } : snapshot);
|
|
69
|
+
} catch (cause) {
|
|
70
|
+
if(version===detailVersion) setAppealWorkspaceSnapshot((snapshot) => snapshot.detail.appealId === appealId ? { ...snapshot, detail: { ...snapshot.detail, ...(cause instanceof ApiRequestError && [401,403,404].includes(cause.status)?{appeal:null}:{}), error: errorMessage(cause, "Appeal details could not be loaded.") } } : snapshot);
|
|
71
|
+
}
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
function closeAppeal(): void {
|
|
74
|
+
function closeAppeal(committed = false): void {
|
|
75
|
+
if (!committed && !window.dispatchEvent(new Event(appealEvents.beforeClose,{cancelable:true}))) return;
|
|
76
|
+
detailVersion++;
|
|
59
77
|
setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, detail: { appealId: null, appeal: null, error: "", pendingAction: null } }));
|
|
60
78
|
}
|
|
61
79
|
|
|
@@ -67,6 +85,7 @@ async function claimAppeal(): Promise<void> {
|
|
|
67
85
|
try {
|
|
68
86
|
await requestJson(`/v1/admin/appeals/${encodeURIComponent(appeal.id)}/claim`, { method: "POST" });
|
|
69
87
|
await loadAppeals();
|
|
88
|
+
await openAppeal(appeal.id);
|
|
70
89
|
} catch (cause) {
|
|
71
90
|
setAppealWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, "The appeal could not be claimed.") } }));
|
|
72
91
|
} finally {
|
|
@@ -78,8 +97,9 @@ async function recordDecision(input: AppealDecisionInput): Promise<void> {
|
|
|
78
97
|
const snapshot = getAppealWorkspaceSnapshot();
|
|
79
98
|
const appeal = snapshot.detail.appeal;
|
|
80
99
|
const activeClaim = Boolean(appeal && appeal.reviewerId === snapshot.actorId && appeal.claimExpiresAt && Date.parse(appeal.claimExpiresAt) > Date.now());
|
|
81
|
-
if (!appeal || !
|
|
82
|
-
|
|
100
|
+
if (!appeal || !getShellSnapshot().session?.permissions.decideAppeals || snapshot.detail.pendingAction || !activeClaim) return;
|
|
101
|
+
if (!decisionRetry || decisionRetry.appealId!==appeal.id || decisionRetry.actorId!==snapshot.actorId) decisionRetry={appealId:appeal.id,actorId:snapshot.actorId,key:crypto.randomUUID()};
|
|
102
|
+
const idempotencyKey = decisionRetry.key;
|
|
83
103
|
setAppealWorkspaceSnapshot({ ...snapshot, detail: { ...snapshot.detail, error: "", pendingAction: "decision" } });
|
|
84
104
|
try {
|
|
85
105
|
await requestJson(`/v1/admin/appeals/${encodeURIComponent(appeal.id)}/decision`, {
|
|
@@ -87,10 +107,13 @@ async function recordDecision(input: AppealDecisionInput): Promise<void> {
|
|
|
87
107
|
headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
|
|
88
108
|
body: JSON.stringify({ idempotency_key: idempotencyKey, outcome: input.outcome, rationale: input.rationale, participant_notice: input.participantNotice }),
|
|
89
109
|
});
|
|
90
|
-
|
|
110
|
+
decisionRetry=null;
|
|
111
|
+
if (getAppealWorkspaceSnapshot().detail.appealId!==appeal.id || getShellSnapshot().session?.actor.id!==snapshot.actorId) return;
|
|
112
|
+
closeAppeal(true);
|
|
91
113
|
await loadAppeals();
|
|
92
114
|
dispatchReportRefresh();
|
|
93
115
|
} catch (cause) {
|
|
116
|
+
if (cause instanceof ApiRequestError && cause.status>=400 && cause.status<500) decisionRetry=null;
|
|
94
117
|
setAppealWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, "The appeal decision could not be recorded.") } }));
|
|
95
118
|
} finally {
|
|
96
119
|
setAppealWorkspaceSnapshot((current) => permissions({ ...current, detail: { ...current.detail, pendingAction: null } }));
|
|
@@ -103,19 +126,22 @@ export function startAppealController(): void {
|
|
|
103
126
|
subscribeToShell(() => {
|
|
104
127
|
const session = getShellSnapshot().session;
|
|
105
128
|
const nextActorId = session?.actor.id || "";
|
|
106
|
-
|
|
129
|
+
const nextAccessSignature = JSON.stringify({permissions:session?.permissions,queueScope:session?.queueScope});
|
|
130
|
+
if (nextActorId === actorId && nextAccessSignature === accessSignature) {
|
|
107
131
|
setAppealWorkspaceSnapshot((snapshot) => permissions(snapshot));
|
|
108
132
|
return;
|
|
109
133
|
}
|
|
110
134
|
actorId = nextActorId;
|
|
135
|
+
accessSignature = nextAccessSignature;
|
|
136
|
+
listVersion++; detailVersion++; decisionRetry=null;
|
|
111
137
|
setAppealWorkspaceSnapshot(initialSnapshot());
|
|
112
138
|
if (session) void loadAppeals();
|
|
113
139
|
});
|
|
114
|
-
window.addEventListener(appealEvents.refresh, () => { void loadAppeals(); });
|
|
140
|
+
window.addEventListener(appealEvents.refresh, () => { void loadAppeals(); const id=getAppealWorkspaceSnapshot().detail.appealId; if(id)void openAppeal(id,true); });
|
|
115
141
|
window.addEventListener(appealEvents.open, (event) => {
|
|
116
|
-
if (event instanceof CustomEvent && typeof event.detail?.appealId === "string") openAppeal(event.detail.appealId);
|
|
142
|
+
if (event instanceof CustomEvent && typeof event.detail?.appealId === "string") void openAppeal(event.detail.appealId);
|
|
117
143
|
});
|
|
118
|
-
window.addEventListener(appealEvents.close, closeAppeal);
|
|
144
|
+
window.addEventListener(appealEvents.close, () => closeAppeal());
|
|
119
145
|
window.addEventListener(appealEvents.claim, () => { void claimAppeal(); });
|
|
120
146
|
window.addEventListener(appealEvents.decision, (event) => {
|
|
121
147
|
if (!(event instanceof CustomEvent) || !event.detail || !["upheld", "modified", "reversed"].includes(event.detail.outcome)) return;
|
|
@@ -3,6 +3,12 @@ export type AppealPendingAction = "claim" | "decision" | null;
|
|
|
3
3
|
|
|
4
4
|
export interface AppealRecord {
|
|
5
5
|
id: string;
|
|
6
|
+
reportId?: string;
|
|
7
|
+
originalDecisionCode?: string;
|
|
8
|
+
originalPolicyCode?: string;
|
|
9
|
+
originalRationale?: string;
|
|
10
|
+
originalMakerId?: string;
|
|
11
|
+
decisions?: Array<{ id: string; outcome: string; rationale: string; reviewerId: string; completionState: string; correctionRequired: number; correctionReference: string | null; correctionNote: string | null; correctionConfirmedAt: string | null; correctionConfirmedBy: string | null; noticeState: string | null }>;
|
|
6
12
|
reportReference: string;
|
|
7
13
|
appellantAudience: string;
|
|
8
14
|
submittedAt: string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { appealEvents } from "./events";
|
|
3
|
+
|
|
4
|
+
export function useAppealDraftGuard(open:boolean,dirty:boolean,pending:boolean) {
|
|
5
|
+
useEffect(()=>{
|
|
6
|
+
if(!open)return;
|
|
7
|
+
const close=(event:Event)=>{if(pending || (dirty&&!window.confirm("Discard your unsent appeal changes?")))event.preventDefault();};
|
|
8
|
+
const unload=(event:BeforeUnloadEvent)=>{if(dirty||pending){event.preventDefault();event.returnValue="";}};
|
|
9
|
+
window.addEventListener(appealEvents.beforeClose,close); window.addEventListener("beforeunload",unload);
|
|
10
|
+
return()=>{window.removeEventListener(appealEvents.beforeClose,close);window.removeEventListener("beforeunload",unload);};
|
|
11
|
+
},[open,dirty,pending]);
|
|
12
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, useSyncExternalStore, type FormEvent, type KeyboardEvent } from "react";
|
|
2
2
|
import { PageHeader } from "../components/PageHeader";
|
|
3
3
|
import { relativeTime } from "../lib/format";
|
|
4
|
-
import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
4
|
+
import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
5
5
|
import { dispatchOpenReport } from "../reports/events";
|
|
6
6
|
import { dispatchNavigation } from "../shell/events";
|
|
7
7
|
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
@@ -9,7 +9,7 @@ import type { AssistantHistory, AssistantMessage, AssistantSessionResponse } fro
|
|
|
9
9
|
|
|
10
10
|
const prompts = [
|
|
11
11
|
"How many phishing reports were resolved yesterday?",
|
|
12
|
-
"
|
|
12
|
+
"Find spam",
|
|
13
13
|
] as const;
|
|
14
14
|
const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
|
|
15
15
|
const readable = (value: string): string => value.replaceAll("_", " ").replace(/^./u, (letter) => letter.toUpperCase());
|
|
@@ -28,14 +28,33 @@ export function AssistantWorkspace() {
|
|
|
28
28
|
const [error, setError] = useState("");
|
|
29
29
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
30
30
|
const messagesRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const sendingRef = useRef(false);
|
|
31
32
|
const canUse = shell.session?.permissions.useAssistant === true;
|
|
32
33
|
|
|
33
34
|
useEffect(() => {
|
|
35
|
+
let current = true;
|
|
34
36
|
setSessionId("");
|
|
35
37
|
setHistory(null);
|
|
36
38
|
setDraft("");
|
|
37
39
|
setError("");
|
|
38
|
-
|
|
40
|
+
setPending("");
|
|
41
|
+
const actor = shell.session?.actor.id;
|
|
42
|
+
if (!actor || !canUse) return;
|
|
43
|
+
const key = `safest-assistant:${actor}`;
|
|
44
|
+
let saved = "";
|
|
45
|
+
try { saved = sessionStorage.getItem(key) || ""; } catch { /* Browser storage may be disabled. */ }
|
|
46
|
+
if (!saved) return;
|
|
47
|
+
setPending("new");
|
|
48
|
+
void requestJson<AssistantHistory>(`/v1/admin/assistant/sessions/${encodeURIComponent(saved)}`).then((result) => {
|
|
49
|
+
if (current) { setSessionId(saved); setHistory(result); }
|
|
50
|
+
}).catch((cause) => {
|
|
51
|
+
if (!current) return;
|
|
52
|
+
if (cause instanceof ApiRequestError && [404, 409].includes(cause.status)) {
|
|
53
|
+
try { sessionStorage.removeItem(key); } catch { /* Storage is optional. */ }
|
|
54
|
+
} else setError(errorMessage(cause, "Your conversation could not be restored. Refresh to try again."));
|
|
55
|
+
}).finally(() => { if (current) setPending(""); });
|
|
56
|
+
return () => { current = false; };
|
|
57
|
+
}, [shell.session?.actor.id, canUse]);
|
|
39
58
|
|
|
40
59
|
useEffect(() => {
|
|
41
60
|
const focus = (): void => { requestAnimationFrame(() => inputRef.current?.focus()); };
|
|
@@ -55,6 +74,7 @@ export function AssistantWorkspace() {
|
|
|
55
74
|
try {
|
|
56
75
|
const result = await requestJson<AssistantSessionResponse>("/v1/admin/assistant/sessions", { method: "POST", headers: mutationHeaders(false) });
|
|
57
76
|
setSessionId(result.sessionId);
|
|
77
|
+
try { sessionStorage.setItem(`safest-assistant:${shell.session?.actor.id}`, result.sessionId); } catch { /* Conversation remains usable without browser storage. */ }
|
|
58
78
|
setHistory({ session: { id: result.sessionId, timezone: result.timezone, expiresAt: result.expiresAt }, messages: [], toolCalls: [] });
|
|
59
79
|
setDraft("");
|
|
60
80
|
requestAnimationFrame(() => inputRef.current?.focus());
|
|
@@ -70,10 +90,11 @@ export function AssistantWorkspace() {
|
|
|
70
90
|
const send = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
71
91
|
event.preventDefault();
|
|
72
92
|
const body = draft.trim();
|
|
73
|
-
if (!body || !canUse) return;
|
|
93
|
+
if (!body || !canUse || sendingRef.current || pending) return;
|
|
94
|
+
sendingRef.current = true;
|
|
74
95
|
let activeSessionId = sessionId;
|
|
75
96
|
if (!activeSessionId) activeSessionId = await createConversation() ?? "";
|
|
76
|
-
if (!activeSessionId) return;
|
|
97
|
+
if (!activeSessionId) { sendingRef.current = false; return; }
|
|
77
98
|
const optimistic: AssistantMessage = { id: crypto.randomUUID(), role: "user", body, citations: [], createdAt: new Date().toISOString() };
|
|
78
99
|
setDraft("");
|
|
79
100
|
setPending("send");
|
|
@@ -91,6 +112,7 @@ export function AssistantWorkspace() {
|
|
|
91
112
|
setDraft(body);
|
|
92
113
|
setError(errorMessage(cause, "Safest could not answer that question."));
|
|
93
114
|
} finally {
|
|
115
|
+
sendingRef.current = false;
|
|
94
116
|
setPending("");
|
|
95
117
|
}
|
|
96
118
|
};
|
|
@@ -103,6 +125,7 @@ export function AssistantWorkspace() {
|
|
|
103
125
|
const onComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
104
126
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
105
127
|
event.preventDefault();
|
|
128
|
+
if (sendingRef.current || pending) return;
|
|
106
129
|
event.currentTarget.form?.requestSubmit();
|
|
107
130
|
}
|
|
108
131
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useSyncExternalStore } from "react";
|
|
2
2
|
import { PageHeader } from "../components/PageHeader";
|
|
3
|
-
import {
|
|
3
|
+
import { CaseEscalations } from '../operations/CaseEscalations';
|
|
4
|
+
import { dispatchOpenReport, dispatchReportFilters } from "../reports/events";
|
|
4
5
|
import { dispatchNavigation } from "../shell/events";
|
|
5
6
|
import { compactDuration } from "./presentation";
|
|
6
7
|
import { getCommandSnapshot, subscribeToCommand } from "./store";
|
|
@@ -27,6 +28,10 @@ function EmptyReviewQueue() {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function Metrics({ metrics, loading }: { metrics: CommandMetricSnapshot; loading: boolean }) {
|
|
31
|
+
const openSummary = (label: string): void => {
|
|
32
|
+
dispatchReportFilters({ search: "", queueId: "", priority: label === "Urgent" ? "urgent" : "", age: "", state: "needs_review", sort: "priority", ownership: label === "Unassigned" ? "unassigned" : "" });
|
|
33
|
+
dispatchNavigation("reports");
|
|
34
|
+
};
|
|
30
35
|
const values: Array<{ label: string; value: number; detail: string; tone: CommandStatusTone }> = [
|
|
31
36
|
{ label: "To review", value: metrics.awaitingReview, detail: "Ready for a decision", tone: metrics.awaitingReview ? "warning" : "positive" },
|
|
32
37
|
{ label: "Urgent", value: metrics.urgent, detail: "Highest priority", tone: metrics.urgent ? "danger" : "positive" },
|
|
@@ -36,7 +41,7 @@ function Metrics({ metrics, loading }: { metrics: CommandMetricSnapshot; loading
|
|
|
36
41
|
return (
|
|
37
42
|
<section className="command-signals" aria-label="Live review summary" aria-busy={loading}>
|
|
38
43
|
{values.map((item) => (
|
|
39
|
-
<button className={`command-signal ${item.tone}`} type="button" key={item.label} onClick={() =>
|
|
44
|
+
item.label === "Reviewers online" ? <article className={`command-signal ${item.tone}`} key={item.label}><span>{item.label}</span><strong>{loading ? "—" : item.value}</strong><small>{item.detail}</small></article> : <button className={`command-signal ${item.tone}`} type="button" key={item.label} onClick={() => openSummary(item.label)}>
|
|
40
45
|
<span>{item.label}</span><strong>{loading ? "—" : item.value}</strong><small>{item.detail}</small>
|
|
41
46
|
</button>
|
|
42
47
|
))}
|
|
@@ -94,15 +99,15 @@ export function CommandCentre() {
|
|
|
94
99
|
const summary = snapshot.loading
|
|
95
100
|
? "Loading the live review queue."
|
|
96
101
|
: reviewCount
|
|
97
|
-
? `${reviewCount}
|
|
102
|
+
? `${reviewCount} case${reviewCount === 1 ? "" : "s"} awaiting review · oldest waiting ${compactDuration(snapshot.metrics.oldestWaitMinutes)}.`
|
|
98
103
|
: "The review queue is clear. New work will appear here automatically.";
|
|
99
|
-
const nextReport = snapshot.
|
|
104
|
+
const nextReport = snapshot.nextReview;
|
|
100
105
|
|
|
101
106
|
return (
|
|
102
107
|
<div data-react-slice="command-centre">
|
|
103
|
-
<PageHeader className="command-head" title="
|
|
108
|
+
<PageHeader className="command-head" title="Today" description={summary}>
|
|
104
109
|
<div className="surface-buttons command-actions">
|
|
105
|
-
{nextReport ? <button className="primary" type="button" onClick={() =>
|
|
110
|
+
{nextReport ? <button className="primary" type="button" onClick={() => dispatchOpenReport(nextReport.reportId, true)}><svg aria-hidden="true"><use href="#i-inbox" /></svg>{nextReport.mine ? "Continue review" : "Review next"}</button> : null}
|
|
106
111
|
{snapshot.permissions.useAssistant ? <button className="secondary command-assistant-action" type="button" onClick={() => dispatchNavigation("assistant")}><svg aria-hidden="true"><use href="#i-spark" /></svg>Ask Safest</button> : null}
|
|
107
112
|
</div>
|
|
108
113
|
</PageHeader>
|
|
@@ -112,6 +117,7 @@ export function CommandCentre() {
|
|
|
112
117
|
) : null}
|
|
113
118
|
|
|
114
119
|
<Metrics metrics={snapshot.metrics} loading={snapshot.loading} />
|
|
120
|
+
<CaseEscalations compact />
|
|
115
121
|
|
|
116
122
|
<div className="command-workspace-grid">
|
|
117
123
|
<section className="surface command-panel review-priority-panel">
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { loadInbox } from "../reports/load-inbox";
|
|
1
2
|
import { requestJson } from "../lib/http";
|
|
2
3
|
import { readable, relativeTime } from "../lib/format";
|
|
3
4
|
import { getOperationsWorkspaceSnapshot, subscribeToOperationsWorkspace } from "../operations/store";
|
|
@@ -19,7 +20,7 @@ interface CommandReport {
|
|
|
19
20
|
state: string;
|
|
20
21
|
priority: number;
|
|
21
22
|
receivedAt: string;
|
|
22
|
-
|
|
23
|
+
assignment?: { reviewerId?: string; analyst?: { displayName?: string } } | null;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
interface WorkflowRun {
|
|
@@ -34,7 +35,6 @@ interface WorkflowRun {
|
|
|
34
35
|
createdAt: string;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
interface ReportsResponse { reports?: CommandReport[] }
|
|
38
38
|
interface QueuesResponse { queues?: QueueRecord[] }
|
|
39
39
|
interface WorkflowRunsResponse { runs?: WorkflowRun[] }
|
|
40
40
|
|
|
@@ -55,7 +55,7 @@ let refreshScheduled = false;
|
|
|
55
55
|
let started = false;
|
|
56
56
|
|
|
57
57
|
function reportDataSignature(): string {
|
|
58
|
-
return reports.map((report) => `${report.id}:${report.state}:${report.priority}:${report.
|
|
58
|
+
return reports.map((report) => `${report.id}:${report.state}:${report.priority}:${report.assignment?.reviewerId || ""}`).sort().join("|");
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function reportStoreSignature(): string {
|
|
@@ -112,7 +112,7 @@ function buildSnapshot(): CommandSnapshot {
|
|
|
112
112
|
const rightBreached = slaStatus(reportAgeMinutes(right), rightTarget).breached;
|
|
113
113
|
if (leftBreached !== rightBreached) return leftBreached ? -1 : 1;
|
|
114
114
|
if (left.priority !== right.priority) return right.priority - left.priority;
|
|
115
|
-
if (Boolean(left.
|
|
115
|
+
if (Boolean(left.assignment) !== Boolean(right.assignment)) return left.assignment ? 1 : -1;
|
|
116
116
|
return Date.parse(left.receivedAt) - Date.parse(right.receivedAt);
|
|
117
117
|
});
|
|
118
118
|
|
|
@@ -125,7 +125,7 @@ function buildSnapshot(): CommandSnapshot {
|
|
|
125
125
|
reference: report.reference,
|
|
126
126
|
queueName: queue?.name || "Unrouted",
|
|
127
127
|
age: relativeTime(report.receivedAt),
|
|
128
|
-
ownership: report.
|
|
128
|
+
ownership: report.assignment ? (report.assignment.reviewerId === actorId ? "Assigned to you" : `Assigned to ${report.assignment.analyst?.displayName || "another reviewer"}`) : "Unassigned",
|
|
129
129
|
priorityLabel: priorityLabel(report.priority),
|
|
130
130
|
slaLabel: sla.label,
|
|
131
131
|
tone: report.priority >= 80 ? "danger" : sla.tone,
|
|
@@ -145,7 +145,7 @@ function buildSnapshot(): CommandSnapshot {
|
|
|
145
145
|
name: queue.name,
|
|
146
146
|
targetMinutes,
|
|
147
147
|
openCount: queuedReports.length,
|
|
148
|
-
unassignedCount: queuedReports.filter((report) => !report.
|
|
148
|
+
unassignedCount: queuedReports.filter((report) => !report.assignment).length,
|
|
149
149
|
oldestMinutes,
|
|
150
150
|
breachedCount,
|
|
151
151
|
pressure,
|
|
@@ -155,7 +155,7 @@ function buildSnapshot(): CommandSnapshot {
|
|
|
155
155
|
|
|
156
156
|
const optionalSetup = new Set(setup?.optional || []);
|
|
157
157
|
const incompleteSetupCount = setup?.features.filter((feature) => feature.state !== "ready" && !optionalSetup.has(feature.featureKey)).length || 0;
|
|
158
|
-
const unassigned = needsHuman.filter((report) => !report.
|
|
158
|
+
const unassigned = needsHuman.filter((report) => !report.assignment).length;
|
|
159
159
|
const urgent = needsHuman.filter((report) => report.priority >= 80).length;
|
|
160
160
|
const oldestWaitMinutes = needsHuman.length ? Math.max(...needsHuman.map(reportAgeMinutes)) : 0;
|
|
161
161
|
const systemIssueCount = Number(incidentCount > 0) + Number(workflowIssueCount > 0) + Number(deliveryFailureCount > 0);
|
|
@@ -170,6 +170,10 @@ function buildSnapshot(): CommandSnapshot {
|
|
|
170
170
|
oldestWaitMinutes,
|
|
171
171
|
},
|
|
172
172
|
attention,
|
|
173
|
+
nextReview: session?.permissions.claimReports ? (() => {
|
|
174
|
+
const next = sortedAttention.find(report => report.assignment?.reviewerId === actorId) || sortedAttention.find(report => !report.assignment);
|
|
175
|
+
return next ? { reportId: next.id, mine: next.assignment?.reviewerId === actorId } : null;
|
|
176
|
+
})() : null,
|
|
173
177
|
queueLoads,
|
|
174
178
|
systemHealth: {
|
|
175
179
|
issueCount: systemIssueCount,
|
|
@@ -201,8 +205,8 @@ async function loadCommandData(): Promise<void> {
|
|
|
201
205
|
loading = true;
|
|
202
206
|
publish();
|
|
203
207
|
const tasks: Promise<void>[] = [
|
|
204
|
-
|
|
205
|
-
.then((response) => { reports = response
|
|
208
|
+
loadInbox<CommandReport>()
|
|
209
|
+
.then((response) => { reports = response; })
|
|
206
210
|
.catch(() => { reports = []; }),
|
|
207
211
|
requestJson<QueuesResponse>(`/v1/admin/queues${session.permissions.manageConfiguration ? "?include_archived=true" : ""}`)
|
|
208
212
|
.then((response) => { queues = response.queues || []; })
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommandStatusTone } from "./types";
|
|
2
|
+
export { priorityLabel } from "../lib/priority";
|
|
2
3
|
|
|
3
4
|
export function compactDuration(minutes: number): string {
|
|
4
5
|
const rounded = Math.max(0, Math.floor(minutes));
|
|
@@ -9,12 +10,6 @@ export function compactDuration(minutes: number): string {
|
|
|
9
10
|
return `${Math.floor(hours / 24)}d`;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
export function priorityLabel(priority: number): string {
|
|
13
|
-
if (priority >= 80) return "Urgent";
|
|
14
|
-
if (priority >= 60) return "High";
|
|
15
|
-
return "Standard";
|
|
16
|
-
}
|
|
17
|
-
|
|
18
13
|
export function slaStatus(ageMinutes: number, targetMinutes: number | null): {
|
|
19
14
|
label: string;
|
|
20
15
|
tone: CommandStatusTone;
|
|
@@ -8,6 +8,7 @@ let snapshot: CommandSnapshot = {
|
|
|
8
8
|
generatedAt: new Date().toISOString(),
|
|
9
9
|
metrics: { awaitingReview: 0, urgent: 0, unassigned: 0, onlineReviewers: 0, oldestWaitMinutes: 0 },
|
|
10
10
|
attention: [],
|
|
11
|
+
nextReview: null,
|
|
11
12
|
queueLoads: [],
|
|
12
13
|
systemHealth: { issueCount: 0, incidentCount: 0, workflowIssueCount: 0, deliveryFailureCount: 0, activeRunCount: 0 },
|
|
13
14
|
incompleteSetupCount: 0,
|
|
@@ -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
|
+
}
|