create-safest-tools 0.3.1 → 0.4.1
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 +8 -4
- package/package.json +1 -1
- package/src/cli.mjs +52 -7
- package/src/config.mjs +26 -5
- package/src/scaffold.mjs +4 -1
- package/src/update.mjs +295 -0
- package/template/README.md +7 -5
- package/template/console/reports/ReportDrawer.tsx +51 -6
- package/template/console/reports/controller.ts +11 -5
- package/template/console/reports/store.ts +1 -1
- package/template/console/reports/types.ts +23 -0
- package/template/console/shell/WorkspaceShell.tsx +0 -4
- package/template/migrations/0001_safest_resolve_baseline.sql +2446 -0
- package/template/package.json +4 -3
- package/template/public/console/auth-shell.js +4333 -4243
- package/template/public/styles.css +19 -17
- package/template/reports.config.example.json +2 -1
- package/template/reports.schema.json +2 -1
- package/template/scripts/reports-auth-onboarding.mjs +6 -5
- package/template/scripts/reports-plan.mjs +14 -5
- package/template/src/index.ts +12 -3
- package/template/src/installation-admin.ts +2 -1
- package/template/src/report-ai.ts +30 -5
- package/template/src/report-better-auth.ts +5 -14
- package/template/src/report-delivery.ts +4 -3
- package/template/src/report-email.ts +106 -0
- package/template/src/report-workflow.ts +103 -21
- package/template/src/workflow-effects.ts +4 -3
- package/template/worker-configuration.d.ts +5 -3
- package/template/migrations/0001_reports_foundation.sql +0 -444
- package/template/migrations/0002_human_report_loop.sql +0 -65
- package/template/migrations/0003_delivery_reliability.sql +0 -18
- package/template/migrations/0004_public_intake.sql +0 -14
- package/template/migrations/0005_operations_visibility.sql +0 -24
- package/template/migrations/0006_ai_governance.sql +0 -198
- package/template/migrations/0007_ai_release_gates.sql +0 -6
- package/template/migrations/0008_retention_analytics_exports.sql +0 -52
- package/template/migrations/0009_retention_derived_copies.sql +0 -14
- package/template/migrations/0010_ai_quality_controls.sql +0 -26
- package/template/migrations/0011_analyst_presence.sql +0 -28
- package/template/migrations/0012_queue_policies.sql +0 -87
- package/template/migrations/0013_routing_agents.sql +0 -63
- package/template/migrations/0014_webhook_enrichments.sql +0 -122
- package/template/migrations/0015_queue_owned_ai.sql +0 -55
- package/template/migrations/0016_operator_accounts.sql +0 -67
- package/template/migrations/0017_operator_profiles_and_recovery.sql +0 -33
- package/template/migrations/0018_platform_configuration.sql +0 -382
- package/template/migrations/0019_workflow_authoring_runtime.sql +0 -372
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +0 -427
- package/template/migrations/0021_abuse_evidence_operations.sql +0 -324
- package/template/migrations/0022_workflow_dispatch_operations.sql +0 -42
- package/template/migrations/0023_component_connection_execution.sql +0 -75
- package/template/migrations/0024_access_runtime_integrity.sql +0 -72
- package/template/migrations/0025_installation_timezone.sql +0 -11
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +0 -49
- package/template/migrations/0027_prompt_and_ai_registry.sql +0 -37
- package/template/migrations/0028_step_attempt_ai_provenance.sql +0 -13
- package/template/migrations/0029_evidence_fetch_transport.sql +0 -5
- package/template/migrations/0030_evidence_dlq_incidents.sql +0 -45
- package/template/migrations/0031_shadow_quality_integrity.sql +0 -7
- package/template/migrations/0032_action_delivery_outbox.sql +0 -55
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +0 -43
- package/template/migrations/0034_installation_integrations.sql +0 -31
- package/template/migrations/0035_workspace_governance.sql +0 -21
- package/template/migrations/0036_published_routing_baseline.sql +0 -15
- package/template/migrations/0037_builtin_phishing_specialist.sql +0 -71
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +0 -228
- package/template/migrations/0039_secure_reporting_channels.sql +0 -45
- package/template/migrations/0040_notification_only_reporting.sql +0 -24
- package/template/migrations/0041_better_auth_credentials.sql +0 -17
- package/template/migrations/0042_owner_setup_tokens.sql +0 -29
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
dispatchReporterMessage,
|
|
10
10
|
} from "./events";
|
|
11
11
|
import { getReportWorkspaceSnapshot, subscribeToReportWorkspace } from "./store";
|
|
12
|
-
import type { ReportDetailRecord, TimelineEvent } from "./types";
|
|
12
|
+
import type { ReportAnswer, ReportDetailRecord, TimelineEvent } from "./types";
|
|
13
13
|
|
|
14
14
|
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
|
|
15
15
|
return <><dt>{label}</dt><dd>{children || "—"}</dd></>;
|
|
@@ -19,6 +19,40 @@ function EmptyLine({ children }: { children: React.ReactNode }) {
|
|
|
19
19
|
return <p className="detail-empty">{children}</p>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
function answerOptionLabel(answer: ReportAnswer, value: string): string {
|
|
23
|
+
return answer.options?.find((option) => option.value === value)?.label || readable(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function AnswerValue({ answer }: { answer: ReportAnswer }) {
|
|
27
|
+
if (!answer.answered) return <span className="answer-empty">Not answered</span>;
|
|
28
|
+
if (typeof answer.value === "boolean") return <span>{answer.value ? "Yes" : "No"}</span>;
|
|
29
|
+
if (Array.isArray(answer.value)) {
|
|
30
|
+
const values = answer.value.map((value) => answerOptionLabel(answer, String(value)));
|
|
31
|
+
return <span>{values.length ? values.join(", ") : "None selected"}</span>;
|
|
32
|
+
}
|
|
33
|
+
if (answer.value === null || answer.value === undefined || answer.value === "") return <span className="answer-empty">Not answered</span>;
|
|
34
|
+
const value = String(answer.value);
|
|
35
|
+
if (answer.type === "select") return <span>{answerOptionLabel(answer, value)}</span>;
|
|
36
|
+
const url = answer.type === "url" ? safeCustomerUrl(value) : null;
|
|
37
|
+
return url
|
|
38
|
+
? <a className="text-button" href={url} target="_blank" rel="noopener noreferrer" referrerPolicy="no-referrer">{value} ↗</a>
|
|
39
|
+
: <span className="answer-text">{value}</span>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function deliveryLabel(state: string): string {
|
|
43
|
+
if (state === "suppressed") return "Not sent";
|
|
44
|
+
if (state === "queued" || state === "pending" || state === "sending" || state === "delivering") return "Queued";
|
|
45
|
+
return readable(state);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function deliveryExplanation(state: string): string {
|
|
49
|
+
if (state === "suppressed") return "No eligible participant delivery channel was available when this message was created.";
|
|
50
|
+
if (state === "queued" || state === "pending" || state === "sending" || state === "delivering") return "Waiting for the delivery worker.";
|
|
51
|
+
if (state === "delivered") return "Successfully handed to the configured delivery provider.";
|
|
52
|
+
if (state === "failed" || state === "rejected" || state === "bounced") return "Delivery needs attention in Operations.";
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
function timelineDetail(event: TimelineEvent): string {
|
|
23
57
|
const details = event.details ?? {};
|
|
24
58
|
const value = (key: string): string => typeof details[key] === "string" || typeof details[key] === "number" ? String(details[key]) : "";
|
|
@@ -38,7 +72,7 @@ function timelineMarker(eventType: string): string {
|
|
|
38
72
|
return "•";
|
|
39
73
|
}
|
|
40
74
|
|
|
41
|
-
function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, pendingAction, messageMutationVersion, error }: {
|
|
75
|
+
function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports, messageReporters, decideReports, pendingAction, messageMutationVersion, statusMessage, error }: {
|
|
42
76
|
report: ReportDetailRecord;
|
|
43
77
|
actorId: string;
|
|
44
78
|
generatedAt: string;
|
|
@@ -48,9 +82,11 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
48
82
|
decideReports: boolean;
|
|
49
83
|
pendingAction: "claim" | "release" | "message" | "decision" | null;
|
|
50
84
|
messageMutationVersion: number;
|
|
85
|
+
statusMessage: string;
|
|
51
86
|
error: string;
|
|
52
87
|
}) {
|
|
53
88
|
const evidence = report.evidence ?? [];
|
|
89
|
+
const answers = report.answers ?? [];
|
|
54
90
|
const messages = report.messages ?? [];
|
|
55
91
|
const workflowRuns = report.workflowRuns ?? [];
|
|
56
92
|
const timeline = report.timeline ?? [];
|
|
@@ -106,6 +142,14 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
106
142
|
return (
|
|
107
143
|
<>
|
|
108
144
|
<div className="detail-grid">
|
|
145
|
+
<section className="report-submission"><h3>Reporter submission</h3><dl className="submission-meta">
|
|
146
|
+
<Fact label="Form">{report.formTitle || report.formVersionId || "Unknown form"}</Fact>
|
|
147
|
+
<Fact label="Channel">{readable(report.source || "unknown")}</Fact>
|
|
148
|
+
<Fact label="Submitted">{dateTime(report.submittedAt || report.receivedAt)}</Fact>
|
|
149
|
+
<Fact label="Language">{report.locale || "—"}</Fact>
|
|
150
|
+
</dl><div className="answer-list">
|
|
151
|
+
{answers.length ? answers.map((answer) => <article className="answer-record" key={answer.fieldKey}><div><strong>{answer.label || readable(answer.fieldKey)}</strong>{answer.required ? <small>Required question</small> : null}</div><AnswerValue answer={answer} /></article>) : <EmptyLine>This form did not contain additional questions.</EmptyLine>}
|
|
152
|
+
</div></section>
|
|
109
153
|
<section><h3>Allegation and target</h3><dl>
|
|
110
154
|
<Fact label="Allegation">{readable(report.reasonCode)}</Fact>
|
|
111
155
|
<Fact label="Target type">{readable(report.targetType)}</Fact>
|
|
@@ -125,22 +169,23 @@ function DetailContent({ report, actorId, generatedAt, actionCodes, claimReports
|
|
|
125
169
|
|
|
126
170
|
{workflowRuns.length || timeline.length ? <section><h3>Workflow & case timeline</h3><div className="report-run-list">{workflowRuns.map((run) => <article className="report-run-record" key={run.id}><div><strong>{run.workflowName || run.workflowKey || `Workflow ${String(run.workflowVersionId || run.id).slice(0, 12)}`}</strong><small>{readable(run.runKind || "primary")} · {readable(run.authorityMode || "assist")} · immutable version {run.workflowVersion || String(run.workflowVersionId || "").slice(0, 12)}</small></div><span className={`badge${["failed", "repair_required"].includes(run.state) ? " urgent" : ""}`}>{readable(run.state)}</span></article>)}</div><div className="report-timeline">{timeline.map((item, index) => <article className="timeline-event" key={item.id ?? `${item.eventType}-${item.createdAt}-${index}`}><span className={`timeline-marker ${item.eventType || "event"}`}>{timelineMarker(item.eventType)}</span><div><strong>{readable(item.eventType || "activity")}</strong><span>{timelineDetail(item)}</span><small>{relativeTime(item.createdAt)} · {readable(item.actorType || "system")}</small></div></article>)}</div></section> : null}
|
|
127
171
|
|
|
128
|
-
<section><h3>Case messages</h3><div className="message-list">{messages.length ? messages.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {
|
|
172
|
+
<section><h3>Case messages</h3><div className="message-list">{messages.length ? messages.map((item, index) => <article className={`case-message ${item.direction}`} key={item.id ?? `${item.createdAt}-${index}`}><div>{item.body}</div><small>{readable(item.senderType)} · {dateTime(item.createdAt)} · {deliveryLabel(item.deliveryState)}{deliveryExplanation(item.deliveryState) ? ` — ${deliveryExplanation(item.deliveryState)}` : ""}</small></article>) : <EmptyLine>No case messages yet.</EmptyLine>}</div></section>
|
|
129
173
|
|
|
130
174
|
{findings.length ? <section><h3>Component findings</h3><div className="finding-result-list">{findings.map((finding, index) => <article key={finding.id ?? `${finding.findingType}-${index}`}><div><strong>{finding.findingType}</strong><small>{finding.summary}{finding.componentVersionId ? ` · component ${finding.componentVersionId}` : ""}{finding.expiresAt ? ` · expires ${dateTime(finding.expiresAt)}` : ""}</small>{finding.data ? <pre>{JSON.stringify(finding.data, null, 2)}</pre> : null}</div><span className={`badge${finding.freshness !== "available" ? " urgent" : ""}`}>{readable(finding.freshness || finding.status)}</span></article>)}</div></section> : null}
|
|
131
175
|
|
|
132
176
|
{decisions.length ? <section><h3>Decisions & rationale</h3><div className="decision-record-list">{decisions.map((decision, index) => <article className="decision-record" key={decision.id ?? `${decision.createdAt}-${index}`}><div><strong>{readable(decision.decisionCode)}</strong><span className="badge">{decision.policyCode || "Policy decision"}</span></div><p>{decision.rationale || "No rationale recorded."}</p><small>{readable(decision.makerType)} · {dateTime(decision.createdAt)} · {(decision.evidenceReferences || []).length} evidence reference(s)</small></article>)}</div></section> : null}
|
|
133
177
|
|
|
134
|
-
{deliveries.length ? <section><h3>Decision and delivery</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}</small></div><span className={`badge${["failed", "rejected"].includes(delivery.state) ? " urgent" : ""}`}>{
|
|
178
|
+
{deliveries.length ? <section><h3>Decision and delivery</h3><div className="delivery-list">{deliveries.map((delivery) => <article key={delivery.id}><div><strong>{delivery.title}</strong><small>{delivery.detail}{deliveryExplanation(delivery.state) ? ` · ${deliveryExplanation(delivery.state)}` : ""}</small></div><span className={`badge${["failed", "rejected", "bounced", "suppressed"].includes(delivery.state) ? " urgent" : ""}`}>{deliveryLabel(delivery.state)}</span></article>)}</div></section> : null}
|
|
135
179
|
|
|
136
180
|
{aiRuns.length ? <section><h3>Bounded AI record</h3><div className="ai-run-list">{aiRuns.map((run, index) => <article key={run.id ?? `${run.model}-${index}`}><div><strong>{readable(run.mode)} · {readable(run.status)}</strong><small>{run.model} · {run.promptVersion} · {run.configVersionId}</small></div><p>{run.summary || readable(run.errorCode || "No validated result")}</p><small>{readable(run.outcome || "no outcome")} · {run.policyCode || "no policy"} · {run.estimatedCostMicrousd || 0} µUSD{run.uncertain ? " · escalated for uncertainty" : ""}</small></article>)}</div></section> : null}
|
|
137
181
|
|
|
138
182
|
<div className="claim-bar"><p>{activeClaim && report.claim ? <><AnalystAvatar analyst={report.claim.analyst ?? { displayName: "Another reviewer" }} size="small" />{mine ? "You hold" : `${report.claim.analyst?.displayName || "Another reviewer"} holds`} this report until {new Date(report.claim.expiresAt).toLocaleTimeString()}.</> : "This report must be claimed before a reviewer changes it."}</p><div>{mine ? <button className="secondary" type="button" disabled={changingClaim} onClick={dispatchReleaseReport}>{pendingAction === "release" ? "Releasing…" : "Release"}</button> : null}{claimable && !activeClaim ? <button className="primary" type="button" disabled={changingClaim} onClick={dispatchClaimReport}>{pendingAction === "claim" ? "Claiming…" : "Claim report"}</button> : null}</div></div>
|
|
139
183
|
|
|
140
184
|
<div className="detail-actions">
|
|
141
|
-
<form onSubmit={sendMessage}><h3>Ask the reporter</h3><label>Message<textarea maxLength={4000} required value={message} onChange={(event) => setMessage(event.currentTarget.value)} disabled={!canMessage || pendingAction === "message"} /></label><label className="checkbox"><input type="checkbox" checked={awaitReporter} onChange={(event) => setAwaitReporter(event.currentTarget.checked)} disabled={!canMessage || pendingAction === "message"} />Move to awaiting reporter</label><button className="secondary" type="submit" disabled={!canMessage || pendingAction === "message"}>{pendingAction === "message" ? "
|
|
185
|
+
<form onSubmit={sendMessage}><h3>Ask the reporter</h3><p className="form-help">The update is emailed when the reporter supplied an address. Otherwise, Resolve uses the configured signed customer notification channel.</p><label>Message<textarea maxLength={4000} required value={message} onChange={(event) => setMessage(event.currentTarget.value)} disabled={!canMessage || pendingAction === "message"} /></label><label className="checkbox"><input type="checkbox" checked={awaitReporter} onChange={(event) => setAwaitReporter(event.currentTarget.checked)} disabled={!canMessage || pendingAction === "message"} />Move to awaiting reporter</label><button className="secondary" type="submit" disabled={!canMessage || pendingAction === "message"}>{pendingAction === "message" ? "Queueing…" : "Send participant update"}</button></form>
|
|
142
186
|
<form onSubmit={recordDecision}><h3>Record a human decision</h3><label>Decision code<input value={decisionCode} onChange={(event) => setDecisionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Policy code<select value={policyCode} onChange={(event) => setPolicyCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required>{rules.map((rule) => <option value={rule.code} key={rule.code}>{rule.title ? `${rule.code} · ${rule.title}` : rule.code}</option>)}</select></label><label>Internal rationale<textarea maxLength={8000} value={rationale} onChange={(event) => setRationale(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Reporter outcome<textarea maxLength={4000} value={reporterNotice} onChange={(event) => setReporterNotice(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} required /></label><label>Affected-user notice <small>Optional; sent through a separate audience channel.</small><textarea maxLength={4000} value={affectedNotice} onChange={(event) => setAffectedNotice(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"} /></label>{actionCodes.length ? <label>Application action <small>Optional. Only deployment-allowlisted actions are shown.</small><select value={actionCode} onChange={(event) => setActionCode(event.currentTarget.value)} disabled={!canDecide || pendingAction === "decision"}><option value="">No application action</option>{actionCodes.map((code) => <option value={code} key={code}>{readable(code)}</option>)}</select></label> : null}<button className="primary" type="submit" disabled={!canDecide || pendingAction === "decision"}>{pendingAction === "decision" ? "Recording…" : "Complete human review"}</button></form>
|
|
143
187
|
</div>
|
|
188
|
+
<p className="mutation-status" role="status">{statusMessage}</p>
|
|
144
189
|
<p className="error" role="alert">{error}</p>
|
|
145
190
|
</>
|
|
146
191
|
);
|
|
@@ -153,7 +198,7 @@ export function ReportDrawer() {
|
|
|
153
198
|
return (
|
|
154
199
|
<dialog className="detail-dialog report-drawer" open={open} aria-labelledby="report-detail-title" onCancel={(event) => { event.preventDefault(); dispatchCloseReport(); }} data-react-slice="report-drawer">
|
|
155
200
|
<div className="detail-head"><div><h2 id="report-detail-title">{detail.report?.reference || "Report"}</h2><p>{detail.report ? `${readable(detail.report.state)} · ${readable(detail.report.queueId || "unassigned")}` : "Loading report…"}</p></div><button className="icon-button" type="button" aria-label="Close report detail" onClick={dispatchCloseReport}>×</button></div>
|
|
156
|
-
<div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent report={detail.report} actorId={snapshot.actorId} generatedAt={snapshot.generatedAt} actionCodes={snapshot.actionCodes} claimReports={snapshot.permissions.claimReports} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} error={detail.error} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
|
|
201
|
+
<div className="report-detail-content">{detail.loading && !detail.report ? <div className="skeleton-block" role="status" aria-label="Loading report detail" /> : detail.report ? <DetailContent report={detail.report} actorId={snapshot.actorId} generatedAt={snapshot.generatedAt} actionCodes={snapshot.actionCodes} claimReports={snapshot.permissions.claimReports} messageReporters={snapshot.permissions.messageReporters} decideReports={snapshot.permissions.decideReports} pendingAction={detail.pendingAction} messageMutationVersion={detail.messageMutationVersion} statusMessage={detail.statusMessage} error={detail.error} /> : detail.error ? <p className="error surface-error" role="alert">{detail.error}</p> : null}</div>
|
|
157
202
|
</dialog>
|
|
158
203
|
);
|
|
159
204
|
}
|
|
@@ -62,7 +62,7 @@ const initialSnapshot = (): ReportWorkspaceSnapshot => ({
|
|
|
62
62
|
presence: [],
|
|
63
63
|
actionCodes: [],
|
|
64
64
|
permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false },
|
|
65
|
-
detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion: 0, report: null },
|
|
65
|
+
detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
let actorId = "";
|
|
@@ -223,6 +223,7 @@ async function openReport(reportId: string): Promise<void> {
|
|
|
223
223
|
reportId,
|
|
224
224
|
loading: true,
|
|
225
225
|
error: "",
|
|
226
|
+
statusMessage: snapshot.detail.reportId === reportId ? snapshot.detail.statusMessage : "",
|
|
226
227
|
report: snapshot.detail.report?.id === reportId ? snapshot.detail.report : null,
|
|
227
228
|
},
|
|
228
229
|
});
|
|
@@ -262,7 +263,7 @@ function closeReport(): void {
|
|
|
262
263
|
detailRequestVersion += 1;
|
|
263
264
|
const messageMutationVersion = getReportWorkspaceSnapshot().detail.messageMutationVersion;
|
|
264
265
|
document.body.classList.remove("report-open");
|
|
265
|
-
publish({ detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion, report: null } });
|
|
266
|
+
publish({ detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion, report: null } });
|
|
266
267
|
sendPresenceActivity();
|
|
267
268
|
}
|
|
268
269
|
|
|
@@ -301,15 +302,20 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
|
|
|
301
302
|
const reportId = snapshot.detail.report?.id;
|
|
302
303
|
if (!reportId || !snapshot.permissions.messageReporters || snapshot.detail.pendingAction) return;
|
|
303
304
|
const idempotencyKey = crypto.randomUUID();
|
|
304
|
-
publish({ detail: { ...snapshot.detail, error: "", pendingAction: "message" } });
|
|
305
|
+
publish({ detail: { ...snapshot.detail, error: "", statusMessage: "", pendingAction: "message" } });
|
|
305
306
|
try {
|
|
306
|
-
await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/messages`, {
|
|
307
|
+
const response = await requestJson<{ delivery_state?: string; delivery_channel?: string | null }>(`/v1/admin/reports/${encodeURIComponent(reportId)}/messages`, {
|
|
307
308
|
method: "POST",
|
|
308
309
|
headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
|
|
309
310
|
body: JSON.stringify({ idempotency_key: idempotencyKey, body: input.body, await_reporter: input.awaitReporter }),
|
|
310
311
|
});
|
|
311
312
|
const version = getReportWorkspaceSnapshot().detail.messageMutationVersion + 1;
|
|
312
|
-
|
|
313
|
+
const statusMessage = response.delivery_state === "queued"
|
|
314
|
+
? response.delivery_channel === "email"
|
|
315
|
+
? "Participant update queued for email delivery."
|
|
316
|
+
: "Participant update queued for the configured customer notification channel."
|
|
317
|
+
: "The update was saved but not sent because this report has no available participant delivery channel.";
|
|
318
|
+
publish({ detail: { ...getReportWorkspaceSnapshot().detail, messageMutationVersion: version, statusMessage } });
|
|
313
319
|
await Promise.all([openReport(reportId), loadReports()]);
|
|
314
320
|
} catch (cause) {
|
|
315
321
|
publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The message could not be created.") } });
|
|
@@ -15,7 +15,7 @@ let snapshot: ReportWorkspaceSnapshot = {
|
|
|
15
15
|
presence: [],
|
|
16
16
|
actionCodes: [],
|
|
17
17
|
permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false },
|
|
18
|
-
detail: { reportId: null, loading: false, error: "", pendingAction: null, messageMutationVersion: 0, report: null },
|
|
18
|
+
detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
window.addEventListener(reportEvents.state, (event) => {
|
|
@@ -151,12 +151,33 @@ export interface ReportClaim {
|
|
|
151
151
|
analyst?: AnalystIdentityData;
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
export interface ReportAnswerOption {
|
|
155
|
+
value: string;
|
|
156
|
+
label: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ReportAnswer {
|
|
160
|
+
fieldKey: string;
|
|
161
|
+
label: string;
|
|
162
|
+
type: string;
|
|
163
|
+
required: boolean;
|
|
164
|
+
answered: boolean;
|
|
165
|
+
value: unknown;
|
|
166
|
+
options?: ReportAnswerOption[];
|
|
167
|
+
}
|
|
168
|
+
|
|
154
169
|
export interface ReportDetailRecord {
|
|
155
170
|
id: string;
|
|
156
171
|
reference: string;
|
|
157
172
|
state: string;
|
|
158
173
|
queueId?: string;
|
|
159
174
|
reasonCode: string;
|
|
175
|
+
source?: string;
|
|
176
|
+
locale?: string;
|
|
177
|
+
submittedAt?: string;
|
|
178
|
+
formVersionId?: string;
|
|
179
|
+
formTitle?: string;
|
|
180
|
+
consentNotice?: string;
|
|
160
181
|
targetType: string;
|
|
161
182
|
targetReference: string;
|
|
162
183
|
customerUrl?: string;
|
|
@@ -168,6 +189,7 @@ export interface ReportDetailRecord {
|
|
|
168
189
|
reviewerInstructions?: string;
|
|
169
190
|
receivedAt: string;
|
|
170
191
|
policyRules?: PolicyRule[];
|
|
192
|
+
answers?: ReportAnswer[];
|
|
171
193
|
evidence?: EvidenceReference[];
|
|
172
194
|
messages?: CaseMessage[];
|
|
173
195
|
workflowRuns?: WorkflowRunRecord[];
|
|
@@ -189,6 +211,7 @@ export interface ReportDetailSnapshot {
|
|
|
189
211
|
error: string;
|
|
190
212
|
pendingAction: ReportPendingAction;
|
|
191
213
|
messageMutationVersion: number;
|
|
214
|
+
statusMessage: string;
|
|
192
215
|
report: ReportDetailRecord | null;
|
|
193
216
|
}
|
|
194
217
|
|
|
@@ -94,10 +94,6 @@ function Navigation({ session, currentView, reportCount, open, onClose }: {
|
|
|
94
94
|
</Fragment>
|
|
95
95
|
))}
|
|
96
96
|
</nav>
|
|
97
|
-
<div className="principle">
|
|
98
|
-
<span className="principle-dot" aria-hidden="true" />
|
|
99
|
-
<div><strong>Customer-owned records</strong><p>Reports and decisions stay in this deployment.</p></div>
|
|
100
|
-
</div>
|
|
101
97
|
</aside>
|
|
102
98
|
<button className="nav-scrim" type="button" aria-label="Close navigation" hidden={!open} onClick={onClose} />
|
|
103
99
|
</>
|