create-safest-tools 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/template/README.md +1 -1
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +12 -6
- package/template/console/command/controller.ts +13 -9
- package/template/console/command/presentation.ts +1 -6
- package/template/console/command/store.ts +1 -0
- package/template/console/command/types.ts +1 -0
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
- package/template/console/registry/RegistryDialog.tsx +34 -4
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +109 -67
- package/template/console/reports/ReportWorkspace.tsx +11 -12
- package/template/console/reports/controller.ts +96 -49
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +41 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +22 -8
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +25 -17
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +18701 -11800
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +329 -10
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +3 -2
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/database-readiness.ts +8 -3
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +178 -18
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-delivery.ts +136 -129
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-operations.ts +16 -63
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +181 -274
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +18 -7
- package/template/src/workflow-effects.ts +95 -18
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +9 -4
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { canonicalJson } from "./audit";
|
|
2
2
|
import type { DeliveryJob } from "./report-types";
|
|
3
|
-
import { decryptReportContact } from "./report-crypto";
|
|
3
|
+
import { decryptReportContact, sha256 } from "./report-crypto";
|
|
4
4
|
import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
|
|
5
5
|
import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix, reporterReplyAddress } from "./report-email";
|
|
6
6
|
import { renderReporterEmail } from "./report-email-template";
|
|
7
|
-
import { readBoundedBytes } from "./report-http";
|
|
7
|
+
import { ApiError, readBoundedBytes } from "./report-http";
|
|
8
8
|
import { loadWorkspaceBrand } from "./workspace-branding";
|
|
9
|
+
import { signalNoticeDelivery } from "./notice-delivery";
|
|
10
|
+
import { issueParticipantAccessLink, lookupParticipantAccessLink } from "./participant-access";
|
|
9
11
|
|
|
10
12
|
const MAX_ATTEMPTS = 8;
|
|
11
13
|
const RESPONSE_LIMIT = 16 * 1024;
|
|
@@ -29,6 +31,8 @@ interface NotificationOutboxRow {
|
|
|
29
31
|
payload_json: string;
|
|
30
32
|
status: string;
|
|
31
33
|
attempt_count: number;
|
|
34
|
+
attempt_limit: number;
|
|
35
|
+
message_id: string | null;
|
|
32
36
|
audience: "reporter" | "affected_user";
|
|
33
37
|
recipient_reference: string | null;
|
|
34
38
|
contact_ciphertext: string | null;
|
|
@@ -41,6 +45,9 @@ interface EmailDeliveryRow {
|
|
|
41
45
|
audience: "reporter" | "affected_user";
|
|
42
46
|
state: string;
|
|
43
47
|
attemptCount: number;
|
|
48
|
+
attemptLimit: number;
|
|
49
|
+
claimedAt: string;
|
|
50
|
+
recipientHash: string;
|
|
44
51
|
recipientReference: string | null;
|
|
45
52
|
contactCiphertext: string | null;
|
|
46
53
|
body: string;
|
|
@@ -58,7 +65,7 @@ export class RetryableDeliveryError extends Error {
|
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
export class DeliveryConfigurationError extends Error {
|
|
61
|
-
constructor(message: string) {
|
|
68
|
+
constructor(message: string, readonly code?: string) {
|
|
62
69
|
super(message);
|
|
63
70
|
this.name = "DeliveryConfigurationError";
|
|
64
71
|
}
|
|
@@ -186,16 +193,16 @@ async function claimNotification(db: D1Database, id: string): Promise<Notificati
|
|
|
186
193
|
UPDATE notification_outbox
|
|
187
194
|
SET status = 'delivering', attempt_count = attempt_count + 1,
|
|
188
195
|
lease_expires_at = ?2, updated_at = ?1
|
|
189
|
-
WHERE id = ?3 AND terminal = 0 AND attempt_count <
|
|
196
|
+
WHERE id = ?3 AND terminal = 0 AND attempt_count < attempt_limit
|
|
190
197
|
AND (
|
|
191
198
|
status = 'pending'
|
|
192
199
|
OR (status = 'failed' AND next_attempt_at <= ?1)
|
|
193
200
|
OR (status = 'delivering' AND lease_expires_at <= ?1)
|
|
194
201
|
)
|
|
195
|
-
`).bind(claimedAt, plusSeconds(claimedAt, 60), id
|
|
202
|
+
`).bind(claimedAt, plusSeconds(claimedAt, 60), id).run();
|
|
196
203
|
if ((result.meta.changes ?? 0) < 1) return null;
|
|
197
204
|
return db.prepare(`
|
|
198
|
-
SELECT o.id, n.report_id, o.notice_id, o.idempotency_key, o.payload_json, o.status, o.attempt_count,
|
|
205
|
+
SELECT o.id, n.report_id, o.notice_id, o.idempotency_key, o.payload_json, o.status, o.attempt_count, o.attempt_limit, n.message_id,
|
|
199
206
|
n.audience, p.reference AS recipient_reference, p.contact_ciphertext
|
|
200
207
|
FROM notification_outbox o JOIN notices n ON n.id = o.notice_id
|
|
201
208
|
LEFT JOIN report_participants p ON p.report_id = n.report_id AND p.audience = n.audience
|
|
@@ -208,14 +215,14 @@ async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow
|
|
|
208
215
|
const staleAt = plusSeconds(claimedAt, -60);
|
|
209
216
|
const result = await db.prepare(`
|
|
210
217
|
UPDATE email_deliveries SET state = 'sending', attempt_count = attempt_count + 1,
|
|
211
|
-
updated_at = ?2 WHERE id = ?1 AND attempt_count <
|
|
218
|
+
updated_at = ?2 WHERE id = ?1 AND attempt_count < attempt_limit
|
|
212
219
|
AND (state = 'pending' OR (state = 'failed' AND next_attempt_at <= ?2)
|
|
213
|
-
OR (state = 'sending' AND updated_at <= ?
|
|
214
|
-
`).bind(id, claimedAt,
|
|
220
|
+
OR (state = 'sending' AND updated_at <= ?3))
|
|
221
|
+
`).bind(id, claimedAt, staleAt).run();
|
|
215
222
|
if ((result.meta.changes ?? 0) < 1) return null;
|
|
216
223
|
return db.prepare(`
|
|
217
224
|
SELECT e.id, m.report_id AS reportId, e.message_id AS messageId,
|
|
218
|
-
e.audience, e.state, e.attempt_count AS attemptCount,
|
|
225
|
+
e.audience, e.state, e.attempt_count AS attemptCount, e.attempt_limit AS attemptLimit, e.updated_at AS claimedAt, e.recipient_hash AS recipientHash,
|
|
219
226
|
p.reference AS recipientReference, p.contact_ciphertext AS contactCiphertext,
|
|
220
227
|
m.body, m.template_version_id AS templateVersionId, r.public_reference AS reportReference,
|
|
221
228
|
r.installation_id AS installationId, r.queue_id AS queueId
|
|
@@ -230,14 +237,17 @@ async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow
|
|
|
230
237
|
function deliveryAudit(
|
|
231
238
|
db: D1Database,
|
|
232
239
|
input: { key: string; action: string; targetType: string; targetId: string; reportId: string; details: unknown; createdAt: string },
|
|
240
|
+
receipt?: { table: "notification_attempts" | "email_delivery_attempts"; id: string },
|
|
233
241
|
): D1PreparedStatement {
|
|
234
242
|
return db.prepare(`
|
|
235
243
|
INSERT OR IGNORE INTO audit_events (
|
|
236
244
|
id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|
|
237
|
-
)
|
|
245
|
+
) SELECT ?1, ?2, ?3, 'system', 'delivery-worker', ?4, ?5, ?6, ?7
|
|
246
|
+
${receipt ? `WHERE EXISTS (SELECT 1 FROM ${receipt.table} WHERE id = ?8)` : ""}
|
|
238
247
|
`).bind(
|
|
239
248
|
crypto.randomUUID(), input.key, input.action, input.targetType, input.targetId,
|
|
240
249
|
canonicalJson({ reportId: input.reportId, ...input.details as Record<string, unknown> }), input.createdAt,
|
|
250
|
+
...(receipt ? [receipt.id] : []),
|
|
241
251
|
);
|
|
242
252
|
}
|
|
243
253
|
|
|
@@ -331,14 +341,14 @@ async function processAction(db: D1Database, outboxId: string, env: Env): Promis
|
|
|
331
341
|
if (!terminal) throw new RetryableDeliveryError(retryDelay);
|
|
332
342
|
}
|
|
333
343
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
344
|
+
function localNotificationTransport(env: Env): typeof fetch | undefined {
|
|
345
|
+
// Only the disposable loopback demo can use its local customer service binding.
|
|
346
|
+
if (env.AI_LOCAL_FIXTURE_ENABLED !== "true" || env.NOTIFICATION_WEBHOOK_URL !== "https://safest-notice-harness.invalid/api/safest/notices") return;
|
|
347
|
+
try {if (!["localhost", "127.0.0.1"].includes(new URL(env.PUBLIC_BASE_URL).hostname)) return;} catch {return;}
|
|
348
|
+
const candidate: unknown = Reflect.get(env, "NOTICE_CUSTOMER");
|
|
349
|
+
if (!candidate || typeof candidate !== "object" || !("fetch" in candidate) || typeof candidate.fetch !== "function") return;
|
|
350
|
+
const binding = candidate as Fetcher;
|
|
351
|
+
return (input, init) => binding.fetch(input, init);
|
|
342
352
|
}
|
|
343
353
|
|
|
344
354
|
async function processNotification(db: D1Database, outboxId: string, env: Env): Promise<void> {
|
|
@@ -355,13 +365,29 @@ async function processNotification(db: D1Database, outboxId: string, env: Env):
|
|
|
355
365
|
} catch {
|
|
356
366
|
payload = {};
|
|
357
367
|
}
|
|
368
|
+
if ("recipient_contact_fingerprint" in payload) {
|
|
369
|
+
const fingerprint = row.contact_ciphertext ? await sha256(row.contact_ciphertext) : null;
|
|
370
|
+
if (payload.recipient_contact_fingerprint !== fingerprint || (payload.recipient_reference ?? null) !== row.recipient_reference) throw new DeliveryConfigurationError("The participant identity changed after this notice was prepared.", "notice_recipient_changed");
|
|
371
|
+
delete payload.recipient_contact_fingerprint;
|
|
372
|
+
}
|
|
358
373
|
const recipientEmail = row.contact_ciphertext
|
|
359
374
|
? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contact_ciphertext, row.report_id, row.audience)).trim().toLowerCase()
|
|
360
375
|
: null;
|
|
376
|
+
const accessInput = {
|
|
377
|
+
reportId: row.report_id, audience: row.audience, deliveryKey: `notice:${row.id}`,
|
|
378
|
+
expectedIdentity: { reference: row.recipient_reference, contactCiphertext: row.contact_ciphertext },
|
|
379
|
+
};
|
|
380
|
+
// Keep webhook retries byte-equivalent even for a notice first sent by an
|
|
381
|
+
// older build without a participant link. Existing grants reproduce the
|
|
382
|
+
// original link; an upgrade must never add a new credential to an old retry.
|
|
383
|
+
const participantUrl = row.attempt_count === 1
|
|
384
|
+
? await issueParticipantAccessLink(env, accessInput)
|
|
385
|
+
: await lookupParticipantAccessLink(env, accessInput);
|
|
361
386
|
const deliveryPayload = canonicalJson({
|
|
362
387
|
...payload,
|
|
363
388
|
...(row.recipient_reference ? { recipient_reference: row.recipient_reference } : {}),
|
|
364
389
|
...(recipientEmail ? { recipient_email: recipientEmail } : {}),
|
|
390
|
+
...(participantUrl ? { participant_url: participantUrl } : {}),
|
|
365
391
|
});
|
|
366
392
|
const response = await sendWebhook({
|
|
367
393
|
url: env.NOTIFICATION_WEBHOOK_URL,
|
|
@@ -370,70 +396,81 @@ async function processNotification(db: D1Database, outboxId: string, env: Env):
|
|
|
370
396
|
event: "participant.notification.requested",
|
|
371
397
|
idempotencyKey: row.idempotency_key,
|
|
372
398
|
payload: deliveryPayload,
|
|
373
|
-
});
|
|
399
|
+
}, localNotificationTransport(env));
|
|
374
400
|
status = response.status;
|
|
375
401
|
} catch (error) {
|
|
376
402
|
errorCode = error instanceof DeliveryConfigurationError
|
|
377
|
-
? "webhook_configuration_error"
|
|
403
|
+
? error.code ?? "webhook_configuration_error"
|
|
404
|
+
: error instanceof ApiError && error.code === "participant_access_unavailable" ? "notice_recipient_changed"
|
|
378
405
|
: error instanceof DOMException && error.name === "AbortError" ? "webhook_timeout" : "webhook_network_error";
|
|
379
406
|
}
|
|
380
407
|
const finishedAt = now();
|
|
381
408
|
const attemptId = crypto.randomUUID();
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
UPDATE notification_outbox SET status = 'delivered', terminal = 1,
|
|
386
|
-
lease_expires_at = NULL, last_error_code = NULL, updated_at = ?2 WHERE id = ?1
|
|
387
|
-
`).bind(row.id, finishedAt),
|
|
388
|
-
db.prepare(`
|
|
389
|
-
INSERT INTO notification_attempts (
|
|
390
|
-
id, notification_id, attempt_number, response_class, response_status, started_at, finished_at
|
|
391
|
-
) VALUES (?1, ?2, ?3, 'delivered', ?4, ?5, ?6)
|
|
392
|
-
`).bind(attemptId, row.id, row.attempt_count, status, startedAt, finishedAt),
|
|
393
|
-
deliveryAudit(db, {
|
|
394
|
-
key: `notification-delivery:${row.id}:delivered`,
|
|
395
|
-
action: "notification.delivered",
|
|
396
|
-
targetType: "notification",
|
|
397
|
-
targetId: row.id,
|
|
398
|
-
reportId: row.report_id,
|
|
399
|
-
details: { responseStatus: status, attemptCount: row.attempt_count },
|
|
400
|
-
createdAt: finishedAt,
|
|
401
|
-
}),
|
|
402
|
-
]);
|
|
403
|
-
await updateNoticeDelivery(db, row.notice_id, "delivered", finishedAt);
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
if (status !== null) errorCode = `webhook_http_${status}`;
|
|
407
|
-
const temporary = errorCode !== "webhook_configuration_error"
|
|
409
|
+
const delivered = status !== null && responseClass(status) === "success";
|
|
410
|
+
if (status !== null && !delivered) errorCode = `webhook_http_${status}`;
|
|
411
|
+
const temporary = errorCode !== "webhook_configuration_error" && errorCode !== "notice_recipient_changed"
|
|
408
412
|
&& (status === null || responseClass(status) === "temporary_failure");
|
|
409
|
-
const terminal = !temporary || row.attempt_count >=
|
|
413
|
+
const terminal = delivered || !temporary || row.attempt_count >= row.attempt_limit;
|
|
410
414
|
const retryDelay = delay(row.attempt_count);
|
|
411
415
|
const statements = [
|
|
412
|
-
db.prepare(`
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
id, notification_id, attempt_number, response_class, response_status, error_code, started_at, finished_at
|
|
420
|
-
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
421
|
-
`).bind(attemptId, row.id, row.attempt_count, terminal ? "permanent_failure" : "temporary_failure", status, errorCode, startedAt, finishedAt),
|
|
416
|
+
db.prepare(`UPDATE notification_outbox SET status = ?2, terminal = ?3,
|
|
417
|
+
next_attempt_at = ?4, lease_expires_at = NULL, last_error_code = ?5, updated_at = ?6
|
|
418
|
+
WHERE id = ?1 AND status = 'delivering' AND attempt_count = ?7 AND terminal = 0`)
|
|
419
|
+
.bind(row.id, delivered ? "delivered" : "failed", terminal ? 1 : 0, plusSeconds(finishedAt, retryDelay), delivered ? null : errorCode, finishedAt, row.attempt_count),
|
|
420
|
+
db.prepare(`INSERT INTO notification_attempts (id, notification_id, attempt_number, response_class, response_status, error_code, started_at, finished_at)
|
|
421
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE changes() = 1`)
|
|
422
|
+
.bind(attemptId, row.id, row.attempt_count, delivered ? "delivered" : terminal ? "permanent_failure" : "temporary_failure", status, delivered ? null : errorCode, startedAt, finishedAt),
|
|
422
423
|
];
|
|
423
|
-
if (terminal) statements.push(
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
424
|
+
if (terminal) statements.push(
|
|
425
|
+
db.prepare(`UPDATE notices SET delivery_state = ?2, updated_at = ?3 WHERE id = ?1
|
|
426
|
+
AND EXISTS (SELECT 1 FROM notification_attempts WHERE id = ?4)`)
|
|
427
|
+
.bind(row.notice_id, delivered ? "delivered" : "failed", finishedAt, attemptId),
|
|
428
|
+
db.prepare(`UPDATE case_messages SET delivery_state = ?2 WHERE id = (SELECT message_id FROM notices WHERE id = ?1)
|
|
429
|
+
AND EXISTS (SELECT 1 FROM notification_attempts WHERE id = ?3)`)
|
|
430
|
+
.bind(row.notice_id, delivered ? "delivered" : "failed", attemptId),
|
|
431
|
+
deliveryAudit(db, {
|
|
432
|
+
key: `notification-delivery:${row.id}:${row.attempt_count}:${delivered ? "delivered" : "failed"}`,
|
|
433
|
+
action: delivered ? "notification.delivered" : "notification.delivery_failed", targetType: "notification", targetId: row.id,
|
|
434
|
+
reportId: row.report_id, details: { responseStatus: status, attemptCount: row.attempt_count, errorCode: delivered ? null : errorCode }, createdAt: finishedAt,
|
|
435
|
+
}, {table: "notification_attempts", id: attemptId}),
|
|
436
|
+
);
|
|
437
|
+
const written = await db.batch(statements);
|
|
438
|
+
if (written[0]?.meta.changes !== 1) return;
|
|
439
|
+
if (terminal && row.message_id) await signalNoticeDelivery(env, row.message_id, `notification-${row.attempt_count}`);
|
|
434
440
|
if (!terminal) throw new RetryableDeliveryError(retryDelay);
|
|
435
441
|
}
|
|
436
442
|
|
|
443
|
+
async function commitEmailResult(db: D1Database, env: Env, row: EmailDeliveryRow, input: {
|
|
444
|
+
state: "delivered" | "failed" | "suppressed"; terminal: boolean; nextAttemptAt: string | null;
|
|
445
|
+
errorCode: string | null; providerMessageId?: string | null;
|
|
446
|
+
}): Promise<boolean> {
|
|
447
|
+
const timestamp = now(), attemptId = crypto.randomUUID();
|
|
448
|
+
const statements = [
|
|
449
|
+
db.prepare(`UPDATE email_deliveries SET state = ?2, provider_message_id = COALESCE(?3, provider_message_id),
|
|
450
|
+
last_error_code = ?4, next_attempt_at = ?5, delivered_at = CASE WHEN ?2 = 'delivered' THEN ?6 ELSE delivered_at END, updated_at = ?6
|
|
451
|
+
WHERE id = ?1 AND state = 'sending' AND attempt_count = ?7 AND updated_at = ?8`)
|
|
452
|
+
.bind(row.id, input.state, input.providerMessageId ?? null, input.errorCode, input.nextAttemptAt, timestamp, row.attemptCount, row.claimedAt),
|
|
453
|
+
db.prepare(`INSERT INTO email_delivery_attempts (id, delivery_id, attempt_number, result, provider_message_id, error_code, started_at, finished_at)
|
|
454
|
+
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE changes() = 1`)
|
|
455
|
+
.bind(attemptId, row.id, row.attemptCount, input.state, input.providerMessageId ?? null, input.errorCode, row.claimedAt, timestamp),
|
|
456
|
+
];
|
|
457
|
+
if (input.terminal) statements.push(
|
|
458
|
+
db.prepare(`UPDATE case_messages SET delivery_state = ?2 WHERE id = ?1 AND EXISTS (SELECT 1 FROM email_delivery_attempts WHERE id = ?3)`)
|
|
459
|
+
.bind(row.messageId, input.state, attemptId),
|
|
460
|
+
db.prepare(`UPDATE notices SET delivery_state = ?2, updated_at = ?3 WHERE message_id = ?1 AND EXISTS (SELECT 1 FROM email_delivery_attempts WHERE id = ?4)`)
|
|
461
|
+
.bind(row.messageId, input.state, timestamp, attemptId),
|
|
462
|
+
deliveryAudit(db, {
|
|
463
|
+
key: `email-delivery:${row.id}:${row.attemptCount}:${input.state}`, action: input.state === "delivered" ? "email.delivered" : "email.delivery_failed",
|
|
464
|
+
targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
|
|
465
|
+
details: {provider: "cloudflare_email_service", attemptCount: row.attemptCount, errorCode: input.errorCode}, createdAt: timestamp,
|
|
466
|
+
}, {table: "email_delivery_attempts", id: attemptId}),
|
|
467
|
+
);
|
|
468
|
+
const written = await db.batch(statements);
|
|
469
|
+
if (written[0]?.meta.changes !== 1) return false;
|
|
470
|
+
if (input.terminal) await signalNoticeDelivery(env, row.messageId, `email-${row.attemptCount}`);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
|
|
437
474
|
async function processEmail(db: D1Database, deliveryId: string, env: Env): Promise<void> {
|
|
438
475
|
const row = await claimEmail(db, deliveryId);
|
|
439
476
|
if (!row) return;
|
|
@@ -449,22 +486,16 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
|
|
|
449
486
|
});
|
|
450
487
|
} catch (error) {
|
|
451
488
|
if (!(error instanceof BudgetControlError)) throw error;
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
db.prepare(`UPDATE email_deliveries SET state = 'suppressed', last_error_code = 'email_budget_exhausted', next_attempt_at = NULL, updated_at = ?2 WHERE id = ?1`).bind(row.id, timestamp),
|
|
460
|
-
deliveryAudit(db, {
|
|
461
|
-
key: `email-delivery:${row.id}:budget-suppressed`, action: "email.budget_exhausted",
|
|
462
|
-
targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
|
|
463
|
-
details: { fallback: "customer_webhook", fallbackReference: error.fallbackReference }, createdAt: timestamp,
|
|
464
|
-
}),
|
|
465
|
-
]);
|
|
489
|
+
const terminal = !error.retryable || row.attemptCount >= row.attemptLimit;
|
|
490
|
+
const committed = await commitEmailResult(db, env, row, {
|
|
491
|
+
state: terminal ? "suppressed" : "failed", terminal,
|
|
492
|
+
errorCode: terminal ? "email_budget_exhausted" : "email_budget_deferred",
|
|
493
|
+
nextAttemptAt: terminal ? null : plusSeconds(now(), error.delaySeconds),
|
|
494
|
+
});
|
|
495
|
+
if (committed && !terminal) throw new RetryableDeliveryError(error.delaySeconds);
|
|
466
496
|
return;
|
|
467
497
|
}
|
|
498
|
+
|
|
468
499
|
let providerMessageId: string | null = null;
|
|
469
500
|
let providerAttempted = false;
|
|
470
501
|
let errorCode: string | null = null;
|
|
@@ -476,6 +507,7 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
|
|
|
476
507
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(from)) {
|
|
477
508
|
throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
|
|
478
509
|
}
|
|
510
|
+
if (row.recipientHash && row.recipientHash !== `sha256:${await sha256(`email-recipient-v1:${env.RATE_LIMIT_PEPPER}:${email}`)}`) throw new DeliveryConfigurationError("The participant address changed after this notice was prepared.", "notice_recipient_changed");
|
|
479
511
|
const prefix = reporterEmailSubjectPrefix(env);
|
|
480
512
|
const [brand, replyTo] = await Promise.all([
|
|
481
513
|
loadWorkspaceBrand(db),
|
|
@@ -493,6 +525,10 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
|
|
|
493
525
|
const rendered = renderReporterEmail({
|
|
494
526
|
brand, body: row.body, reportReference: row.reportReference,
|
|
495
527
|
templateVersionId: row.templateVersionId, subjectPrefix: prefix, audience: row.audience,
|
|
528
|
+
participantUrl: await issueParticipantAccessLink(env, {
|
|
529
|
+
reportId: row.reportId, audience: row.audience, deliveryKey: `email:${row.id}`,
|
|
530
|
+
expectedIdentity: { reference: row.recipientReference, contactCiphertext: row.contactCiphertext },
|
|
531
|
+
}),
|
|
496
532
|
});
|
|
497
533
|
providerAttempted = true;
|
|
498
534
|
const result = await binding.send({
|
|
@@ -507,50 +543,21 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
|
|
|
507
543
|
providerMessageId = typeof result.messageId === "string" ? result.messageId.slice(0, 300) : null;
|
|
508
544
|
} catch (error) {
|
|
509
545
|
errorCode = error instanceof DeliveryConfigurationError
|
|
510
|
-
? "email_configuration_error"
|
|
546
|
+
? error.code ?? "email_configuration_error"
|
|
547
|
+
: error instanceof ApiError && error.code === "participant_access_unavailable" ? "notice_recipient_changed"
|
|
511
548
|
: typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
|
|
512
549
|
? `email_${error.code}`.slice(0, 100) : "email_provider_error";
|
|
513
550
|
}
|
|
514
|
-
const finishedAt = now();
|
|
515
|
-
if (!errorCode) {
|
|
516
|
-
await settleBudgets(db, reservations, true);
|
|
517
|
-
await db.batch([
|
|
518
|
-
db.prepare(`UPDATE email_deliveries SET state = 'delivered', provider_message_id = ?2, last_error_code = NULL, next_attempt_at = NULL, delivered_at = ?3, updated_at = ?3 WHERE id = ?1`)
|
|
519
|
-
.bind(row.id, providerMessageId, finishedAt),
|
|
520
|
-
db.prepare(`UPDATE case_messages SET delivery_state = 'delivered' WHERE id = ?1`).bind(row.messageId),
|
|
521
|
-
db.prepare(`UPDATE notices SET delivery_state = 'delivered', updated_at = ?2 WHERE message_id = ?1`).bind(row.messageId, finishedAt),
|
|
522
|
-
deliveryAudit(db, {
|
|
523
|
-
key: `email-delivery:${row.id}:delivered`, action: "email.delivered",
|
|
524
|
-
targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
|
|
525
|
-
details: { provider: "cloudflare_email_service", attemptCount: row.attemptCount }, createdAt: finishedAt,
|
|
526
|
-
}),
|
|
527
|
-
]);
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
const permanentConfigurationFailure = errorCode === "email_configuration_error"
|
|
531
|
-
|| errorCode === "email_contact_decryption_failed"
|
|
532
|
-
|| errorCode === "email_contact_encryption_unavailable";
|
|
533
|
-
const terminal = permanentConfigurationFailure || row.attemptCount >= MAX_ATTEMPTS;
|
|
534
551
|
await settleBudgets(db, reservations, providerAttempted);
|
|
552
|
+
const configurationFailure = errorCode === "email_configuration_error" || errorCode === "notice_recipient_changed"
|
|
553
|
+
|| errorCode === "email_contact_decryption_failed" || errorCode === "email_contact_encryption_unavailable";
|
|
554
|
+
const terminal = !errorCode || configurationFailure || row.attemptCount >= row.attemptLimit;
|
|
535
555
|
const retryDelay = delay(row.attemptCount);
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
if (terminal) {
|
|
542
|
-
statements.push(
|
|
543
|
-
db.prepare(`UPDATE case_messages SET delivery_state = 'failed' WHERE id = ?1 AND delivery_state != 'delivered'`).bind(row.messageId),
|
|
544
|
-
db.prepare(`UPDATE notices SET delivery_state = 'failed', updated_at = ?2 WHERE message_id = ?1 AND delivery_state != 'delivered'`).bind(row.messageId, finishedAt),
|
|
545
|
-
deliveryAudit(db, {
|
|
546
|
-
key: `email-delivery:${row.id}:failed`, action: "email.delivery_failed",
|
|
547
|
-
targetType: "email_delivery", targetId: row.id, reportId: row.reportId,
|
|
548
|
-
details: { errorCode, attemptCount: row.attemptCount }, createdAt: finishedAt,
|
|
549
|
-
}),
|
|
550
|
-
);
|
|
551
|
-
}
|
|
552
|
-
await db.batch(statements);
|
|
553
|
-
if (!terminal) throw new RetryableDeliveryError(retryDelay);
|
|
556
|
+
const committed = await commitEmailResult(db, env, row, {
|
|
557
|
+
state: errorCode ? "failed" : "delivered", terminal,
|
|
558
|
+
errorCode, providerMessageId, nextAttemptAt: terminal ? null : plusSeconds(now(), retryDelay),
|
|
559
|
+
});
|
|
560
|
+
if (committed && !terminal) throw new RetryableDeliveryError(retryDelay);
|
|
554
561
|
}
|
|
555
562
|
|
|
556
563
|
export async function processDeliveryJob(db: D1Database, job: DeliveryJob, env: Env): Promise<void> {
|
|
@@ -573,21 +580,21 @@ export async function pendingDeliveryJobs(db: D1Database, limit = 50): Promise<D
|
|
|
573
580
|
`).bind(at, MAX_ATTEMPTS, boundedLimit).all<{ id: string }>(),
|
|
574
581
|
db.prepare(`
|
|
575
582
|
SELECT id FROM notification_outbox
|
|
576
|
-
WHERE terminal = 0 AND attempt_count <
|
|
583
|
+
WHERE terminal = 0 AND attempt_count < attempt_limit AND (
|
|
577
584
|
(status IN ('pending', 'failed') AND next_attempt_at <= ?1)
|
|
578
585
|
OR (status = 'delivering' AND lease_expires_at <= ?1)
|
|
579
586
|
)
|
|
580
|
-
ORDER BY next_attempt_at, id LIMIT ?
|
|
581
|
-
`).bind(at,
|
|
587
|
+
ORDER BY next_attempt_at, id LIMIT ?2
|
|
588
|
+
`).bind(at, boundedLimit).all<{ id: string }>(),
|
|
582
589
|
db.prepare(`
|
|
583
590
|
SELECT id FROM email_deliveries
|
|
584
|
-
WHERE attempt_count <
|
|
591
|
+
WHERE attempt_count < attempt_limit AND (
|
|
585
592
|
(state = 'pending' AND COALESCE(next_attempt_at, created_at) <= ?1)
|
|
586
593
|
OR (state = 'failed' AND next_attempt_at IS NOT NULL AND next_attempt_at <= ?1)
|
|
587
|
-
OR (state = 'sending' AND updated_at <= ?
|
|
594
|
+
OR (state = 'sending' AND updated_at <= ?2)
|
|
588
595
|
)
|
|
589
|
-
ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT ?
|
|
590
|
-
`).bind(at,
|
|
596
|
+
ORDER BY COALESCE(next_attempt_at, created_at), id LIMIT ?3
|
|
597
|
+
`).bind(at, plusSeconds(at, -60), boundedLimit).all<{ id: string }>(),
|
|
591
598
|
]);
|
|
592
599
|
return [
|
|
593
600
|
...actions.results.map((row): DeliveryJob => ({ version: 1, jobId: `action:${row.id}`, type: "deliver_action", outboxId: row.id })),
|
|
@@ -153,6 +153,7 @@ export function renderReporterEmail(input: {
|
|
|
153
153
|
templateVersionId: string | null;
|
|
154
154
|
subjectPrefix: string;
|
|
155
155
|
audience?: CaseEmailAudience;
|
|
156
|
+
participantUrl?: string;
|
|
156
157
|
}): RenderedEmail {
|
|
157
158
|
const emailKind = caseKind(input.templateVersionId);
|
|
158
159
|
const audience = input.audience ?? "reporter";
|
|
@@ -196,7 +197,8 @@ export function renderReporterEmail(input: {
|
|
|
196
197
|
category: copy.category,
|
|
197
198
|
heading: copy.heading,
|
|
198
199
|
intro: copy.intro,
|
|
199
|
-
contentHtml: `<div style="margin:24px 0 0;padding:20px;border-left:4px solid ${input.brand.accent_color};border-radius:${Math.min(input.brand.border_radius, 12)}px;background-color:${input.brand.background_color};color:${input.brand.text_color};font-size:16px;line-height:1.65">${messageHtml(input.body)}</div
|
|
200
|
+
contentHtml: `<div style="margin:24px 0 0;padding:20px;border-left:4px solid ${input.brand.accent_color};border-radius:${Math.min(input.brand.border_radius, 12)}px;background-color:${input.brand.background_color};color:${input.brand.text_color};font-size:16px;line-height:1.65">${messageHtml(input.body)}</div>`
|
|
201
|
+
+ (input.participantUrl ? `<p style="margin:24px 0"><a href="${escapeHtml(input.participantUrl)}" style="display:inline-block;padding:14px 20px;border-radius:8px;background:${actionColors(input.brand).background};color:${actionColors(input.brand).foreground};text-decoration:none;font-weight:700">Open your case</a></p><p style="font-size:14px;line-height:1.6">Read your conversation, add information or request an independent appeal. This private link is for you only; do not forward it. It opens once within 30 days. Request a fresh link from the case page if needed.</p>` : ""),
|
|
200
202
|
details: [
|
|
201
203
|
{ label: "Report reference", value: input.reportReference },
|
|
202
204
|
{ label: "Status", value: copy.status },
|
|
@@ -206,7 +208,8 @@ export function renderReporterEmail(input: {
|
|
|
206
208
|
return {
|
|
207
209
|
subject: copy.subject,
|
|
208
210
|
html,
|
|
209
|
-
text: `${copy.heading}\n\n${copy.intro}\n\n${input.body}\n\nReport reference: ${input.reportReference}\nStatus: ${copy.status}\n\n${replyCopy}\n\nFor privacy, this email includes only the information needed for this update
|
|
211
|
+
text: `${copy.heading}\n\n${copy.intro}\n\n${input.body}\n\nReport reference: ${input.reportReference}\nStatus: ${copy.status}\n\n${replyCopy}\n\nFor privacy, this email includes only the information needed for this update.`
|
|
212
|
+
+ (input.participantUrl ? `\n\nOpen your case: ${input.participantUrl}\nRead your conversation, add information or request an independent appeal. This private link is for you only; do not forward it. It opens once within 30 days. Request a fresh link from the case page if needed.` : ""),
|
|
210
213
|
};
|
|
211
214
|
}
|
|
212
215
|
|
|
@@ -100,57 +100,49 @@ function percentile(values: number[], ratio: number): number | null {
|
|
|
100
100
|
return Math.round(values[index] ?? 0);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
export async function loadAnalytics(db: D1Database, days = 30): Promise<Record<string, unknown>> {
|
|
103
|
+
export async function loadAnalytics(db: D1Database, days = 30, queueScope: string[] | null = null): Promise<Record<string, unknown>> {
|
|
104
104
|
const generatedAt = now();
|
|
105
105
|
const boundedDays = Math.max(1, Math.min(366, days));
|
|
106
106
|
const fromDate = subtractDays(generatedAt, boundedDays).slice(0, 10);
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
SELECT
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
db.prepare(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
FROM
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
SUM(appeal_reversals) AS appealReversals, SUM(invalid_outputs) AS invalidOutputs,
|
|
132
|
-
SUM(provider_errors) AS providerErrors, SUM(timeouts) AS timeouts
|
|
133
|
-
FROM ai_usage_daily WHERE usage_date >= ?1 GROUP BY usage_date ORDER BY usage_date
|
|
134
|
-
`).bind(fromDate).all<Record<string, unknown>>(),
|
|
107
|
+
const from = `${fromDate}T00:00:00.000Z`;
|
|
108
|
+
const scope = queueScope === null ? null : JSON.stringify(queueScope);
|
|
109
|
+
const visible = `WITH visible_reports AS (SELECT * FROM reports WHERE (?2 IS NULL OR queue_id IN (SELECT value FROM json_each(?2))))`;
|
|
110
|
+
const query = (sql: string) => db.prepare(`${visible} ${sql}`).bind(from, scope);
|
|
111
|
+
const [series, durations, backlog, evidence, actions, notices, appeals, audits] = await Promise.all([
|
|
112
|
+
query(`SELECT substr(received_at,1,10) AS aggregateDate, 'reports_received' AS metricKey, 'source' AS dimensionKey, source AS dimensionValue, COUNT(*) AS value FROM visible_reports WHERE received_at >= ?1 GROUP BY aggregateDate, source
|
|
113
|
+
UNION ALL SELECT substr(received_at,1,10), 'reports_received', 'reason', reason_code, COUNT(*) FROM visible_reports WHERE received_at >= ?1 GROUP BY substr(received_at,1,10), reason_code
|
|
114
|
+
UNION ALL SELECT substr(d.created_at,1,10), 'decisions', 'maker_type', d.maker_type, COUNT(*) FROM decisions d JOIN visible_reports r ON r.id=d.report_id WHERE d.created_at >= ?1 GROUP BY substr(d.created_at,1,10), d.maker_type
|
|
115
|
+
UNION ALL SELECT substr(d.created_at,1,10), 'decisions', 'outcome', d.decision_code, COUNT(*) FROM decisions d JOIN visible_reports r ON r.id=d.report_id WHERE d.created_at >= ?1 GROUP BY substr(d.created_at,1,10), d.decision_code
|
|
116
|
+
ORDER BY 1,2,3,4 LIMIT 10000`).all<Record<string, unknown>>(),
|
|
117
|
+
query(`SELECT CAST((julianday(resolved_at)-julianday(received_at))*86400 AS INTEGER) AS seconds FROM visible_reports WHERE resolved_at IS NOT NULL AND received_at >= ?1 ORDER BY seconds LIMIT 10000`).all<{ seconds: number }>(),
|
|
118
|
+
// Backlog is current, irrespective of the selected reporting period.
|
|
119
|
+
db.prepare(`${visible} SELECT queue_id AS queueId, (SELECT name FROM queue_definitions WHERE id=queue_id) AS queueName, state, COUNT(*) AS count, CAST(MAX((julianday(?1)-julianday(received_at))*86400) AS INTEGER) AS oldestAgeSeconds
|
|
120
|
+
FROM visible_reports WHERE state IN ('received','human_review','ai_review','awaiting_reporter','human_review_requested','appealed') GROUP BY queue_id,state ORDER BY queue_id,state`).bind(generatedAt, scope).all<Record<string, unknown>>(),
|
|
121
|
+
query(`SELECT COUNT(*) AS receivedCases,
|
|
122
|
+
COALESCE(SUM(EXISTS(SELECT 1 FROM findings f WHERE f.report_id=r.id AND f.status='available' AND (f.expires_at IS NULL OR f.expires_at > ?3))),0) AS withRetrievedEvidence,
|
|
123
|
+
COALESCE(SUM(EXISTS(SELECT 1 FROM findings f WHERE f.report_id=r.id AND (f.status IN ('unavailable','invalid','stale') OR (f.status='available' AND f.expires_at <= ?3)))),0) AS withEvidenceGaps,
|
|
124
|
+
COALESCE(SUM(EXISTS(SELECT 1 FROM findings f WHERE f.report_id=r.id AND f.status='available' AND json_array_length(f.uncertainty_json)>0)),0) AS withRecordedUncertainty
|
|
125
|
+
FROM visible_reports r WHERE received_at >= ?1`).bind(from, scope, generatedAt).first<Record<string, number>>(),
|
|
126
|
+
query(`SELECT 'playbook' AS pipeline, CASE WHEN a.accepted_at IS NOT NULL AND a.delivery_terminal = 0 THEN 'awaiting_confirmation' ELSE a.state END AS state, COUNT(*) AS count FROM action_proposals a JOIN visible_reports r ON r.id=a.report_id WHERE a.created_at >= ?1 GROUP BY 2
|
|
127
|
+
UNION ALL SELECT 'legacy', a.status, COUNT(*) FROM decision_action_outbox a JOIN visible_reports r ON r.id=a.report_id WHERE a.created_at >= ?1 GROUP BY a.status`).all<Record<string, unknown>>(),
|
|
128
|
+
query(`SELECT n.audience, n.delivery_state AS state, COUNT(*) AS count FROM notices n JOIN visible_reports r ON r.id=n.report_id WHERE n.created_at >= ?1 GROUP BY n.audience,n.delivery_state`).all<Record<string, unknown>>(),
|
|
129
|
+
query(`SELECT a.state, COUNT(*) AS count FROM appeals a JOIN visible_reports r ON r.id=a.report_id WHERE a.submitted_at >= ?1 GROUP BY a.state`).all<Record<string, unknown>>(),
|
|
130
|
+
query(`SELECT s.sample_reason AS reason, COUNT(*) AS selected, SUM(EXISTS(SELECT 1 FROM quality_reviews q WHERE q.sample_id=s.id)) AS reviewed FROM ai_quality_samples s JOIN visible_reports r ON r.id=s.report_id WHERE s.selected_at >= ?1 GROUP BY s.sample_reason`).all<Record<string, unknown>>(),
|
|
135
131
|
]);
|
|
136
|
-
const seconds = durations.results.map(
|
|
132
|
+
const seconds = durations.results.map(row => row.seconds).filter(value => Number.isFinite(value) && value >= 0);
|
|
137
133
|
return {
|
|
138
|
-
generatedAt,
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
backlog: backlog.results,
|
|
143
|
-
resolutionSeconds: {
|
|
144
|
-
count: seconds.length,
|
|
145
|
-
median: percentile(seconds, 0.5),
|
|
146
|
-
p90: percentile(seconds, 0.9),
|
|
147
|
-
p95: percentile(seconds, 0.95),
|
|
148
|
-
},
|
|
149
|
-
aiUsage: aiUsage.results,
|
|
134
|
+
generatedAt, fromDate, days: boundedDays, queueScope,
|
|
135
|
+
series: series.results, backlog: backlog.results,
|
|
136
|
+
resolutionSeconds: { count: seconds.length, median: percentile(seconds,0.5), p90: percentile(seconds,0.9), p95: percentile(seconds,0.95) },
|
|
137
|
+
evidenceCoverage: evidence, actions: actions.results, notices: notices.results, appeals: appeals.results, audits: audits.results,
|
|
150
138
|
limitations: [
|
|
151
|
-
"Counts describe
|
|
152
|
-
"
|
|
139
|
+
"Counts describe recorded cases and decisions, not verified real-world incidents.",
|
|
140
|
+
"Evidence coverage counts stored, available findings; it does not establish that an allegation is true.",
|
|
141
|
+
"Delivered notices were handed to a delivery provider; recipient reading is not measured.",
|
|
142
|
+
"Appeal outcomes and quality samples describe reviewed work, not an overall accuracy score.",
|
|
143
|
+
...(queueScope !== null ? ["Only cases in your current queue scope are included."] : []),
|
|
153
144
|
...(durations.results.length >= 10000 ? ["Resolution percentiles are bounded to the first 10,000 records in the selected period."] : []),
|
|
145
|
+
...(series.results.length >= 10000 ? ["Daily breakdowns are limited to 10,000 rows; narrow the period for a complete breakdown."] : []),
|
|
154
146
|
],
|
|
155
147
|
};
|
|
156
148
|
}
|
|
@@ -179,6 +171,7 @@ async function anonymizeReport(
|
|
|
179
171
|
]);
|
|
180
172
|
if (scope === "all_permitted_data") {
|
|
181
173
|
await db.batch([
|
|
174
|
+
db.prepare(`UPDATE report_handoffs SET note = '[removed by retention]', redacted_at = COALESCE(redacted_at, ?2) WHERE report_id = ?1`).bind(reportId, at),
|
|
182
175
|
db.prepare(`UPDATE case_messages SET body = '[removed by retention]', sender_id = 'redacted', redacted_at = COALESCE(redacted_at, ?2) WHERE report_id = ?1`).bind(reportId, at),
|
|
183
176
|
db.prepare(`
|
|
184
177
|
UPDATE report_targets SET target_reference = 'redacted:' || id, owner_reference = NULL,
|
|
@@ -189,7 +182,7 @@ async function anonymizeReport(
|
|
|
189
182
|
db.prepare(`UPDATE decisions SET rationale = '[removed by retention]', evidence_references_json = '[]', sensitive_redacted_at = ?2 WHERE report_id = ?1 AND sensitive_redacted_at IS NULL`).bind(reportId, at),
|
|
190
183
|
db.prepare(`UPDATE notices SET rendered_body = '[removed by retention]', redacted_at = ?2 WHERE report_id = ?1 AND redacted_at IS NULL`).bind(reportId, at),
|
|
191
184
|
db.prepare(`
|
|
192
|
-
UPDATE appeal_decisions SET rationale = '[removed by retention]', rationale_redacted_at = ?2
|
|
185
|
+
UPDATE appeal_decisions SET rationale = '[removed by retention]', rationale_redacted_at = ?2, correction_reference = NULL, correction_note = NULL
|
|
193
186
|
WHERE appeal_id IN (SELECT id FROM appeals WHERE report_id = ?1) AND rationale_redacted_at IS NULL
|
|
194
187
|
`).bind(reportId, at),
|
|
195
188
|
db.prepare(`
|
|
@@ -249,6 +242,10 @@ export async function runScheduledRetention(db: D1Database, messageRetentionDays
|
|
|
249
242
|
SELECT 1 FROM legal_holds h WHERE h.report_id = notices.report_id AND h.state = 'active'
|
|
250
243
|
)
|
|
251
244
|
`).bind(messageCutoff, startedAt).run();
|
|
245
|
+
await db.prepare(`UPDATE report_handoffs SET note = '[removed by retention]', redacted_at = ?2
|
|
246
|
+
WHERE redacted_at IS NULL AND created_at < ?1 AND NOT EXISTS (
|
|
247
|
+
SELECT 1 FROM legal_holds h WHERE h.report_id = report_handoffs.report_id AND h.state = 'active'
|
|
248
|
+
)`).bind(messageCutoff, startedAt).run();
|
|
252
249
|
const candidates = await db.prepare(`
|
|
253
250
|
SELECT id FROM reports r
|
|
254
251
|
WHERE r.updated_at < ?1 AND r.state IN ('resolved_by_human', 'resolved_by_ai', 'closed')
|