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