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,14 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clearPrivateDrafts, removePrivateDraft } from "./private-drafts";
|
|
2
|
+
import { loadInbox } from "./load-inbox";
|
|
3
|
+
import { priorityName } from "../lib/priority";
|
|
4
|
+
import { ApiRequestError, errorMessage, requestJson } from "../lib/http";
|
|
2
5
|
import { readable } from "../lib/format";
|
|
3
6
|
import { getQueueWorkspaceSnapshot, subscribeToQueueWorkspace } from "../queues/store";
|
|
4
7
|
import type { QueueRecord } from "../queues/types";
|
|
5
8
|
import { getShellSnapshot, setShellSnapshot, subscribeToShell } from "../shell/store";
|
|
6
|
-
import { reportEvents } from "./events";
|
|
9
|
+
import { isReportSection, reportEvents } from "./events";
|
|
7
10
|
import { getReportWorkspaceSnapshot, setReportWorkspaceSnapshot } from "./store";
|
|
8
11
|
import type {
|
|
9
12
|
HumanDecisionInput,
|
|
10
13
|
PresenceAnalyst,
|
|
11
14
|
ReportDetailRecord,
|
|
15
|
+
ReportSection,
|
|
12
16
|
ReportFilters,
|
|
13
17
|
ReporterMessageInput,
|
|
14
18
|
ReportSummary,
|
|
@@ -18,6 +22,7 @@ import type {
|
|
|
18
22
|
} from "./types";
|
|
19
23
|
|
|
20
24
|
interface RawReport {
|
|
25
|
+
assignment?: RawReport["claim"];
|
|
21
26
|
id: string;
|
|
22
27
|
reference: string;
|
|
23
28
|
targetType: string;
|
|
@@ -43,7 +48,6 @@ interface RawPresenceAnalyst {
|
|
|
43
48
|
activeReportId?: string | null;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
interface ReportsResponse { reports?: RawReport[] }
|
|
47
51
|
interface ReportResponse { report: ReportDetailRecord }
|
|
48
52
|
interface TimelineResponse { events?: ReportDetailRecord["timeline"] }
|
|
49
53
|
interface WorkflowRunsResponse { runs?: WorkflowRunRecord[] }
|
|
@@ -51,7 +55,7 @@ interface WorkflowActionsResponse { actions?: WorkflowActionRecord[] }
|
|
|
51
55
|
interface FindingsResponse { findings?: ReportDetailRecord["findings"] }
|
|
52
56
|
interface PresenceResponse { analysts?: RawPresenceAnalyst[] }
|
|
53
57
|
|
|
54
|
-
const defaultFilters = (): ReportFilters => ({ search: "", queueId: "", priority: "", age: "", state: "", sort: "
|
|
58
|
+
const defaultFilters = (): ReportFilters => ({ search: "", queueId: "", priority: "", age: "", state: "needs_review", sort: "priority" });
|
|
55
59
|
const initialSnapshot = (): ReportWorkspaceSnapshot => ({
|
|
56
60
|
loading: true,
|
|
57
61
|
refreshing: false,
|
|
@@ -63,7 +67,7 @@ const initialSnapshot = (): ReportWorkspaceSnapshot => ({
|
|
|
63
67
|
reports: [],
|
|
64
68
|
presence: [],
|
|
65
69
|
actionCodes: [],
|
|
66
|
-
permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
|
|
70
|
+
permissions: { reassignReports: false, manageConfiguration: false, claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
|
|
67
71
|
detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
|
|
68
72
|
});
|
|
69
73
|
|
|
@@ -71,6 +75,7 @@ let actorId = "";
|
|
|
71
75
|
let rawReports: RawReport[] = [];
|
|
72
76
|
let rawPresence: RawPresenceAnalyst[] = [];
|
|
73
77
|
let detailRequestVersion = 0;
|
|
78
|
+
let listRequestVersion = 0;
|
|
74
79
|
let presenceSessionId = crypto.randomUUID();
|
|
75
80
|
let presenceSocket: WebSocket | null = null;
|
|
76
81
|
let presenceReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -80,11 +85,12 @@ let realtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
80
85
|
let presenceReconnectAttempts = 0;
|
|
81
86
|
let presenceRunning = false;
|
|
82
87
|
let started = false;
|
|
83
|
-
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return
|
|
88
|
+
const caseMutationKeys = new Map<string, { fingerprint: string; key: string }>();
|
|
89
|
+
function caseMutationKey(kind: string, reportId: string, input: unknown): string {
|
|
90
|
+
const fingerprint = JSON.stringify([getShellSnapshot().session?.actor.id, reportId, input]);
|
|
91
|
+
const previous = caseMutationKeys.get(kind);
|
|
92
|
+
if (previous?.fingerprint === fingerprint) return previous.key;
|
|
93
|
+
const key = crypto.randomUUID(); caseMutationKeys.set(kind, { fingerprint, key }); return key;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
function queues(): QueueRecord[] {
|
|
@@ -95,6 +101,10 @@ function visibleReports(filters: ReportFilters): RawReport[] {
|
|
|
95
101
|
const query = filters.search.trim().toLowerCase();
|
|
96
102
|
const now = Date.now();
|
|
97
103
|
return rawReports.filter((report) => {
|
|
104
|
+
if (filters.state === "needs_review" && !["human_review", "human_review_requested"].includes(report.state)) return false;
|
|
105
|
+
const claimed = Boolean(report.assignment);
|
|
106
|
+
if (filters.ownership === "unassigned" && claimed) return false;
|
|
107
|
+
if (filters.ownership === "mine" && (!claimed || report.assignment?.reviewerId !== actorId)) return false;
|
|
98
108
|
if (filters.priority && priorityName(report.priority) !== filters.priority) return false;
|
|
99
109
|
const milliseconds = now - Date.parse(report.receivedAt);
|
|
100
110
|
if (filters.age === "hour" && milliseconds > 3_600_000) return false;
|
|
@@ -124,12 +134,12 @@ function reportSummaries(filters: ReportFilters): ReportSummary[] {
|
|
|
124
134
|
state: report.state,
|
|
125
135
|
priority: Number(report.priority || 0),
|
|
126
136
|
receivedAt: report.receivedAt,
|
|
127
|
-
owner: report.
|
|
128
|
-
reviewerId: report.
|
|
129
|
-
mine: report.
|
|
130
|
-
displayName: report.
|
|
131
|
-
avatarUrl: report.
|
|
132
|
-
avatarColor: report.
|
|
137
|
+
owner: report.assignment ? {
|
|
138
|
+
reviewerId: report.assignment.reviewerId,
|
|
139
|
+
mine: report.assignment.reviewerId === session?.actor.id,
|
|
140
|
+
displayName: report.assignment.analyst?.displayName || "Unknown analyst",
|
|
141
|
+
avatarUrl: report.assignment.analyst?.avatarUrl || null,
|
|
142
|
+
avatarColor: report.assignment.analyst?.avatarColor || "#315d4d",
|
|
133
143
|
} : null,
|
|
134
144
|
}));
|
|
135
145
|
}
|
|
@@ -175,6 +185,8 @@ function publish(next?: Partial<ReportWorkspaceSnapshot>): void {
|
|
|
175
185
|
? configuration.allowed_action_codes
|
|
176
186
|
: [],
|
|
177
187
|
permissions: {
|
|
188
|
+
reassignReports: shell.session?.permissions.reassignReports === true,
|
|
189
|
+
manageConfiguration: shell.session?.permissions.manageConfiguration === true,
|
|
178
190
|
claimReports: shell.session?.permissions.claimReports === true,
|
|
179
191
|
messageReporters: shell.session?.permissions.messageReporters === true,
|
|
180
192
|
decideReports: shell.session?.permissions.decideReports === true,
|
|
@@ -185,6 +197,7 @@ function publish(next?: Partial<ReportWorkspaceSnapshot>): void {
|
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
async function loadReports(): Promise<void> {
|
|
200
|
+
const version = ++listRequestVersion;
|
|
188
201
|
const session = getShellSnapshot().session;
|
|
189
202
|
if (!session?.permissions.readReports) {
|
|
190
203
|
rawReports = [];
|
|
@@ -196,29 +209,33 @@ async function loadReports(): Promise<void> {
|
|
|
196
209
|
publish({ error: "", refreshing: !snapshot.loading });
|
|
197
210
|
const params = new URLSearchParams({ limit: "100" });
|
|
198
211
|
if (snapshot.filters.queueId) params.set("queue_id", snapshot.filters.queueId);
|
|
199
|
-
if (snapshot.filters.state) params.set("state", snapshot.filters.state);
|
|
212
|
+
if (snapshot.filters.state && snapshot.filters.state !== "needs_review") params.set("state", snapshot.filters.state);
|
|
200
213
|
try {
|
|
201
|
-
const
|
|
202
|
-
|
|
214
|
+
const reports = await loadInbox<RawReport>(params);
|
|
215
|
+
if (version !== listRequestVersion) return;
|
|
216
|
+
rawReports = reports;
|
|
203
217
|
setShellSnapshot((shell) => ({
|
|
204
218
|
...shell,
|
|
205
219
|
reportCount: rawReports.filter((report) => !["resolved_by_human", "resolved_by_ai", "closed"].includes(report.state)).length,
|
|
206
220
|
}));
|
|
207
221
|
} catch (cause) {
|
|
208
|
-
publish({ error: errorMessage(cause, "Reports could not be loaded.") });
|
|
222
|
+
if (version === listRequestVersion) publish({ error: errorMessage(cause, "Reports could not be loaded.") });
|
|
209
223
|
} finally {
|
|
210
|
-
publish({ loading: false, refreshing: false });
|
|
224
|
+
if (version === listRequestVersion) publish({ loading: false, refreshing: false });
|
|
211
225
|
}
|
|
212
226
|
}
|
|
213
227
|
|
|
214
|
-
async function openReport(reportId: string): Promise<void> {
|
|
215
|
-
const version = ++detailRequestVersion;
|
|
228
|
+
async function openReport(reportId: string, review = false, destination?: { section: ReportSection; taskId?: string }): Promise<void> {
|
|
216
229
|
const snapshot = getReportWorkspaceSnapshot();
|
|
230
|
+
if (snapshot.detail.reportId && snapshot.detail.reportId !== reportId && !window.dispatchEvent(new Event(reportEvents.beforeClose, { cancelable: true }))) return;
|
|
231
|
+
const version = ++detailRequestVersion;
|
|
217
232
|
document.body.classList.add("report-open");
|
|
218
233
|
publish({
|
|
219
234
|
detail: {
|
|
220
235
|
...snapshot.detail,
|
|
221
236
|
reportId,
|
|
237
|
+
reviewRequest: review ? (snapshot.detail.reviewRequest || 0) + 1 : snapshot.detail.reportId === reportId ? snapshot.detail.reviewRequest : 0,
|
|
238
|
+
destination: destination ? { ...destination, request: version } : snapshot.detail.reportId === reportId ? snapshot.detail.destination : undefined,
|
|
222
239
|
loading: true,
|
|
223
240
|
error: "",
|
|
224
241
|
statusMessage: snapshot.detail.reportId === reportId ? snapshot.detail.statusMessage : "",
|
|
@@ -229,12 +246,16 @@ async function openReport(reportId: string): Promise<void> {
|
|
|
229
246
|
const encodedId = encodeURIComponent(reportId);
|
|
230
247
|
const canReadRuns = getShellSnapshot().session?.permissions.readRuns === true;
|
|
231
248
|
const canReadEvidence = getShellSnapshot().session?.permissions.readEvidence === true;
|
|
249
|
+
const prior = snapshot.detail.report?.id === reportId ? snapshot.detail.report : null;
|
|
250
|
+
const partialLoadErrors: string[] = [];
|
|
251
|
+
const unavailable = <T>(label: string, fallback: T) => () => { partialLoadErrors.push(label); return fallback; };
|
|
252
|
+
const readOptions = () => ({ signal: AbortSignal.timeout(15_000) });
|
|
232
253
|
const [reportResponse, timelineResponse, runsResponse, actionsResponse, findingsResponse] = await Promise.all([
|
|
233
|
-
requestJson<ReportResponse>(`/v1/admin/reports/${encodedId}
|
|
234
|
-
requestJson<TimelineResponse>(`/v1/admin/reports/${encodedId}/timeline?limit=50
|
|
235
|
-
canReadRuns ? requestJson<WorkflowRunsResponse>(`/v1/admin/reports/${encodedId}/workflow-runs
|
|
236
|
-
requestJson<WorkflowActionsResponse>(`/v1/admin/reports/${encodedId}/actions
|
|
237
|
-
canReadEvidence ? requestJson<FindingsResponse>(`/v1/admin/reports/${encodedId}/findings
|
|
254
|
+
requestJson<ReportResponse>(`/v1/admin/reports/${encodedId}`, readOptions()),
|
|
255
|
+
requestJson<TimelineResponse>(`/v1/admin/reports/${encodedId}/timeline?limit=50`, readOptions()).catch(unavailable("activity", { events: prior?.timeline || [] })),
|
|
256
|
+
canReadRuns ? requestJson<WorkflowRunsResponse>(`/v1/admin/reports/${encodedId}/workflow-runs`, readOptions()).catch(unavailable("playbook progress", { runs: prior?.workflowRuns || [] })) : Promise.resolve({ runs: [] }),
|
|
257
|
+
requestJson<WorkflowActionsResponse>(`/v1/admin/reports/${encodedId}/actions`, readOptions()).catch(unavailable("customer action states", { actions: prior?.workflowActions || [] })),
|
|
258
|
+
canReadEvidence ? requestJson<FindingsResponse>(`/v1/admin/reports/${encodedId}/findings`, readOptions()).catch(unavailable("retrieved evidence", { findings: prior?.findings || [] })) : Promise.resolve({ findings: [] }),
|
|
238
259
|
]);
|
|
239
260
|
if (version !== detailRequestVersion || getReportWorkspaceSnapshot().detail.reportId !== reportId) return;
|
|
240
261
|
publish({
|
|
@@ -242,6 +263,7 @@ async function openReport(reportId: string): Promise<void> {
|
|
|
242
263
|
...getReportWorkspaceSnapshot().detail,
|
|
243
264
|
report: {
|
|
244
265
|
...reportResponse.report,
|
|
266
|
+
partialLoadErrors: partialLoadErrors.sort(),
|
|
245
267
|
timeline: timelineResponse.events || [],
|
|
246
268
|
workflowRuns: runsResponse.runs || [],
|
|
247
269
|
workflowActions: actionsResponse.actions || [],
|
|
@@ -252,7 +274,7 @@ async function openReport(reportId: string): Promise<void> {
|
|
|
252
274
|
sendPresenceActivity();
|
|
253
275
|
} catch (cause) {
|
|
254
276
|
if (version !== detailRequestVersion || getReportWorkspaceSnapshot().detail.reportId !== reportId) return;
|
|
255
|
-
publish({ detail: { ...getReportWorkspaceSnapshot().detail, report: null, error: errorMessage(cause, "The
|
|
277
|
+
publish({ detail: { ...getReportWorkspaceSnapshot().detail, ...(cause instanceof ApiRequestError && [401, 403, 404].includes(cause.status) ? { report: null } : {}), error: errorMessage(cause, "The case could not refresh. Use Refresh case to retry.") } });
|
|
256
278
|
} finally {
|
|
257
279
|
if (version === detailRequestVersion && getReportWorkspaceSnapshot().detail.reportId === reportId) {
|
|
258
280
|
publish({ detail: { ...getReportWorkspaceSnapshot().detail, loading: false } });
|
|
@@ -260,7 +282,8 @@ async function openReport(reportId: string): Promise<void> {
|
|
|
260
282
|
}
|
|
261
283
|
}
|
|
262
284
|
|
|
263
|
-
function closeReport(): void {
|
|
285
|
+
function closeReport(committed = false): void {
|
|
286
|
+
if (!committed && !window.dispatchEvent(new Event(reportEvents.beforeClose, { cancelable: true }))) return;
|
|
264
287
|
detailRequestVersion += 1;
|
|
265
288
|
const messageMutationVersion = getReportWorkspaceSnapshot().detail.messageMutationVersion;
|
|
266
289
|
document.body.classList.remove("report-open");
|
|
@@ -274,25 +297,39 @@ async function claimReport(): Promise<void> {
|
|
|
274
297
|
if (!reportId || !snapshot.permissions.claimReports || snapshot.detail.pendingAction) return;
|
|
275
298
|
publish({ detail: { ...snapshot.detail, error: "", pendingAction: "claim" } });
|
|
276
299
|
try {
|
|
277
|
-
await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "POST" });
|
|
278
|
-
|
|
300
|
+
const result = await requestJson<{ claim: NonNullable<ReportDetailRecord["claim"]> }>(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "POST" });
|
|
301
|
+
const detail = getReportWorkspaceSnapshot().detail;
|
|
302
|
+
const analyst = { displayName: getShellSnapshot().session?.displayName || "You" };
|
|
303
|
+
const claim = { ...result.claim, analyst };
|
|
304
|
+
publish({ detail: { ...detail, report: detail.report ? { ...detail.report, claim, assignment: claim } : null,
|
|
305
|
+
reviewRequest: (detail.reviewRequest || 0) + 1, statusMessage: "" } });
|
|
306
|
+
void Promise.all([openReport(reportId), loadReports()]);
|
|
279
307
|
} catch (cause) {
|
|
280
|
-
|
|
308
|
+
await openReport(reportId);
|
|
309
|
+
publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "Review could not be started. Your private draft is unchanged.") } });
|
|
310
|
+
|
|
281
311
|
} finally {
|
|
282
312
|
publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
|
|
283
313
|
}
|
|
284
314
|
}
|
|
285
315
|
|
|
286
|
-
async function
|
|
316
|
+
export async function changeCaseOwner(input: { reviewerId: string | null; displayName?: string; version: number; note: string }): Promise<boolean> {
|
|
287
317
|
const snapshot = getReportWorkspaceSnapshot();
|
|
288
318
|
const reportId = snapshot.detail.report?.id;
|
|
289
|
-
if (!reportId || !snapshot.permissions.
|
|
290
|
-
publish({ detail: { ...snapshot.detail, error: "", pendingAction: "release" } });
|
|
319
|
+
if (!reportId || !snapshot.permissions.reassignReports || snapshot.detail.pendingAction) return false;
|
|
320
|
+
publish({ detail: { ...snapshot.detail, error: "", statusMessage: "", pendingAction: input.reviewerId ? "transfer" : "release" } });
|
|
291
321
|
try {
|
|
292
|
-
await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/
|
|
293
|
-
|
|
322
|
+
await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/ownership`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ reviewer_id: input.reviewerId, version: input.version, note: input.note }) });
|
|
323
|
+
const detail = getReportWorkspaceSnapshot().detail;
|
|
324
|
+
const assignment = input.reviewerId ? { reviewerId: input.reviewerId, version: input.version + 1, expiresAt: "1970-01-01T00:00:00.000Z", analyst: { displayName: input.displayName || "Assigned reviewer" } } : null;
|
|
325
|
+
publish({ detail: { ...detail, report: detail.report ? { ...detail.report, assignment, claim: null } : null,
|
|
326
|
+
statusMessage: `${input.reviewerId ? "Case transferred" : "Case returned to the queue"}. Handoff note shared; your drafts remain private.` } });
|
|
327
|
+
void Promise.all([openReport(reportId), loadReports()]);
|
|
328
|
+
return true;
|
|
294
329
|
} catch (cause) {
|
|
295
|
-
|
|
330
|
+
await openReport(reportId);
|
|
331
|
+
publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "Ownership could not be changed. Your note and private draft are unchanged.") } });
|
|
332
|
+
return false;
|
|
296
333
|
} finally {
|
|
297
334
|
publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
|
|
298
335
|
}
|
|
@@ -302,7 +339,7 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
|
|
|
302
339
|
const snapshot = getReportWorkspaceSnapshot();
|
|
303
340
|
const reportId = snapshot.detail.report?.id;
|
|
304
341
|
if (!reportId || !snapshot.permissions.messageReporters || snapshot.detail.pendingAction) return;
|
|
305
|
-
const idempotencyKey =
|
|
342
|
+
const idempotencyKey = caseMutationKey("message", reportId, input);
|
|
306
343
|
publish({ detail: { ...snapshot.detail, error: "", statusMessage: "", pendingAction: "message" } });
|
|
307
344
|
try {
|
|
308
345
|
const response = await requestJson<{ delivery_state?: string; delivery_channel?: string | null }>(`/v1/admin/reports/${encodeURIComponent(reportId)}/messages`, {
|
|
@@ -310,6 +347,7 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
|
|
|
310
347
|
headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
|
|
311
348
|
body: JSON.stringify({ idempotency_key: idempotencyKey, body: input.body, await_reporter: input.awaitReporter }),
|
|
312
349
|
});
|
|
350
|
+
caseMutationKeys.delete("message");
|
|
313
351
|
const version = getReportWorkspaceSnapshot().detail.messageMutationVersion + 1;
|
|
314
352
|
const statusMessage = response.delivery_state === "queued"
|
|
315
353
|
? response.delivery_channel === "email"
|
|
@@ -317,7 +355,8 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
|
|
|
317
355
|
: "Participant update queued for the configured customer notification channel."
|
|
318
356
|
: "The update was saved but not sent because this report has no available participant delivery channel.";
|
|
319
357
|
publish({ detail: { ...getReportWorkspaceSnapshot().detail, messageMutationVersion: version, statusMessage } });
|
|
320
|
-
|
|
358
|
+
// The mutation is acknowledged. Background refresh must not keep unrelated actions locked.
|
|
359
|
+
void Promise.all([openReport(reportId), loadReports()]);
|
|
321
360
|
} catch (cause) {
|
|
322
361
|
publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The message could not be created.") } });
|
|
323
362
|
} finally {
|
|
@@ -329,7 +368,7 @@ async function recordDecision(input: HumanDecisionInput): Promise<void> {
|
|
|
329
368
|
const snapshot = getReportWorkspaceSnapshot();
|
|
330
369
|
const reportId = snapshot.detail.report?.id;
|
|
331
370
|
if (!reportId || !snapshot.permissions.decideReports || snapshot.detail.pendingAction) return;
|
|
332
|
-
const idempotencyKey =
|
|
371
|
+
const idempotencyKey = caseMutationKey("decision", reportId, input);
|
|
333
372
|
publish({ detail: { ...snapshot.detail, error: "", pendingAction: "decision" } });
|
|
334
373
|
try {
|
|
335
374
|
await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/decisions`, {
|
|
@@ -346,8 +385,10 @@ async function recordDecision(input: HumanDecisionInput): Promise<void> {
|
|
|
346
385
|
...(input.actionCode ? { action_code: input.actionCode } : {}),
|
|
347
386
|
}),
|
|
348
387
|
});
|
|
349
|
-
|
|
350
|
-
|
|
388
|
+
caseMutationKeys.delete("decision");
|
|
389
|
+
removePrivateDraft(getShellSnapshot().session?.actor.id || "", reportId);
|
|
390
|
+
closeReport(true);
|
|
391
|
+
void loadReports();
|
|
351
392
|
} catch (cause) {
|
|
352
393
|
publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The decision could not be recorded.") } });
|
|
353
394
|
} finally {
|
|
@@ -505,9 +546,10 @@ function validFilters(value: unknown): value is ReportFilters {
|
|
|
505
546
|
const filters = value as Partial<ReportFilters>;
|
|
506
547
|
return typeof filters.search === "string"
|
|
507
548
|
&& typeof filters.queueId === "string"
|
|
508
|
-
&& ["", "high", "medium", "low"].includes(filters.priority ?? "invalid")
|
|
549
|
+
&& ["", "urgent", "high", "medium", "low"].includes(filters.priority ?? "invalid")
|
|
509
550
|
&& ["", "hour", "day", "older"].includes(filters.age ?? "invalid")
|
|
510
551
|
&& typeof filters.state === "string"
|
|
552
|
+
&& [undefined, "", "unassigned", "mine"].includes(filters.ownership)
|
|
511
553
|
&& ["oldest", "newest", "priority"].includes(filters.sort ?? "invalid");
|
|
512
554
|
}
|
|
513
555
|
|
|
@@ -521,6 +563,7 @@ export function startReportController(): void {
|
|
|
521
563
|
publish();
|
|
522
564
|
return;
|
|
523
565
|
}
|
|
566
|
+
if (actorId && actorId !== nextActorId) clearPrivateDrafts();
|
|
524
567
|
actorId = nextActorId;
|
|
525
568
|
detailRequestVersion += 1;
|
|
526
569
|
stopPresence();
|
|
@@ -535,9 +578,14 @@ export function startReportController(): void {
|
|
|
535
578
|
});
|
|
536
579
|
subscribeToQueueWorkspace(() => publish());
|
|
537
580
|
window.addEventListener(reportEvents.open, (event) => {
|
|
538
|
-
if (event instanceof CustomEvent
|
|
581
|
+
if (!(event instanceof CustomEvent) || typeof event.detail?.reportId !== "string") return;
|
|
582
|
+
const destination = isReportSection(event.detail.section) ? {
|
|
583
|
+
section: event.detail.section,
|
|
584
|
+
...(typeof event.detail.taskId === "string" && event.detail.taskId.length <= 100 ? { taskId: event.detail.taskId } : {}),
|
|
585
|
+
} : undefined;
|
|
586
|
+
void openReport(event.detail.reportId, event.detail.review === true, destination);
|
|
539
587
|
});
|
|
540
|
-
window.addEventListener(reportEvents.close, closeReport);
|
|
588
|
+
window.addEventListener(reportEvents.close, () => closeReport());
|
|
541
589
|
window.addEventListener(reportEvents.refresh, () => { void loadReports(); });
|
|
542
590
|
window.addEventListener(reportEvents.filters, (event) => {
|
|
543
591
|
if (!(event instanceof CustomEvent) || !validFilters(event.detail?.filters)) return;
|
|
@@ -547,7 +595,6 @@ export function startReportController(): void {
|
|
|
547
595
|
if (filters.queueId !== previous.queueId || filters.state !== previous.state) void loadReports();
|
|
548
596
|
});
|
|
549
597
|
window.addEventListener(reportEvents.claim, () => { void claimReport(); });
|
|
550
|
-
window.addEventListener(reportEvents.release, () => { void releaseReport(); });
|
|
551
598
|
window.addEventListener(reportEvents.message, (event) => {
|
|
552
599
|
if (!(event instanceof CustomEvent) || typeof event.detail?.body !== "string" || typeof event.detail?.awaitReporter !== "boolean") return;
|
|
553
600
|
void sendMessage(event.detail as ReporterMessageInput);
|
|
@@ -557,7 +604,7 @@ export function startReportController(): void {
|
|
|
557
604
|
void recordDecision(event.detail as HumanDecisionInput);
|
|
558
605
|
});
|
|
559
606
|
window.addEventListener("keydown", (event) => {
|
|
560
|
-
if (event.key === "Escape" && getReportWorkspaceSnapshot().detail.reportId) closeReport();
|
|
607
|
+
if (event.key === "Escape" && !event.defaultPrevented && !document.querySelector(".case-handoff-dialog[open]") && getReportWorkspaceSnapshot().detail.reportId) closeReport();
|
|
561
608
|
});
|
|
562
609
|
window.addEventListener("focus", () => {
|
|
563
610
|
scheduleRealtimeRefresh(null, true);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HumanDecisionInput, ReportFilters, ReporterMessageInput, ReportWorkspaceSnapshot } from "./types";
|
|
1
|
+
import type { HumanDecisionInput, ReportFilters, ReporterMessageInput, ReportWorkspaceSnapshot, ReportSection } from "./types";
|
|
2
2
|
|
|
3
3
|
export const reportEvents = {
|
|
4
4
|
state: "safest:reports:state",
|
|
@@ -6,6 +6,7 @@ export const reportEvents = {
|
|
|
6
6
|
filters: "safest:reports:filters",
|
|
7
7
|
open: "safest:reports:open",
|
|
8
8
|
close: "safest:reports:close",
|
|
9
|
+
beforeClose: "safest:reports:before-close",
|
|
9
10
|
claim: "safest:reports:claim",
|
|
10
11
|
release: "safest:reports:release",
|
|
11
12
|
message: "safest:reports:message",
|
|
@@ -18,7 +19,13 @@ function dispatch<TDetail>(type: string, detail?: TDetail): void {
|
|
|
18
19
|
|
|
19
20
|
export function dispatchReportRefresh(): void { dispatch(reportEvents.refresh); }
|
|
20
21
|
export function dispatchReportFilters(filters: ReportFilters): void { dispatch(reportEvents.filters, { filters }); }
|
|
21
|
-
export function dispatchOpenReport(reportId: string): void { dispatch(reportEvents.open, { reportId }); }
|
|
22
|
+
export function dispatchOpenReport(reportId: string, review = false): void { dispatch(reportEvents.open, { reportId, review }); }
|
|
23
|
+
export function dispatchOpenReportSection(reportId: string, section: ReportSection, taskId?: string): void {
|
|
24
|
+
dispatch(reportEvents.open, { reportId, section, taskId });
|
|
25
|
+
}
|
|
26
|
+
export function isReportSection(value: unknown): value is ReportSection {
|
|
27
|
+
return typeof value === "string" && ["overview", "evidence", "review", "messages", "outcome", "activity"].includes(value);
|
|
28
|
+
}
|
|
22
29
|
export function dispatchCloseReport(): void { dispatch(reportEvents.close); }
|
|
23
30
|
export function dispatchClaimReport(): void { dispatch(reportEvents.claim); }
|
|
24
31
|
export function dispatchReleaseReport(): void { dispatch(reportEvents.release); }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { requestJson } from "../lib/http";
|
|
2
|
+
|
|
3
|
+
// The API returns at most 100 reports. Follow its opaque cursor so search,
|
|
4
|
+
// sorting, and summary counts include the entire authorized result set.
|
|
5
|
+
export async function loadInbox<T>(query = new URLSearchParams()): Promise<T[]> {
|
|
6
|
+
const params = new URLSearchParams(query);
|
|
7
|
+
params.set("limit", "100");
|
|
8
|
+
const reports: T[] = [];
|
|
9
|
+
let cursor: string | null = null;
|
|
10
|
+
do {
|
|
11
|
+
if (cursor) params.set("cursor", cursor);
|
|
12
|
+
const page = await requestJson<{ reports?: T[]; next_cursor?: string | null }>(`/v1/admin/reports?${params}`);
|
|
13
|
+
reports.push(...(page.reports || []));
|
|
14
|
+
cursor = page.next_cursor || null;
|
|
15
|
+
} while (cursor);
|
|
16
|
+
return reports;
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const prefix = "safest:private-case-draft:";
|
|
2
|
+
export interface PrivateCaseDraft {
|
|
3
|
+
message: string; awaitReporter: boolean; decisionCode: string; policyCode: string;
|
|
4
|
+
rationale: string; reporterNotice: string; affectedNotice: string; actionCode: string; evidence: string[];
|
|
5
|
+
taskDecision?: string; findings?: string[];
|
|
6
|
+
}
|
|
7
|
+
const memory = new Map<string, PrivateCaseDraft>();
|
|
8
|
+
const key = (actorId: string, reportId: string) => `${prefix}${encodeURIComponent(actorId)}:${encodeURIComponent(reportId)}`;
|
|
9
|
+
export function readPrivateDraft(actorId: string, reportId: string): PrivateCaseDraft | null {
|
|
10
|
+
const id = key(actorId, reportId);
|
|
11
|
+
if (memory.has(id)) return memory.get(id)!;
|
|
12
|
+
try {
|
|
13
|
+
const stored = JSON.parse(sessionStorage.getItem(id) || "null");
|
|
14
|
+
if (stored && Date.now() - stored.savedAt < 86_400_000 && typeof stored.draft?.message === "string" && Array.isArray(stored.draft.evidence)) return stored.draft;
|
|
15
|
+
sessionStorage.removeItem(id);
|
|
16
|
+
} catch { /* A browser can deny storage; in-memory drafts still work. */ }
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export function savePrivateDraft(actorId: string, reportId: string, draft: PrivateCaseDraft): void {
|
|
20
|
+
const id = key(actorId, reportId);
|
|
21
|
+
if (![draft.message,draft.rationale,draft.reporterNotice,draft.affectedNotice,draft.actionCode,draft.taskDecision].some(Boolean)) { removePrivateDraft(actorId,reportId); return; }
|
|
22
|
+
memory.set(id,draft);
|
|
23
|
+
try { sessionStorage.setItem(id,JSON.stringify({ savedAt:Date.now(),draft })); } catch { /* Keep the in-memory copy. */ }
|
|
24
|
+
}
|
|
25
|
+
export function removePrivateDraft(actorId: string, reportId: string): void {
|
|
26
|
+
const id = key(actorId,reportId); memory.delete(id);
|
|
27
|
+
try { sessionStorage.removeItem(id); } catch { /* Storage may be unavailable. */ }
|
|
28
|
+
}
|
|
29
|
+
export function clearPrivateDrafts(): void {
|
|
30
|
+
memory.clear();
|
|
31
|
+
try { Object.keys(sessionStorage).filter(id => id.startsWith(prefix)).forEach(id => sessionStorage.removeItem(id)); } catch { /* Storage may be unavailable. */ }
|
|
32
|
+
}
|
|
@@ -9,12 +9,12 @@ let snapshot: ReportWorkspaceSnapshot = {
|
|
|
9
9
|
error: "",
|
|
10
10
|
generatedAt: new Date().toISOString(),
|
|
11
11
|
actorId: "",
|
|
12
|
-
filters: { search: "", queueId: "", priority: "", age: "", state: "", sort: "
|
|
12
|
+
filters: { search: "", queueId: "", priority: "", age: "", state: "needs_review", sort: "priority" },
|
|
13
13
|
queues: [],
|
|
14
14
|
reports: [],
|
|
15
15
|
presence: [],
|
|
16
16
|
actionCodes: [],
|
|
17
|
-
permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
|
|
17
|
+
permissions: { reassignReports: false, manageConfiguration: false, claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
|
|
18
18
|
detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
|
|
19
19
|
};
|
|
20
20
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AnalystIdentityData } from "../components/AnalystIdentity";
|
|
2
2
|
|
|
3
|
-
export type ReportPriorityFilter = "" | "high" | "medium" | "low";
|
|
3
|
+
export type ReportPriorityFilter = "" | "urgent" | "high" | "medium" | "low";
|
|
4
4
|
export type ReportAgeFilter = "" | "hour" | "day" | "older";
|
|
5
5
|
export type ReportSort = "oldest" | "newest" | "priority";
|
|
6
6
|
|
|
@@ -11,6 +11,7 @@ export interface ReportFilters {
|
|
|
11
11
|
age: ReportAgeFilter;
|
|
12
12
|
state: string;
|
|
13
13
|
sort: ReportSort;
|
|
14
|
+
ownership?: "" | "unassigned" | "mine";
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface ReportQueueOption {
|
|
@@ -51,6 +52,7 @@ export interface EvidenceReference {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
export interface CaseMessage {
|
|
55
|
+
audience?: "reporter" | "affected_user" | "internal";
|
|
54
56
|
id?: string;
|
|
55
57
|
direction: string;
|
|
56
58
|
body: string;
|
|
@@ -81,6 +83,7 @@ export interface TimelineEvent {
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
export interface FindingRecord {
|
|
86
|
+
runId?: string | null;
|
|
84
87
|
id?: string;
|
|
85
88
|
findingType: string;
|
|
86
89
|
summary: string;
|
|
@@ -117,13 +120,29 @@ export interface DeliveryAction {
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
export interface DeliveryNotice {
|
|
123
|
+
purpose?: "appeal" | "case";
|
|
120
124
|
id?: string;
|
|
125
|
+
messageId?: string;
|
|
126
|
+
decisionId?: string | null;
|
|
127
|
+
channel?: string | null;
|
|
128
|
+
attemptCount?: number;
|
|
129
|
+
errorCode?: string | null;
|
|
121
130
|
audience: string;
|
|
122
131
|
deliveryState: string;
|
|
123
132
|
updatedAt: string;
|
|
124
133
|
}
|
|
125
134
|
|
|
126
135
|
export interface WorkflowActionRecord {
|
|
136
|
+
runId?: string;
|
|
137
|
+
payloadDigest?: string;
|
|
138
|
+
terminal?: boolean;
|
|
139
|
+
retryable?: boolean;
|
|
140
|
+
acceptedAt?: string | null;
|
|
141
|
+
confirmedAt?: string | null;
|
|
142
|
+
confirmationDeadlineAt?: string | null;
|
|
143
|
+
deliveryCycle?: number;
|
|
144
|
+
confirmationState?: string;
|
|
145
|
+
capabilityName?: string;
|
|
127
146
|
id?: string;
|
|
128
147
|
recordKind: string;
|
|
129
148
|
actionCode: string;
|
|
@@ -154,6 +173,7 @@ export interface PolicyRule {
|
|
|
154
173
|
}
|
|
155
174
|
|
|
156
175
|
export interface ReportClaim {
|
|
176
|
+
version: number;
|
|
157
177
|
reviewerId: string;
|
|
158
178
|
expiresAt: string;
|
|
159
179
|
analyst?: AnalystIdentityData;
|
|
@@ -175,6 +195,11 @@ export interface ReportAnswer {
|
|
|
175
195
|
}
|
|
176
196
|
|
|
177
197
|
export interface ReportDetailRecord {
|
|
198
|
+
relatedReports?: Array<{id:string;reference:string;state:string;reasonCode:string;submittedAt:string;queueName:string|null}>;
|
|
199
|
+
appeals?: Array<{id:string;audience:string;state:string;submittedAt:string;completionState:string|null;correctionRequired:number;correctionConfirmedAt:string|null;noticeState:string|null}>;
|
|
200
|
+
partialLoadErrors?: string[];
|
|
201
|
+
customerFacts?: Record<string, unknown>;
|
|
202
|
+
playbookManaged?: boolean;
|
|
178
203
|
id: string;
|
|
179
204
|
reference: string;
|
|
180
205
|
state: string;
|
|
@@ -212,11 +237,23 @@ export interface ReportDetailRecord {
|
|
|
212
237
|
workflowActions?: WorkflowActionRecord[];
|
|
213
238
|
aiRuns?: AiRunRecord[];
|
|
214
239
|
claim?: ReportClaim | null;
|
|
240
|
+
assignment?: ReportClaim | null;
|
|
241
|
+
handoffs?: Array<{ id: string; fromReviewerId: string; toReviewerId: string | null; note: string; authorName: string; createdAt: string }>;
|
|
215
242
|
}
|
|
216
243
|
|
|
217
|
-
export type ReportPendingAction = "claim" | "release" | "message" | "decision" | null;
|
|
244
|
+
export type ReportPendingAction = "claim" | "release" | "transfer" | "message" | "decision" | null;
|
|
245
|
+
|
|
246
|
+
export type ReportSection = "overview" | "evidence" | "review" | "messages" | "outcome" | "activity";
|
|
247
|
+
|
|
248
|
+
export interface ReportDestination {
|
|
249
|
+
section: ReportSection;
|
|
250
|
+
taskId?: string;
|
|
251
|
+
request: number;
|
|
252
|
+
}
|
|
218
253
|
|
|
219
254
|
export interface ReportDetailSnapshot {
|
|
255
|
+
reviewRequest?: number;
|
|
256
|
+
destination?: ReportDestination;
|
|
220
257
|
reportId: string | null;
|
|
221
258
|
loading: boolean;
|
|
222
259
|
error: string;
|
|
@@ -227,6 +264,8 @@ export interface ReportDetailSnapshot {
|
|
|
227
264
|
}
|
|
228
265
|
|
|
229
266
|
export interface ReportPermissions {
|
|
267
|
+
reassignReports: boolean;
|
|
268
|
+
manageConfiguration: boolean;
|
|
230
269
|
claimReports: boolean;
|
|
231
270
|
messageReporters: boolean;
|
|
232
271
|
decideReports: boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// Expiry must disable inputs even when offline and no presence update arrives.
|
|
4
|
+
export function useEditingAccess(expiresAt?: string | null): boolean {
|
|
5
|
+
const [, tick] = useState(0);
|
|
6
|
+
const expiry = Date.parse(expiresAt || "");
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
if (!Number.isFinite(expiry) || expiry <= Date.now()) return;
|
|
9
|
+
const timer = setTimeout(() => tick(value => value + 1), Math.min(2_147_483_647,expiry - Date.now() + 10));
|
|
10
|
+
return () => clearTimeout(timer);
|
|
11
|
+
}, [expiry]);
|
|
12
|
+
return Number.isFinite(expiry) && expiry > Date.now();
|
|
13
|
+
}
|
|
@@ -144,7 +144,7 @@ export function SettingsWorkspace() {
|
|
|
144
144
|
const isOptional = optional.has(feature.featureKey);
|
|
145
145
|
return <article className="setup-row" key={feature.featureKey}><span className={`setup-state${ready ? "" : " warning"}`}>{ready ? "✓" : "!"}</span><div><strong>{featureName(feature.featureKey)}</strong><small>{feature.message || feature.detail || (ready ? "Verified in this deployment" : isOptional ? "Optional setup" : "Verification required")}</small></div>{ready ? <span className="badge">Ready</span> : <button className="secondary compact-button" type="button" disabled={Boolean(verifying)} onClick={() => void verify(feature.featureKey)}>{verifying === feature.featureKey ? "Checking…" : isOptional ? "Check optional" : "Verify"}</button>}</article>;
|
|
146
146
|
}) : <div className="settings-empty-row"><strong>No setup items</strong><small>There is nothing to verify for this deployment.</small></div>}</div>}</section>
|
|
147
|
-
<section className="surface settings-card" aria-labelledby="workspace-details-title"><div className="surface-head"><h2 id="workspace-details-title">Workspace details</h2></div>{loading && !installation ? <SettingsLoading /> : installation ? <dl className="settings-facts">{installationFacts(installation).map(([label, value]) => <
|
|
147
|
+
<section className="surface settings-card" aria-labelledby="workspace-details-title"><div className="surface-head"><h2 id="workspace-details-title">Workspace details</h2></div>{loading && !installation ? <SettingsLoading /> : installation ? <dl className="settings-facts">{installationFacts(installation).map(([label, value]) => <div className="settings-fact" key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl> : <div className="settings-empty-row">Workspace details are unavailable.</div>}</section>
|
|
148
148
|
</> : null}
|
|
149
149
|
{canManageConfiguration ? <BrandEditor brand={brand} displayName={installation?.displayName ?? "Safest Resolve"} loading={loading && !brand} onSaved={setBrand} /> : null}
|
|
150
150
|
{canManageIntegrations ? <section className="surface settings-card settings-wide" aria-labelledby="reporting-channels-title"><div className="surface-head"><div><h2 id="reporting-channels-title">Reporting channels</h2><p>Choose where people open the form and what context can be trusted.</p></div><button className="primary" type="button" onClick={openChannelDialog}>Add channel</button></div>{loading && !channels.length && !integrations.length ? <SettingsLoading /> : <div className="integration-list">{channels.map((channel) => <ChannelRow channel={channel} key={channel.id} />)}{integrations.map((integration) => <IntegrationRow integration={integration} key={integration.id} />)}{!channels.length && !integrations.length ? <div className="settings-empty-row"><strong>No reporting channels yet</strong><small>Add a hosted form or connect the widget to your application.</small></div> : null}</div>}</section> : null}
|