create-safest-tools 0.4.0 → 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 +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +8 -6
- package/src/config.mjs +26 -5
- package/src/update.mjs +32 -2
- package/template/README.md +3 -3
- 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/package.json +2 -2
- package/template/public/console/auth-shell.js +4332 -4236
- package/template/public/styles.css +17 -2
- 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 +13 -4
- 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/README.md
CHANGED
|
@@ -18,7 +18,7 @@ npx create-safest-tools safest-resolve \
|
|
|
18
18
|
--email-from reports@example.com
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
The `--email-from` address must belong to a domain onboarded under Cloudflare Email Service → Email Sending.
|
|
21
|
+
The `--email-from` address is used only for reporter receipts, follow-ups, and outcomes, and must belong to a domain onboarded under Cloudflare Email Service → Email Sending. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address.
|
|
22
22
|
|
|
23
23
|
The command creates a local project and prints a read-only infrastructure plan. It does not change Cloudflare unless `--deploy` is supplied or the generated project’s `npm run setup` command is run and explicitly confirmed.
|
|
24
24
|
|
|
@@ -68,7 +68,7 @@ npm run restore:plan -- .safest/backups/<backup-directory> --target-config repor
|
|
|
68
68
|
npm run uninstall:plan
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
The updater hashes Safest-owned project files and refuses to overwrite local modifications. It preserves `reports.config.json`, owner-only secrets, backups, the
|
|
71
|
+
The updater hashes Safest-owned project files and refuses to overwrite local modifications. It preserves `reports.config.json`, owner-only secrets, backups, Cloudflare resource IDs, the Turnstile site key, customer webhook URLs, and the action allowlist; creates a local rollback snapshot; takes a verified D1/R2 backup; installs and validates the release; then applies forward-only migrations and deploys. `update . --plan` is read-only.
|
|
72
72
|
|
|
73
73
|
Backups contain the D1 export and every object in the four private R2 buckets, with stable inventories and SHA-256 verification. They intentionally exclude pending Queue messages, live Workflow engine state, Worker secrets, external provider state, and ephemeral presence. Restore verification is local; restore planning requires a separate target installation and never mutates Cloudflare.
|
|
74
74
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -29,7 +29,8 @@ Options:
|
|
|
29
29
|
--origin origin Allowed embedding/application origin; repeat as needed
|
|
30
30
|
--owner-email email Initial infrastructure owner bound to one-time setup
|
|
31
31
|
--admin-email email Deprecated alias for --owner-email
|
|
32
|
-
--email-from address
|
|
32
|
+
--email-from address Reporter-update sender on an onboarded Email Sending domain
|
|
33
|
+
--auth-email-from addr Account/invitation sender (default: accounts@same-domain)
|
|
33
34
|
--yes Do not prompt for omitted optional values
|
|
34
35
|
--skip-install Create files without running npm install or setup:plan
|
|
35
36
|
--deploy Run setup after scaffolding (still requires exact confirmation)
|
|
@@ -54,7 +55,7 @@ export function parseArguments(argv) {
|
|
|
54
55
|
const result = {
|
|
55
56
|
command: commandName,
|
|
56
57
|
directory: null, installationName: null, publicBaseUrl: null, allowedOrigins: [],
|
|
57
|
-
ownerEmail: null, emailFromAddress: null, yes: false, skipInstall: false,
|
|
58
|
+
ownerEmail: null, emailFromAddress: null, authEmailFromAddress: null, yes: false, skipInstall: false,
|
|
58
59
|
deploy: false, dryRun: false, plan: false, help: false, version: false,
|
|
59
60
|
};
|
|
60
61
|
for (let index = commandName === "update" ? 1 : 0; index < argv.length; index += 1) {
|
|
@@ -69,7 +70,7 @@ export function parseArguments(argv) {
|
|
|
69
70
|
else if (argument === "--plan") result.plan = true;
|
|
70
71
|
else if (argument === "--help" || argument === "-h") result.help = true;
|
|
71
72
|
else if (argument === "--version" || argument === "-v") result.version = true;
|
|
72
|
-
else if (["--name", "--public-url", "--origin", "--owner-email", "--admin-email", "--email-from"].includes(argument)) {
|
|
73
|
+
else if (["--name", "--public-url", "--origin", "--owner-email", "--admin-email", "--email-from", "--auth-email-from"].includes(argument)) {
|
|
73
74
|
const value = valueAfter(argv, index, argument);
|
|
74
75
|
index += 1;
|
|
75
76
|
if (argument === "--name") result.installationName = value;
|
|
@@ -79,13 +80,14 @@ export function parseArguments(argv) {
|
|
|
79
80
|
if (result.ownerEmail && result.ownerEmail !== value) throw new Error("provide only one infrastructure owner email");
|
|
80
81
|
result.ownerEmail = value;
|
|
81
82
|
}
|
|
82
|
-
else result.emailFromAddress = value;
|
|
83
|
+
else if (argument === "--email-from") result.emailFromAddress = value;
|
|
84
|
+
else result.authEmailFromAddress = value;
|
|
83
85
|
} else throw new Error(`unknown option: ${argument}`);
|
|
84
86
|
}
|
|
85
87
|
if (result.deploy && result.skipInstall) throw new Error("--deploy cannot be used with --skip-install");
|
|
86
88
|
if (result.command === "create" && result.plan) throw new Error("--plan is available only with the update command");
|
|
87
89
|
if (result.command === "update" && (result.installationName || result.publicBaseUrl || result.allowedOrigins.length
|
|
88
|
-
|| result.ownerEmail || result.emailFromAddress || result.skipInstall || result.deploy || result.dryRun)) {
|
|
90
|
+
|| result.ownerEmail || result.emailFromAddress || result.authEmailFromAddress || result.skipInstall || result.deploy || result.dryRun)) {
|
|
89
91
|
throw new Error("update accepts only a project directory, --plan, and --yes");
|
|
90
92
|
}
|
|
91
93
|
return result;
|
|
@@ -109,7 +111,7 @@ async function completeInteractive(options, input) {
|
|
|
109
111
|
options.publicBaseUrl ||= await ask(input, "Public reports origin", "https://reports.example.com");
|
|
110
112
|
if (!options.allowedOrigins.length) options.allowedOrigins.push(await ask(input, "Application origin allowed to embed the report form", "https://app.example.com"));
|
|
111
113
|
options.ownerEmail ||= await ask(input, "Infrastructure owner email");
|
|
112
|
-
options.emailFromAddress ||= await ask(input, "
|
|
114
|
+
options.emailFromAddress ||= await ask(input, "Reporter-update sender on a Cloudflare Email Sending domain");
|
|
113
115
|
return options;
|
|
114
116
|
}
|
|
115
117
|
|
package/src/config.mjs
CHANGED
|
@@ -23,6 +23,20 @@ function optionalEmail(value, field) {
|
|
|
23
23
|
return result;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function defaultAuthEmail(reporterEmail) {
|
|
27
|
+
const at = reporterEmail.lastIndexOf("@");
|
|
28
|
+
if (at < 1) return "";
|
|
29
|
+
const domain = reporterEmail.slice(at + 1);
|
|
30
|
+
return `${reporterEmail.slice(0, at).toLowerCase() === "accounts" ? "auth" : "accounts"}@${domain}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emailAddresses(config) {
|
|
34
|
+
const reporter = optionalEmail(config.email?.fromAddress, "email.fromAddress");
|
|
35
|
+
const auth = optionalEmail(config.email?.authFromAddress, "email.authFromAddress") || defaultAuthEmail(reporter);
|
|
36
|
+
if (reporter && auth === reporter) throw new Error("authentication and reporter email senders must be different addresses");
|
|
37
|
+
return { reporter, auth };
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
function resource(base, suffix) {
|
|
27
41
|
const prefix = base.slice(0, 63 - suffix.length - 1).replace(/-+$/u, "");
|
|
28
42
|
return `${prefix}-${suffix}`;
|
|
@@ -37,6 +51,9 @@ export function buildConfiguration(options) {
|
|
|
37
51
|
if (!ownerEmail) throw new Error("--owner-email is required");
|
|
38
52
|
const emailFromAddress = optionalEmail(options.emailFromAddress, "email sender");
|
|
39
53
|
if (!emailFromAddress) throw new Error("--email-from is required and its domain must be onboarded to Cloudflare Email Sending");
|
|
54
|
+
const authEmailFromAddress = optionalEmail(options.authEmailFromAddress, "authentication email sender")
|
|
55
|
+
|| defaultAuthEmail(emailFromAddress);
|
|
56
|
+
if (authEmailFromAddress === emailFromAddress) throw new Error("--auth-email-from must differ from --email-from");
|
|
40
57
|
const resources = {
|
|
41
58
|
workerName: resource(installationId, "worker"),
|
|
42
59
|
databaseName: resource(installationId, "db"),
|
|
@@ -61,7 +78,7 @@ export function buildConfiguration(options) {
|
|
|
61
78
|
publicBaseUrl,
|
|
62
79
|
allowedOrigins,
|
|
63
80
|
owner: { email: ownerEmail },
|
|
64
|
-
email: { enabled: true, fromAddress: emailFromAddress },
|
|
81
|
+
email: { enabled: true, fromAddress: emailFromAddress, authFromAddress: authEmailFromAddress },
|
|
65
82
|
auth: { password: true, providers: [] },
|
|
66
83
|
retention: { reportsDays: 365, messagesDays: 180, appealWindowDays: 30 },
|
|
67
84
|
};
|
|
@@ -70,6 +87,7 @@ export function buildConfiguration(options) {
|
|
|
70
87
|
export function buildWranglerConfiguration(config) {
|
|
71
88
|
const publicHostname = new URL(config.publicBaseUrl).hostname.toLowerCase();
|
|
72
89
|
const usesWorkersDev = publicHostname.endsWith(".workers.dev");
|
|
90
|
+
const email = emailAddresses(config);
|
|
73
91
|
const wrangler = {
|
|
74
92
|
$schema: "node_modules/wrangler/config-schema.json",
|
|
75
93
|
name: config.resources.workerName,
|
|
@@ -126,15 +144,18 @@ export function buildWranglerConfiguration(config) {
|
|
|
126
144
|
ACTION_WEBHOOK_URL: "",
|
|
127
145
|
NOTIFICATION_WEBHOOK_URL: "",
|
|
128
146
|
OPERATOR_NOTIFICATION_WEBHOOK_URL: "",
|
|
129
|
-
AUTH_EMAIL_FROM:
|
|
130
|
-
|
|
131
|
-
|
|
147
|
+
AUTH_EMAIL_FROM: email.auth,
|
|
148
|
+
REPORTER_EMAIL_FROM: email.reporter,
|
|
149
|
+
REPORTER_EMAIL_SUBJECT_PREFIX: "Safest report update",
|
|
132
150
|
},
|
|
133
151
|
triggers: { crons: ["*/5 * * * *"] },
|
|
134
152
|
};
|
|
135
153
|
if (!usesWorkersDev) {
|
|
136
154
|
wrangler.routes = [{ pattern: publicHostname, custom_domain: true }];
|
|
137
155
|
}
|
|
138
|
-
wrangler.send_email = [
|
|
156
|
+
wrangler.send_email = [
|
|
157
|
+
{ name: "AUTH_EMAIL", allowed_sender_addresses: [email.auth] },
|
|
158
|
+
{ name: "REPORTER_EMAIL", allowed_sender_addresses: [email.reporter] },
|
|
159
|
+
];
|
|
139
160
|
return wrangler;
|
|
140
161
|
}
|
package/src/update.mjs
CHANGED
|
@@ -7,6 +7,13 @@ import { buildWranglerConfiguration } from "./config.mjs";
|
|
|
7
7
|
const MANIFEST_PATH = ".safest-managed.json";
|
|
8
8
|
const MANIFEST_SCHEMA_VERSION = 1;
|
|
9
9
|
const REBUILDABLE_PATHS = new Set(["public/console/auth-shell.js"]);
|
|
10
|
+
const CUSTOMER_RUNTIME_VAR_KEYS = new Set([
|
|
11
|
+
"ALLOWED_ACTION_CODES",
|
|
12
|
+
"TURNSTILE_SITE_KEY",
|
|
13
|
+
"ACTION_WEBHOOK_URL",
|
|
14
|
+
"NOTIFICATION_WEBHOOK_URL",
|
|
15
|
+
"OPERATOR_NOTIFICATION_WEBHOOK_URL",
|
|
16
|
+
]);
|
|
10
17
|
|
|
11
18
|
function stableValue(value) {
|
|
12
19
|
if (Array.isArray(value)) return value.map(stableValue);
|
|
@@ -58,6 +65,7 @@ function normalizedWranglerConfiguration(configuration) {
|
|
|
58
65
|
delete database.database_id;
|
|
59
66
|
delete database.preview_database_id;
|
|
60
67
|
}
|
|
68
|
+
for (const key of CUSTOMER_RUNTIME_VAR_KEYS) delete normalized.vars?.[key];
|
|
61
69
|
return normalized;
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -65,6 +73,25 @@ function wranglerHash(configuration) {
|
|
|
65
73
|
return sha256(stableJson(normalizedWranglerConfiguration(configuration)));
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
function legacyWranglerHash(configuration) {
|
|
77
|
+
const normalized = structuredClone(configuration);
|
|
78
|
+
delete normalized.account_id;
|
|
79
|
+
for (const database of normalized.d1_databases ?? []) {
|
|
80
|
+
delete database.database_id;
|
|
81
|
+
delete database.preview_database_id;
|
|
82
|
+
}
|
|
83
|
+
return sha256(stableJson(normalized));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function wranglerMatchesManifest(configuration, expectedHash) {
|
|
87
|
+
if (wranglerHash(configuration) === expectedHash || legacyWranglerHash(configuration) === expectedHash) return true;
|
|
88
|
+
const preUpdateDefaults = structuredClone(configuration);
|
|
89
|
+
for (const key of CUSTOMER_RUNTIME_VAR_KEYS) {
|
|
90
|
+
if (key in (preUpdateDefaults.vars ?? {})) preUpdateDefaults.vars[key] = "";
|
|
91
|
+
}
|
|
92
|
+
return legacyWranglerHash(preUpdateDefaults) === expectedHash;
|
|
93
|
+
}
|
|
94
|
+
|
|
68
95
|
function mergeWranglerRuntimeState(desired, current) {
|
|
69
96
|
const result = structuredClone(desired);
|
|
70
97
|
if (typeof current.account_id === "string" && current.account_id) result.account_id = current.account_id;
|
|
@@ -75,6 +102,9 @@ function mergeWranglerRuntimeState(desired, current) {
|
|
|
75
102
|
if (typeof installed[field] === "string" && installed[field]) database[field] = installed[field];
|
|
76
103
|
}
|
|
77
104
|
}
|
|
105
|
+
for (const key of CUSTOMER_RUNTIME_VAR_KEYS) {
|
|
106
|
+
if (typeof current.vars?.[key] === "string") result.vars[key] = current.vars[key];
|
|
107
|
+
}
|
|
78
108
|
return result;
|
|
79
109
|
}
|
|
80
110
|
|
|
@@ -172,7 +202,7 @@ export async function planProjectUpdate({ directory, templateDirectory }) {
|
|
|
172
202
|
if (currentHash !== null && currentHash !== targetHash) conflicts.push(path);
|
|
173
203
|
added.push(path);
|
|
174
204
|
}
|
|
175
|
-
if (
|
|
205
|
+
if (!wranglerMatchesManifest(currentWrangler, manifest.wranglerHash)) conflicts.push("wrangler.jsonc");
|
|
176
206
|
|
|
177
207
|
const desiredWrangler = mergeWranglerRuntimeState(buildWranglerConfiguration(reportsConfiguration), currentWrangler);
|
|
178
208
|
const deployed = (currentWrangler.d1_databases ?? []).some(({ database_id: id }) => typeof id === "string" && id.length > 0);
|
|
@@ -186,7 +216,7 @@ export async function planProjectUpdate({ directory, templateDirectory }) {
|
|
|
186
216
|
ready: uniqueConflicts.length === 0,
|
|
187
217
|
changes: { added: added.sort(), updated: updated.sort(), removed: removed.sort() },
|
|
188
218
|
conflicts: uniqueConflicts,
|
|
189
|
-
preserves: ["reports.config.json", ".safest/secrets.env", ".safest/backups/", "Cloudflare account_id", "D1 database_id"],
|
|
219
|
+
preserves: ["reports.config.json", ".safest/secrets.env", ".safest/backups/", "Cloudflare account_id", "D1 database_id", "Turnstile site key", "customer webhook URLs and action allowlist"],
|
|
190
220
|
manifest,
|
|
191
221
|
targetFiles,
|
|
192
222
|
desiredWrangler,
|
package/template/README.md
CHANGED
|
@@ -9,7 +9,7 @@ This project deploys one Worker, one D1 database, four private R2 buckets, a Dyn
|
|
|
9
9
|
Before deployment:
|
|
10
10
|
|
|
11
11
|
1. Review `reports.config.json` and `npm run setup:plan`.
|
|
12
|
-
2. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain.
|
|
12
|
+
2. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain. Reporter updates and account mail use separate sender addresses and sender-restricted bindings.
|
|
13
13
|
3. Run `npm run setup`. The guided setup signs in through Wrangler and verifies Workers Paid from the account's Workers usage model before provisioning. Standard accounts need no separate billing token; only legacy or ambiguous models use the temporary Billing Read fallback. Setup then creates owner-only local secrets and walks through optional Google, GitHub, and Cloudflare OAuth credentials with exact callback URLs.
|
|
14
14
|
4. Type the exact installation confirmation when setup requests it. After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes and is never written to disk.
|
|
15
15
|
5. Open the owner link, create the Safest owner account, then invite administrators and analysts from People. Nobody needs a Cloudflare account to sign in. Names and workspace roles are always displayed separately.
|
|
@@ -20,7 +20,7 @@ If the setup link expires, run `npm run owner:setup`. If the owner loses access
|
|
|
20
20
|
|
|
21
21
|
Administrators build and preview questions in **Configuration → Intake forms**, set the logo, colours, fonts, and shape in **Settings → Branding**, then choose a private hosted page, signed-in product widget, or anonymous product widget in **Settings → Reporting channels**. Hosted pages and anonymous widgets need no API key. Signed-in widgets use one small customer-backend endpoint; verified reporter, target, and registered trusted facts stay server-side while the browser receives only a short-lived opaque token. Every public submission is verified with Turnstile inside a Resolve-owned isolated frame.
|
|
22
22
|
|
|
23
|
-
Cloudflare Email Service and an `--email-from reports@example.com` address on an onboarded Email Sending domain are required for
|
|
23
|
+
Cloudflare Email Service and an `--email-from reports@example.com` address on an onboarded Email Sending domain are required for reporter receipts, follow-ups, and outcomes. Invitations, email verification, and password recovery use a separate `accounts@` sender by default; `--auth-email-from` overrides it. A configured signed customer notification webhook remains the fallback for reference-only participants.
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
26
|
npm run backup:plan
|
|
@@ -31,7 +31,7 @@ npx create-safest-tools@latest update . --plan
|
|
|
31
31
|
npx create-safest-tools@latest update .
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
The updater preserves customer configuration, secrets, backups,
|
|
34
|
+
The updater preserves customer configuration, secrets, backups, Cloudflare resource IDs, Turnstile configuration, customer webhook URLs, and the action allowlist. It blocks on locally modified Safest-owned files, creates a local rollback snapshot, takes a verified D1/R2 backup, validates the new release, then applies forward-only migrations and deploys. `update . --plan` is read-only.
|
|
35
35
|
|
|
36
36
|
`npm run uninstall:plan` is deliberately read-only. It lists only the resources named by this installation and preserves D1 and all private R2 buckets by default.
|
|
37
37
|
|
|
@@ -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
|
|
package/template/package.json
CHANGED