create-safest-tools 0.2.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/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
import { AUDIT_GENESIS_HASH, canonicalJson, hashAuditEntry } from "./audit";
|
|
2
|
+
import { ApiError } from "./report-http";
|
|
3
|
+
import { analystAvatarColor } from "./report-presence";
|
|
4
|
+
import type { DeliveryJob, OperatorSession, ParticipantAudience, ReportState } from "./report-types";
|
|
5
|
+
import type { AppealDecisionInput, DecisionInput, ReviewerMessageInput } from "./report-workflow-validation";
|
|
6
|
+
|
|
7
|
+
interface WorkflowReportRow {
|
|
8
|
+
id: string;
|
|
9
|
+
public_reference: string;
|
|
10
|
+
state: ReportState;
|
|
11
|
+
queue_id: string | null;
|
|
12
|
+
queue_version_id: string | null;
|
|
13
|
+
policy_version_id: string | null;
|
|
14
|
+
target_reference: string;
|
|
15
|
+
owner_reference: string | null;
|
|
16
|
+
reporter_reference: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ClaimRow {
|
|
20
|
+
report_id: string;
|
|
21
|
+
reviewer_id: string;
|
|
22
|
+
claimed_at: string;
|
|
23
|
+
expires_at: string;
|
|
24
|
+
version: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function now(): string {
|
|
28
|
+
return new Date().toISOString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function addMinutes(timestamp: string, minutes: number): string {
|
|
32
|
+
return new Date(Date.parse(timestamp) + minutes * 60_000).toISOString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parsedJson(value: unknown, fallback: unknown): unknown {
|
|
36
|
+
if (typeof value !== "string") return fallback;
|
|
37
|
+
try { return JSON.parse(value) as unknown; } catch { return fallback; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function auditEvent(
|
|
41
|
+
db: D1Database,
|
|
42
|
+
input: {
|
|
43
|
+
idempotencyKey: string;
|
|
44
|
+
action: string;
|
|
45
|
+
actorType: string;
|
|
46
|
+
actorId: string;
|
|
47
|
+
targetType: string;
|
|
48
|
+
targetId: string;
|
|
49
|
+
details: unknown;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
},
|
|
52
|
+
): D1PreparedStatement {
|
|
53
|
+
return db.prepare(`
|
|
54
|
+
INSERT OR IGNORE INTO audit_events (
|
|
55
|
+
id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|
|
56
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
57
|
+
`).bind(
|
|
58
|
+
crypto.randomUUID(), input.idempotencyKey, input.action, input.actorType, input.actorId,
|
|
59
|
+
input.targetType, input.targetId, canonicalJson(input.details), input.createdAt,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function workflowReport(db: D1Database, reportId: string): Promise<WorkflowReportRow | null> {
|
|
64
|
+
return db.prepare(`
|
|
65
|
+
SELECT r.id, r.public_reference, r.state, r.queue_id, r.queue_version_id, r.policy_version_id,
|
|
66
|
+
t.target_reference, t.owner_reference, p.reference AS reporter_reference
|
|
67
|
+
FROM reports r
|
|
68
|
+
JOIN report_targets t ON t.report_id = r.id
|
|
69
|
+
JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
|
|
70
|
+
WHERE r.id = ?1
|
|
71
|
+
LIMIT 1
|
|
72
|
+
`).bind(reportId).first<WorkflowReportRow>();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function requireActiveClaim(db: D1Database, reportId: string, reviewerId: string, at: string): Promise<ClaimRow> {
|
|
76
|
+
const claim = await db.prepare(`
|
|
77
|
+
SELECT report_id, reviewer_id, claimed_at, expires_at, version
|
|
78
|
+
FROM review_claims
|
|
79
|
+
WHERE report_id = ?1
|
|
80
|
+
LIMIT 1
|
|
81
|
+
`).bind(reportId).first<ClaimRow>();
|
|
82
|
+
if (!claim || claim.reviewer_id !== reviewerId || claim.expires_at <= at) {
|
|
83
|
+
throw new ApiError(409, "active_claim_required", "Claim this report before changing it.");
|
|
84
|
+
}
|
|
85
|
+
return claim;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function claimReport(
|
|
89
|
+
db: D1Database,
|
|
90
|
+
reportId: string,
|
|
91
|
+
session: OperatorSession,
|
|
92
|
+
claimTtlMinutes: number,
|
|
93
|
+
): Promise<{ reviewerId: string; claimedAt: string; expiresAt: string; version: number }> {
|
|
94
|
+
const report = await workflowReport(db, reportId);
|
|
95
|
+
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
96
|
+
if (!["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state)) {
|
|
97
|
+
throw new ApiError(409, "report_not_claimable", "The report is not in a claimable state.");
|
|
98
|
+
}
|
|
99
|
+
const claimedAt = now();
|
|
100
|
+
const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, claimTtlMinutes)));
|
|
101
|
+
await db.prepare(`
|
|
102
|
+
INSERT INTO review_claims (report_id, reviewer_id, claimed_at, expires_at, version)
|
|
103
|
+
VALUES (?1, ?2, ?3, ?4, 1)
|
|
104
|
+
ON CONFLICT(report_id) DO UPDATE SET
|
|
105
|
+
reviewer_id = excluded.reviewer_id,
|
|
106
|
+
claimed_at = excluded.claimed_at,
|
|
107
|
+
expires_at = excluded.expires_at,
|
|
108
|
+
version = review_claims.version + 1
|
|
109
|
+
WHERE review_claims.expires_at <= ?3 OR review_claims.reviewer_id = ?2
|
|
110
|
+
`).bind(reportId, session.actor.id, claimedAt, expiresAt).run();
|
|
111
|
+
const claim = await db.prepare(`
|
|
112
|
+
SELECT report_id, reviewer_id, claimed_at, expires_at, version
|
|
113
|
+
FROM review_claims WHERE report_id = ?1 LIMIT 1
|
|
114
|
+
`).bind(reportId).first<ClaimRow>();
|
|
115
|
+
if (!claim || claim.reviewer_id !== session.actor.id) {
|
|
116
|
+
throw new ApiError(409, "report_already_claimed", "Another reviewer currently holds this report.", {
|
|
117
|
+
expires_at: claim?.expires_at,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
await db.batch([
|
|
121
|
+
auditEvent(db, {
|
|
122
|
+
idempotencyKey: `claim:${reportId}:${claim.version}`,
|
|
123
|
+
action: "report.claimed",
|
|
124
|
+
actorType: session.actor.type,
|
|
125
|
+
actorId: session.actor.id,
|
|
126
|
+
targetType: "report",
|
|
127
|
+
targetId: reportId,
|
|
128
|
+
details: { expiresAt: claim.expires_at, version: claim.version },
|
|
129
|
+
createdAt: claimedAt,
|
|
130
|
+
}),
|
|
131
|
+
]);
|
|
132
|
+
return { reviewerId: claim.reviewer_id, claimedAt: claim.claimed_at, expiresAt: claim.expires_at, version: claim.version };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function releaseReportClaim(db: D1Database, reportId: string, session: OperatorSession): Promise<void> {
|
|
136
|
+
const claim = await db.prepare(`SELECT reviewer_id, version FROM review_claims WHERE report_id = ?1 LIMIT 1`)
|
|
137
|
+
.bind(reportId).first<{ reviewer_id: string; version: number }>();
|
|
138
|
+
if (!claim) return;
|
|
139
|
+
if (claim.reviewer_id !== session.actor.id && !session.permissions.manageConfiguration) {
|
|
140
|
+
throw new ApiError(409, "claim_owned_by_another_reviewer", "Only the current reviewer or an administrator can release this claim.");
|
|
141
|
+
}
|
|
142
|
+
const createdAt = now();
|
|
143
|
+
await db.batch([
|
|
144
|
+
db.prepare(`DELETE FROM review_claims WHERE report_id = ?1`).bind(reportId),
|
|
145
|
+
auditEvent(db, {
|
|
146
|
+
idempotencyKey: `claim-release:${reportId}:${claim.version}`,
|
|
147
|
+
action: "report.claim_released",
|
|
148
|
+
actorType: session.actor.type,
|
|
149
|
+
actorId: session.actor.id,
|
|
150
|
+
targetType: "report",
|
|
151
|
+
targetId: reportId,
|
|
152
|
+
details: { priorReviewerId: claim.reviewer_id, version: claim.version },
|
|
153
|
+
createdAt,
|
|
154
|
+
}),
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function loadReportDetail(db: D1Database, reportId: string): Promise<Record<string, unknown> | null> {
|
|
159
|
+
const report = await db.prepare(`
|
|
160
|
+
SELECT r.id, r.public_reference AS reference, r.source, r.state, r.priority, r.reason_code AS reasonCode,
|
|
161
|
+
r.locale, r.queue_id AS queueId, r.submitted_at AS submittedAt, r.received_at AS receivedAt,
|
|
162
|
+
r.updated_at AS updatedAt, r.due_at AS dueAt, r.resolved_at AS resolvedAt,
|
|
163
|
+
r.policy_version_id AS policyVersionId, policy.title AS policyTitle,
|
|
164
|
+
policy.rules_json AS policyRulesJson, r.queue_version_id AS queuePolicyVersionId,
|
|
165
|
+
qv.management_mode AS queueManagementMode, qv.reviewer_instructions AS reviewerInstructions,
|
|
166
|
+
t.target_type AS targetType, t.target_reference AS targetReference,
|
|
167
|
+
t.owner_reference AS ownerReference, t.customer_url AS customerUrl,
|
|
168
|
+
p.reference AS reporterReference
|
|
169
|
+
FROM reports r
|
|
170
|
+
JOIN report_targets t ON t.report_id = r.id
|
|
171
|
+
JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
|
|
172
|
+
LEFT JOIN policy_versions policy ON policy.id = r.policy_version_id
|
|
173
|
+
LEFT JOIN queue_versions qv ON qv.id = r.queue_version_id
|
|
174
|
+
WHERE r.id = ?1
|
|
175
|
+
LIMIT 1
|
|
176
|
+
`).bind(reportId).first<Record<string, unknown>>();
|
|
177
|
+
if (!report) return null;
|
|
178
|
+
const [answers, evidence, messages, decisions, actions, notices, aiRuns, history, claim, findings] = await Promise.all([
|
|
179
|
+
db.prepare(`SELECT field_key AS fieldKey, value_json AS valueJson FROM report_answers WHERE report_id = ?1 ORDER BY field_key`).bind(reportId).all<Record<string, unknown>>(),
|
|
180
|
+
db.prepare(`SELECT id, evidence_kind AS kind, reference, availability, created_at AS createdAt FROM evidence_references WHERE report_id = ?1 ORDER BY created_at, id`).bind(reportId).all<Record<string, unknown>>(),
|
|
181
|
+
db.prepare(`SELECT id, audience, direction, sender_type AS senderType, sender_id AS senderId, body, automated, delivery_state AS deliveryState, created_at AS createdAt FROM case_messages WHERE report_id = ?1 ORDER BY created_at, id LIMIT 500`).bind(reportId).all<Record<string, unknown>>(),
|
|
182
|
+
db.prepare(`SELECT id, decision_code AS decisionCode, policy_code AS policyCode, rationale, evidence_references_json AS evidenceReferencesJson, maker_type AS makerType, maker_id AS makerId, proposed_action_code AS proposedActionCode, created_at AS createdAt FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, id DESC`).bind(reportId).all<Record<string, unknown>>(),
|
|
183
|
+
db.prepare(`
|
|
184
|
+
SELECT id, decision_id AS decisionId, action_code AS actionCode, status, attempt_count AS attemptCount,
|
|
185
|
+
terminal, last_error_code AS errorCode, updated_at AS updatedAt
|
|
186
|
+
FROM decision_action_outbox WHERE report_id = ?1 ORDER BY created_at DESC, id DESC
|
|
187
|
+
`).bind(reportId).all<Record<string, unknown>>(),
|
|
188
|
+
db.prepare(`
|
|
189
|
+
SELECT id, decision_id AS decisionId, audience, delivery_state AS deliveryState, updated_at AS updatedAt
|
|
190
|
+
FROM notices WHERE report_id = ?1 ORDER BY created_at DESC, id DESC
|
|
191
|
+
`).bind(reportId).all<Record<string, unknown>>(),
|
|
192
|
+
db.prepare(`
|
|
193
|
+
SELECT ar.id, ar.status, ar.mode, ar.model, ar.prompt_version AS promptVersion,
|
|
194
|
+
ar.config_version_id AS configVersionId, ar.error_code AS errorCode,
|
|
195
|
+
ar.input_units AS inputUnits, ar.output_units AS outputUnits,
|
|
196
|
+
ar.estimated_cost_microusd AS estimatedCostMicrousd, ar.started_at AS startedAt,
|
|
197
|
+
ar.finished_at AS finishedAt, ai.outcome, ai.policy_code AS policyCode,
|
|
198
|
+
ai.summary, ai.confidence, ai.uncertain, ai.validation_state AS validationState,
|
|
199
|
+
ai.validation_errors_json AS validationErrorsJson
|
|
200
|
+
FROM ai_runs ar LEFT JOIN ai_results ai ON ai.ai_run_id = ar.id
|
|
201
|
+
WHERE ar.report_id = ?1 ORDER BY ar.started_at DESC, ar.id DESC
|
|
202
|
+
`).bind(reportId).all<Record<string, unknown>>(),
|
|
203
|
+
db.prepare(`SELECT id, from_state AS fromState, to_state AS toState, reason_code AS reasonCode, actor_type AS actorType, actor_id AS actorId, created_at AS createdAt FROM report_state_history WHERE report_id = ?1 ORDER BY created_at, id`).bind(reportId).all<Record<string, unknown>>(),
|
|
204
|
+
db.prepare(`
|
|
205
|
+
SELECT c.reviewer_id AS reviewerId, c.claimed_at AS claimedAt, c.expires_at AS expiresAt, c.version,
|
|
206
|
+
p.display_name AS analystDisplayName, p.avatar_url AS analystAvatarUrl,
|
|
207
|
+
p.avatar_color AS analystAvatarColor
|
|
208
|
+
FROM review_claims c
|
|
209
|
+
LEFT JOIN analyst_profiles p ON p.actor_id = c.reviewer_id
|
|
210
|
+
WHERE c.report_id = ?1 AND c.expires_at > ?2
|
|
211
|
+
LIMIT 1
|
|
212
|
+
`).bind(reportId, now()).first<Record<string, unknown>>(),
|
|
213
|
+
db.prepare(`
|
|
214
|
+
SELECT f.id, f.run_id AS runId, f.step_run_id AS stepRunId,
|
|
215
|
+
f.component_version_id AS componentVersionId, f.ai_run_id AS aiRunId,
|
|
216
|
+
f.finding_type AS findingType, f.subject_type AS subjectType,
|
|
217
|
+
f.subject_reference AS subjectReference, f.status, f.confidence, f.summary,
|
|
218
|
+
f.data_json AS dataJson, f.provenance_json AS provenanceJson,
|
|
219
|
+
f.uncertainty_json AS uncertaintyJson, f.input_digest AS inputDigest,
|
|
220
|
+
f.output_digest AS outputDigest, f.expires_at AS expiresAt,
|
|
221
|
+
f.created_at AS createdAt, f.superseded_at AS supersededAt,
|
|
222
|
+
cv.version AS componentVersion, cd.component_key AS componentKey
|
|
223
|
+
FROM findings f
|
|
224
|
+
LEFT JOIN component_versions cv ON cv.id = f.component_version_id
|
|
225
|
+
LEFT JOIN component_definitions cd ON cd.id = cv.definition_id
|
|
226
|
+
WHERE f.report_id = ?1 ORDER BY f.created_at DESC, f.id DESC
|
|
227
|
+
LIMIT 500
|
|
228
|
+
`).bind(reportId).all<Record<string, unknown>>(),
|
|
229
|
+
]);
|
|
230
|
+
return {
|
|
231
|
+
...report,
|
|
232
|
+
policyRules: (() => {
|
|
233
|
+
try {
|
|
234
|
+
const value: unknown = JSON.parse(String(report.policyRulesJson ?? "[]"));
|
|
235
|
+
return Array.isArray(value) ? value : [];
|
|
236
|
+
} catch { return []; }
|
|
237
|
+
})(),
|
|
238
|
+
policyRulesJson: undefined,
|
|
239
|
+
answers: answers.results.map((row) => ({ fieldKey: row.fieldKey, value: JSON.parse(String(row.valueJson)) })),
|
|
240
|
+
evidence: evidence.results,
|
|
241
|
+
messages: messages.results.map((row) => ({ ...row, automated: row.automated === 1 })),
|
|
242
|
+
decisions: decisions.results.map((row) => ({
|
|
243
|
+
...row,
|
|
244
|
+
evidenceReferences: JSON.parse(String(row.evidenceReferencesJson)),
|
|
245
|
+
evidenceReferencesJson: undefined,
|
|
246
|
+
})),
|
|
247
|
+
actions: actions.results.map((row) => ({ ...row, terminal: row.terminal === 1 })),
|
|
248
|
+
notices: notices.results,
|
|
249
|
+
aiRuns: aiRuns.results.map((row) => ({
|
|
250
|
+
...row,
|
|
251
|
+
uncertain: row.uncertain === 1,
|
|
252
|
+
validationErrors: row.validationErrorsJson ? JSON.parse(String(row.validationErrorsJson)) : [],
|
|
253
|
+
validationErrorsJson: undefined,
|
|
254
|
+
})),
|
|
255
|
+
history: history.results,
|
|
256
|
+
findings: findings.results.map((row) => ({
|
|
257
|
+
...row,
|
|
258
|
+
freshness: row.status === "available" && row.expiresAt && String(row.expiresAt) <= now() ? "stale" : row.status,
|
|
259
|
+
data: parsedJson(row.dataJson, {}),
|
|
260
|
+
provenance: parsedJson(row.provenanceJson, {}),
|
|
261
|
+
uncertainty: parsedJson(row.uncertaintyJson, []),
|
|
262
|
+
dataJson: undefined,
|
|
263
|
+
provenanceJson: undefined,
|
|
264
|
+
uncertaintyJson: undefined,
|
|
265
|
+
})),
|
|
266
|
+
claim: claim ? {
|
|
267
|
+
reviewerId: claim.reviewerId,
|
|
268
|
+
claimedAt: claim.claimedAt,
|
|
269
|
+
expiresAt: claim.expiresAt,
|
|
270
|
+
version: claim.version,
|
|
271
|
+
analyst: {
|
|
272
|
+
displayName: claim.analystDisplayName ?? claim.reviewerId,
|
|
273
|
+
avatarUrl: claim.analystAvatarUrl ?? null,
|
|
274
|
+
avatarColor: claim.analystAvatarColor ?? analystAvatarColor(String(claim.reviewerId)),
|
|
275
|
+
},
|
|
276
|
+
} : null,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface WorkflowDeliveryOptions {
|
|
281
|
+
notificationEnabled: boolean;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function addReviewerMessage(
|
|
285
|
+
db: D1Database,
|
|
286
|
+
reportId: string,
|
|
287
|
+
session: OperatorSession,
|
|
288
|
+
input: ReviewerMessageInput,
|
|
289
|
+
options: WorkflowDeliveryOptions,
|
|
290
|
+
): Promise<{ messageId: string; jobs: DeliveryJob[]; idempotentReplay: boolean }> {
|
|
291
|
+
const existing = await db.prepare(`
|
|
292
|
+
SELECT id FROM case_messages WHERE report_id = ?1 AND idempotency_key = ?2 LIMIT 1
|
|
293
|
+
`).bind(reportId, input.idempotencyKey).first<{ id: string }>();
|
|
294
|
+
if (existing) return { messageId: existing.id, jobs: [], idempotentReplay: true };
|
|
295
|
+
const createdAt = now();
|
|
296
|
+
await requireActiveClaim(db, reportId, session.actor.id, createdAt);
|
|
297
|
+
const report = await workflowReport(db, reportId);
|
|
298
|
+
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
299
|
+
if (["closed", "resolved_by_human", "resolved_by_ai", "appealed"].includes(report.state)) {
|
|
300
|
+
throw new ApiError(409, "report_not_messageable", "The report is no longer open for reviewer messages.");
|
|
301
|
+
}
|
|
302
|
+
const messageId = crypto.randomUUID();
|
|
303
|
+
const noticeId = crypto.randomUUID();
|
|
304
|
+
const notificationId = crypto.randomUUID();
|
|
305
|
+
const nextState: ReportState = input.awaitReporter ? "awaiting_reporter" : report.state;
|
|
306
|
+
const jobs: DeliveryJob[] = options.notificationEnabled
|
|
307
|
+
? [{ version: 1, jobId: `notification:${notificationId}`, type: "deliver_notification", outboxId: notificationId }]
|
|
308
|
+
: [];
|
|
309
|
+
const statements: D1PreparedStatement[] = [
|
|
310
|
+
db.prepare(`
|
|
311
|
+
INSERT INTO case_messages (
|
|
312
|
+
id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
|
|
313
|
+
automated, delivery_state, created_at, idempotency_key
|
|
314
|
+
) VALUES (?1, ?2, 'reporter', 'outbound', 'human', ?3, ?4, 'reporter-follow-up-v1', 0, ?5, ?6, ?7)
|
|
315
|
+
`).bind(messageId, reportId, session.actor.id, input.body, options.notificationEnabled ? "queued" : "suppressed", createdAt, input.idempotencyKey),
|
|
316
|
+
db.prepare(`
|
|
317
|
+
INSERT INTO notices (
|
|
318
|
+
id, report_id, audience, template_version_id, rendered_body, delivery_state,
|
|
319
|
+
created_at, updated_at, idempotency_key, message_id
|
|
320
|
+
) VALUES (?1, ?2, 'reporter', 'reporter-follow-up-v1', ?3, ?4, ?5, ?5, ?6, ?7)
|
|
321
|
+
`).bind(noticeId, reportId, input.body, options.notificationEnabled ? "queued" : "suppressed", createdAt, input.idempotencyKey, messageId),
|
|
322
|
+
auditEvent(db, {
|
|
323
|
+
idempotencyKey: `reviewer-message:${reportId}:${input.idempotencyKey}`,
|
|
324
|
+
action: "report.reporter_message_created",
|
|
325
|
+
actorType: session.actor.type,
|
|
326
|
+
actorId: session.actor.id,
|
|
327
|
+
targetType: "report",
|
|
328
|
+
targetId: reportId,
|
|
329
|
+
details: { messageId, audience: "reporter", nextState },
|
|
330
|
+
createdAt,
|
|
331
|
+
}),
|
|
332
|
+
];
|
|
333
|
+
if (nextState !== report.state) {
|
|
334
|
+
statements.push(
|
|
335
|
+
db.prepare(`UPDATE reports SET state = ?2, updated_at = ?3 WHERE id = ?1`).bind(reportId, nextState, createdAt),
|
|
336
|
+
db.prepare(`
|
|
337
|
+
INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
|
|
338
|
+
VALUES (?1, ?2, ?3, ?4, 'reviewer_requested_information', ?5, ?6, ?7)
|
|
339
|
+
`).bind(crypto.randomUUID(), reportId, report.state, nextState, session.actor.type, session.actor.id, createdAt),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (options.notificationEnabled) {
|
|
343
|
+
statements.push(db.prepare(`
|
|
344
|
+
INSERT INTO notification_outbox (
|
|
345
|
+
id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
|
|
346
|
+
) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
|
|
347
|
+
`).bind(notificationId, noticeId, `notice:${noticeId}`, canonicalJson({
|
|
348
|
+
version: 1,
|
|
349
|
+
event: "report.message.available",
|
|
350
|
+
notification_id: notificationId,
|
|
351
|
+
report_reference: report.public_reference,
|
|
352
|
+
...(report.reporter_reference ? { recipient_reference: report.reporter_reference } : {}),
|
|
353
|
+
audience: "reporter",
|
|
354
|
+
message: input.body,
|
|
355
|
+
}), createdAt));
|
|
356
|
+
}
|
|
357
|
+
await db.batch(statements);
|
|
358
|
+
return { messageId, jobs, idempotentReplay: false };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
export interface DecisionOptions extends WorkflowDeliveryOptions {
|
|
363
|
+
allowedActionCodes: Set<string>;
|
|
364
|
+
actionEnabled: boolean;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export async function recordHumanDecision(
|
|
368
|
+
db: D1Database,
|
|
369
|
+
reportId: string,
|
|
370
|
+
session: OperatorSession,
|
|
371
|
+
input: DecisionInput,
|
|
372
|
+
options: DecisionOptions,
|
|
373
|
+
): Promise<{ decisionId: string; jobs: DeliveryJob[]; idempotentReplay: boolean }> {
|
|
374
|
+
const existing = await db.prepare(`
|
|
375
|
+
SELECT id FROM decisions WHERE report_id = ?1 AND idempotency_key = ?2 LIMIT 1
|
|
376
|
+
`).bind(reportId, input.idempotencyKey).first<{ id: string }>();
|
|
377
|
+
if (existing) return { decisionId: existing.id, jobs: [], idempotentReplay: true };
|
|
378
|
+
const createdAt = now();
|
|
379
|
+
await requireActiveClaim(db, reportId, session.actor.id, createdAt);
|
|
380
|
+
const report = await workflowReport(db, reportId);
|
|
381
|
+
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
382
|
+
if (!["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state)) {
|
|
383
|
+
throw new ApiError(409, "report_not_decidable", "The report is not awaiting a human decision.");
|
|
384
|
+
}
|
|
385
|
+
if (!report.policy_version_id) throw new ApiError(409, "policy_version_missing", "The report has no selected policy version.");
|
|
386
|
+
const policy = await db.prepare(`SELECT rules_json FROM policy_versions WHERE id = ?1 AND published_at IS NOT NULL LIMIT 1`)
|
|
387
|
+
.bind(report.policy_version_id).first<{ rules_json: string }>();
|
|
388
|
+
const policyCodes = new Set<unknown>();
|
|
389
|
+
try {
|
|
390
|
+
const rules: unknown = JSON.parse(policy?.rules_json ?? "[]");
|
|
391
|
+
if (Array.isArray(rules)) rules.forEach((rule) => {
|
|
392
|
+
if (rule && typeof rule === "object" && !Array.isArray(rule)) policyCodes.add((rule as Record<string, unknown>).code);
|
|
393
|
+
});
|
|
394
|
+
} catch {
|
|
395
|
+
throw new ApiError(409, "policy_version_invalid", "The selected policy version cannot be used.");
|
|
396
|
+
}
|
|
397
|
+
if (!policyCodes.has(input.policyCode)) throw new ApiError(400, "policy_code_not_found", "The selected policy code is not in the report policy version.");
|
|
398
|
+
const evidence = await db.prepare(`SELECT reference FROM evidence_references WHERE report_id = ?1`)
|
|
399
|
+
.bind(reportId).all<{ reference: string }>();
|
|
400
|
+
const availableEvidence = new Set(evidence.results.map((item) => item.reference));
|
|
401
|
+
if (input.evidenceReferences.some((reference) => !availableEvidence.has(reference))) {
|
|
402
|
+
throw new ApiError(400, "evidence_reference_not_found", "Every considered evidence reference must belong to this report.");
|
|
403
|
+
}
|
|
404
|
+
if (input.actionCode && (!options.actionEnabled || !options.allowedActionCodes.has(input.actionCode))) {
|
|
405
|
+
throw new ApiError(400, "action_not_allowed", "The requested application action is not allowlisted for this deployment.");
|
|
406
|
+
}
|
|
407
|
+
if (input.affectedUserNotice && !report.owner_reference) {
|
|
408
|
+
throw new ApiError(400, "affected_user_reference_missing", "The report target has no affected-user reference for this notice.");
|
|
409
|
+
}
|
|
410
|
+
const superseded = report.state === "human_review_requested"
|
|
411
|
+
? await db.prepare(`SELECT id FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 1`)
|
|
412
|
+
.bind(reportId).first<{ id: string }>()
|
|
413
|
+
: null;
|
|
414
|
+
|
|
415
|
+
const decisionId = crypto.randomUUID();
|
|
416
|
+
const reporterMessageId = crypto.randomUUID();
|
|
417
|
+
const reporterNoticeId = crypto.randomUUID();
|
|
418
|
+
const reporterNotificationId = crypto.randomUUID();
|
|
419
|
+
const actionId = input.actionCode ? crypto.randomUUID() : null;
|
|
420
|
+
const jobs: DeliveryJob[] = [];
|
|
421
|
+
const statements: D1PreparedStatement[] = [
|
|
422
|
+
db.prepare(`
|
|
423
|
+
INSERT INTO decisions (
|
|
424
|
+
id, report_id, decision_code, policy_version_id, policy_code, rationale,
|
|
425
|
+
evidence_references_json, maker_type, maker_id, queue_version_id, proposed_action_code, created_at, idempotency_key,
|
|
426
|
+
supersedes_decision_id
|
|
427
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'human', ?8, ?9, ?10, ?11, ?12, ?13)
|
|
428
|
+
`).bind(
|
|
429
|
+
decisionId, reportId, input.decisionCode, report.policy_version_id, input.policyCode, input.rationale,
|
|
430
|
+
canonicalJson(input.evidenceReferences), session.actor.id, report.queue_version_id, input.actionCode ?? null, createdAt, input.idempotencyKey,
|
|
431
|
+
superseded?.id ?? null,
|
|
432
|
+
),
|
|
433
|
+
db.prepare(`
|
|
434
|
+
INSERT INTO case_messages (
|
|
435
|
+
id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
|
|
436
|
+
automated, delivery_state, created_at, idempotency_key
|
|
437
|
+
) VALUES (?1, ?2, 'reporter', 'outbound', 'human', ?3, ?4, 'reporter-outcome-v1', 0, ?5, ?6, ?7)
|
|
438
|
+
`).bind(reporterMessageId, reportId, session.actor.id, input.reporterNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt, `decision:${decisionId}:reporter-message`),
|
|
439
|
+
db.prepare(`
|
|
440
|
+
INSERT INTO notices (
|
|
441
|
+
id, report_id, decision_id, audience, template_version_id, rendered_body,
|
|
442
|
+
delivery_state, created_at, updated_at, idempotency_key, message_id
|
|
443
|
+
) VALUES (?1, ?2, ?3, 'reporter', 'reporter-outcome-v1', ?4, ?5, ?6, ?6, ?7, ?8)
|
|
444
|
+
`).bind(reporterNoticeId, reportId, decisionId, input.reporterNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt, `decision:${decisionId}:reporter-notice`, reporterMessageId),
|
|
445
|
+
db.prepare(`
|
|
446
|
+
UPDATE reports SET state = 'resolved_by_human', resolved_at = ?2, updated_at = ?2 WHERE id = ?1
|
|
447
|
+
`).bind(reportId, createdAt),
|
|
448
|
+
db.prepare(`
|
|
449
|
+
INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
|
|
450
|
+
VALUES (?1, ?2, ?3, 'resolved_by_human', ?4, ?5, ?6, ?7)
|
|
451
|
+
`).bind(crypto.randomUUID(), reportId, report.state, input.decisionCode, session.actor.type, session.actor.id, createdAt),
|
|
452
|
+
db.prepare(`DELETE FROM review_claims WHERE report_id = ?1`).bind(reportId),
|
|
453
|
+
auditEvent(db, {
|
|
454
|
+
idempotencyKey: `decision:${reportId}:${input.idempotencyKey}`,
|
|
455
|
+
action: "report.human_decision_recorded",
|
|
456
|
+
actorType: session.actor.type,
|
|
457
|
+
actorId: session.actor.id,
|
|
458
|
+
targetType: "decision",
|
|
459
|
+
targetId: decisionId,
|
|
460
|
+
details: {
|
|
461
|
+
reportId, decisionCode: input.decisionCode, policyVersionId: report.policy_version_id,
|
|
462
|
+
policyCode: input.policyCode, queueVersionId: report.queue_version_id, evidenceReferences: input.evidenceReferences,
|
|
463
|
+
actionCode: input.actionCode ?? null,
|
|
464
|
+
},
|
|
465
|
+
createdAt,
|
|
466
|
+
}),
|
|
467
|
+
db.prepare(`
|
|
468
|
+
INSERT OR IGNORE INTO ai_quality_samples (id, ai_run_id, report_id, sample_reason, state, selected_at)
|
|
469
|
+
SELECT ?2, ar.id, ?1,
|
|
470
|
+
CASE ar.mode WHEN 'shadow' THEN 'shadow_human_comparison' ELSE 'assist_human_comparison' END,
|
|
471
|
+
'pending', ?3
|
|
472
|
+
FROM ai_runs ar
|
|
473
|
+
WHERE ar.report_id = ?1 AND ar.mode IN ('shadow', 'assist') AND ar.status = 'succeeded'
|
|
474
|
+
ORDER BY ar.started_at DESC, ar.id DESC LIMIT 1
|
|
475
|
+
`).bind(reportId, crypto.randomUUID(), createdAt),
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
if (options.notificationEnabled) {
|
|
479
|
+
statements.push(db.prepare(`
|
|
480
|
+
INSERT INTO notification_outbox (
|
|
481
|
+
id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
|
|
482
|
+
) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
|
|
483
|
+
`).bind(reporterNotificationId, reporterNoticeId, `notice:${reporterNoticeId}`, canonicalJson({
|
|
484
|
+
version: 1,
|
|
485
|
+
event: "report.outcome.available",
|
|
486
|
+
notification_id: reporterNotificationId,
|
|
487
|
+
report_reference: report.public_reference,
|
|
488
|
+
...(report.reporter_reference ? { recipient_reference: report.reporter_reference } : {}),
|
|
489
|
+
audience: "reporter",
|
|
490
|
+
message: input.reporterNotice,
|
|
491
|
+
}), createdAt));
|
|
492
|
+
jobs.push({ version: 1, jobId: `notification:${reporterNotificationId}`, type: "deliver_notification", outboxId: reporterNotificationId });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (input.affectedUserNotice && report.owner_reference) {
|
|
496
|
+
const affectedMessageId = crypto.randomUUID();
|
|
497
|
+
const affectedNoticeId = crypto.randomUUID();
|
|
498
|
+
const affectedNotificationId = crypto.randomUUID();
|
|
499
|
+
statements.push(
|
|
500
|
+
db.prepare(`
|
|
501
|
+
INSERT OR IGNORE INTO report_participants (id, report_id, audience, reference, contact_mode, created_at)
|
|
502
|
+
VALUES (?1, ?2, 'affected_user', ?3, 'customer_notification', ?4)
|
|
503
|
+
`).bind(crypto.randomUUID(), reportId, report.owner_reference, createdAt),
|
|
504
|
+
db.prepare(`
|
|
505
|
+
INSERT INTO case_messages (
|
|
506
|
+
id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
|
|
507
|
+
automated, delivery_state, created_at, idempotency_key
|
|
508
|
+
) VALUES (?1, ?2, 'affected_user', 'outbound', 'human', ?3, ?4, 'affected-user-notice-v1', 0, ?5, ?6, ?7)
|
|
509
|
+
`).bind(affectedMessageId, reportId, session.actor.id, input.affectedUserNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt, `decision:${decisionId}:affected-message`),
|
|
510
|
+
db.prepare(`
|
|
511
|
+
INSERT INTO notices (
|
|
512
|
+
id, report_id, decision_id, audience, template_version_id, rendered_body,
|
|
513
|
+
delivery_state, created_at, updated_at, idempotency_key, message_id
|
|
514
|
+
) VALUES (?1, ?2, ?3, 'affected_user', 'affected-user-notice-v1', ?4, ?5, ?6, ?6, ?7, ?8)
|
|
515
|
+
`).bind(affectedNoticeId, reportId, decisionId, input.affectedUserNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt, `decision:${decisionId}:affected-notice`, affectedMessageId),
|
|
516
|
+
);
|
|
517
|
+
if (options.notificationEnabled) {
|
|
518
|
+
statements.push(db.prepare(`
|
|
519
|
+
INSERT INTO notification_outbox (
|
|
520
|
+
id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
|
|
521
|
+
) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
|
|
522
|
+
`).bind(affectedNotificationId, affectedNoticeId, `notice:${affectedNoticeId}`, canonicalJson({
|
|
523
|
+
version: 1,
|
|
524
|
+
event: "affected_user.decision_notice.available",
|
|
525
|
+
notification_id: affectedNotificationId,
|
|
526
|
+
report_reference: report.public_reference,
|
|
527
|
+
recipient_reference: report.owner_reference,
|
|
528
|
+
audience: "affected_user",
|
|
529
|
+
message: input.affectedUserNotice,
|
|
530
|
+
}), createdAt));
|
|
531
|
+
jobs.push({ version: 1, jobId: `notification:${affectedNotificationId}`, type: "deliver_notification", outboxId: affectedNotificationId });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (input.actionCode && actionId) {
|
|
536
|
+
statements.push(db.prepare(`
|
|
537
|
+
INSERT INTO decision_action_outbox (
|
|
538
|
+
id, report_id, decision_id, idempotency_key, action_code, target_reference,
|
|
539
|
+
payload_json, status, next_attempt_at, created_at, updated_at
|
|
540
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?8, ?8)
|
|
541
|
+
`).bind(actionId, reportId, decisionId, `action:${decisionId}:${input.actionCode}`, input.actionCode, report.target_reference, canonicalJson({
|
|
542
|
+
version: 1,
|
|
543
|
+
action_id: actionId,
|
|
544
|
+
idempotency_key: `action:${decisionId}:${input.actionCode}`,
|
|
545
|
+
report_id: reportId,
|
|
546
|
+
report_reference: report.public_reference,
|
|
547
|
+
decision_id: decisionId,
|
|
548
|
+
action_code: input.actionCode,
|
|
549
|
+
target_reference: report.target_reference,
|
|
550
|
+
policy_version_id: report.policy_version_id,
|
|
551
|
+
policy_code: input.policyCode,
|
|
552
|
+
}), createdAt));
|
|
553
|
+
jobs.push({ version: 1, jobId: `action:${actionId}`, type: "deliver_action", outboxId: actionId });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
await db.batch(statements);
|
|
557
|
+
return { decisionId, jobs, idempotentReplay: false };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
export async function listAppeals(db: D1Database, limit: number, allowedQueueIds?: string[] | null): Promise<Array<Record<string, unknown>>> {
|
|
562
|
+
const result = await db.prepare(`
|
|
563
|
+
SELECT a.id, a.report_id AS reportId, r.public_reference AS reportReference,
|
|
564
|
+
a.decision_id AS decisionId, a.appellant_audience AS appellantAudience,
|
|
565
|
+
a.reason, a.state, a.submitted_at AS submittedAt,
|
|
566
|
+
c.reviewer_id AS reviewerId, c.expires_at AS claimExpiresAt
|
|
567
|
+
FROM appeals a
|
|
568
|
+
JOIN reports r ON r.id = a.report_id
|
|
569
|
+
LEFT JOIN appeal_claims c ON c.appeal_id = a.id
|
|
570
|
+
WHERE (?2 = 1 OR r.queue_id IN (SELECT value FROM json_each(?3)))
|
|
571
|
+
ORDER BY a.submitted_at ASC, a.id ASC
|
|
572
|
+
LIMIT ?1
|
|
573
|
+
`).bind(
|
|
574
|
+
Math.max(1, Math.min(100, limit)),
|
|
575
|
+
allowedQueueIds === null || allowedQueueIds === undefined ? 1 : 0,
|
|
576
|
+
canonicalJson(allowedQueueIds ?? []),
|
|
577
|
+
).all<Record<string, unknown>>();
|
|
578
|
+
return result.results;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function loadAppealDetail(db: D1Database, appealId: string): Promise<Record<string, unknown> | null> {
|
|
582
|
+
const appeal = await db.prepare(`
|
|
583
|
+
SELECT a.id, a.report_id AS reportId, r.public_reference AS reportReference,
|
|
584
|
+
a.decision_id AS decisionId, a.appellant_audience AS appellantAudience,
|
|
585
|
+
a.reason, a.state, a.queue_id AS queueId, a.submitted_at AS submittedAt,
|
|
586
|
+
a.resolved_at AS resolvedAt, c.reviewer_id AS reviewerId,
|
|
587
|
+
c.claimed_at AS claimedAt, c.expires_at AS claimExpiresAt, c.version AS claimVersion,
|
|
588
|
+
d.decision_code AS originalDecisionCode, d.policy_code AS originalPolicyCode,
|
|
589
|
+
d.rationale AS originalRationale, d.maker_type AS originalMakerType,
|
|
590
|
+
d.maker_id AS originalMakerId, d.created_at AS originalDecisionAt
|
|
591
|
+
FROM appeals a JOIN reports r ON r.id = a.report_id
|
|
592
|
+
JOIN decisions d ON d.id = a.decision_id
|
|
593
|
+
LEFT JOIN appeal_claims c ON c.appeal_id = a.id AND c.expires_at > ?2
|
|
594
|
+
WHERE a.id = ?1 LIMIT 1
|
|
595
|
+
`).bind(appealId, now()).first<Record<string, unknown>>();
|
|
596
|
+
if (!appeal) return null;
|
|
597
|
+
const decisions = await db.prepare(`
|
|
598
|
+
SELECT id, outcome, rationale, reviewer_id AS reviewerId,
|
|
599
|
+
corrective_action_id AS correctiveActionId, created_at AS createdAt
|
|
600
|
+
FROM appeal_decisions WHERE appeal_id = ?1 ORDER BY created_at, id
|
|
601
|
+
`).bind(appealId).all<Record<string, unknown>>();
|
|
602
|
+
return { ...appeal, decisions: decisions.results };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function releaseAppealClaim(
|
|
606
|
+
db: D1Database,
|
|
607
|
+
appealId: string,
|
|
608
|
+
session: OperatorSession,
|
|
609
|
+
): Promise<void> {
|
|
610
|
+
const claim = await db.prepare(`SELECT reviewer_id AS reviewerId, version FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
|
|
611
|
+
.bind(appealId).first<{ reviewerId: string; version: number }>();
|
|
612
|
+
if (!claim) return;
|
|
613
|
+
if (claim.reviewerId !== session.actor.id && !session.permissions.assignTasks) {
|
|
614
|
+
throw new ApiError(409, "appeal_claim_owned_by_another_reviewer", "Only the current reviewer or an assignment manager can release this claim.");
|
|
615
|
+
}
|
|
616
|
+
const createdAt = now();
|
|
617
|
+
await db.batch([
|
|
618
|
+
db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1`).bind(appealId),
|
|
619
|
+
db.prepare(`UPDATE appeals SET state = 'submitted' WHERE id = ?1 AND state = 'human_review'`).bind(appealId),
|
|
620
|
+
auditEvent(db, {
|
|
621
|
+
idempotencyKey: `appeal-claim-release:${appealId}:${claim.version}`,
|
|
622
|
+
action: "appeal.claim_released",
|
|
623
|
+
actorType: session.actor.type,
|
|
624
|
+
actorId: session.actor.id,
|
|
625
|
+
targetType: "appeal",
|
|
626
|
+
targetId: appealId,
|
|
627
|
+
details: { priorReviewerId: claim.reviewerId, version: claim.version },
|
|
628
|
+
createdAt,
|
|
629
|
+
}),
|
|
630
|
+
]);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export async function claimAppeal(
|
|
634
|
+
db: D1Database,
|
|
635
|
+
appealId: string,
|
|
636
|
+
session: OperatorSession,
|
|
637
|
+
claimTtlMinutes: number,
|
|
638
|
+
): Promise<{ reviewerId: string; claimedAt: string; expiresAt: string; version: number }> {
|
|
639
|
+
const appeal = await db.prepare(`
|
|
640
|
+
SELECT a.id, a.state, d.maker_type, d.maker_id
|
|
641
|
+
FROM appeals a JOIN decisions d ON d.id = a.decision_id
|
|
642
|
+
WHERE a.id = ?1 LIMIT 1
|
|
643
|
+
`).bind(appealId).first<{ id: string; state: string; maker_type: string; maker_id: string }>();
|
|
644
|
+
if (!appeal) throw new ApiError(404, "appeal_not_found", "The appeal does not exist.");
|
|
645
|
+
if (!["submitted", "human_review"].includes(appeal.state)) throw new ApiError(409, "appeal_not_claimable", "The appeal is not awaiting human review.");
|
|
646
|
+
if (appeal.maker_type === "human" && appeal.maker_id === session.actor.id) {
|
|
647
|
+
throw new ApiError(409, "independent_appeal_reviewer_required", "The original decision maker cannot claim this appeal.");
|
|
648
|
+
}
|
|
649
|
+
const claimedAt = now();
|
|
650
|
+
const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, claimTtlMinutes)));
|
|
651
|
+
await db.batch([
|
|
652
|
+
db.prepare(`
|
|
653
|
+
INSERT INTO appeal_claims (appeal_id, reviewer_id, claimed_at, expires_at, version)
|
|
654
|
+
VALUES (?1, ?2, ?3, ?4, 1)
|
|
655
|
+
ON CONFLICT(appeal_id) DO UPDATE SET reviewer_id = excluded.reviewer_id,
|
|
656
|
+
claimed_at = excluded.claimed_at, expires_at = excluded.expires_at,
|
|
657
|
+
version = appeal_claims.version + 1
|
|
658
|
+
WHERE appeal_claims.expires_at <= ?3 OR appeal_claims.reviewer_id = ?2
|
|
659
|
+
`).bind(appealId, session.actor.id, claimedAt, expiresAt),
|
|
660
|
+
db.prepare(`UPDATE appeals SET state = 'human_review' WHERE id = ?1 AND state = 'submitted'`).bind(appealId),
|
|
661
|
+
]);
|
|
662
|
+
const claim = await db.prepare(`SELECT reviewer_id, claimed_at, expires_at, version FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
|
|
663
|
+
.bind(appealId).first<{ reviewer_id: string; claimed_at: string; expires_at: string; version: number }>();
|
|
664
|
+
if (!claim || claim.reviewer_id !== session.actor.id) throw new ApiError(409, "appeal_already_claimed", "Another appeal reviewer currently holds this appeal.");
|
|
665
|
+
return { reviewerId: claim.reviewer_id, claimedAt: claim.claimed_at, expiresAt: claim.expires_at, version: claim.version };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export async function decideAppeal(
|
|
669
|
+
db: D1Database,
|
|
670
|
+
appealId: string,
|
|
671
|
+
session: OperatorSession,
|
|
672
|
+
input: AppealDecisionInput,
|
|
673
|
+
options: DecisionOptions,
|
|
674
|
+
): Promise<{ appealDecisionId: string; jobs: DeliveryJob[]; idempotentReplay: boolean }> {
|
|
675
|
+
const existing = await db.prepare(`
|
|
676
|
+
SELECT id FROM appeal_decisions WHERE appeal_id = ?1 AND idempotency_key = ?2 LIMIT 1
|
|
677
|
+
`).bind(appealId, input.idempotencyKey).first<{ id: string }>();
|
|
678
|
+
if (existing) return { appealDecisionId: existing.id, jobs: [], idempotentReplay: true };
|
|
679
|
+
const createdAt = now();
|
|
680
|
+
const appeal = await db.prepare(`
|
|
681
|
+
SELECT a.id, a.report_id, a.decision_id, a.appellant_audience, a.state, a.submitted_at,
|
|
682
|
+
d.maker_id AS original_maker_id, r.public_reference, r.state AS report_state,
|
|
683
|
+
r.policy_version_id, t.target_reference,
|
|
684
|
+
p.reference AS participant_reference
|
|
685
|
+
FROM appeals a
|
|
686
|
+
JOIN decisions d ON d.id = a.decision_id
|
|
687
|
+
JOIN reports r ON r.id = a.report_id
|
|
688
|
+
JOIN report_targets t ON t.report_id = r.id
|
|
689
|
+
JOIN report_participants p ON p.report_id = r.id AND p.audience = a.appellant_audience
|
|
690
|
+
WHERE a.id = ?1
|
|
691
|
+
LIMIT 1
|
|
692
|
+
`).bind(appealId).first<{
|
|
693
|
+
id: string;
|
|
694
|
+
report_id: string;
|
|
695
|
+
decision_id: string;
|
|
696
|
+
appellant_audience: ParticipantAudience;
|
|
697
|
+
state: string;
|
|
698
|
+
submitted_at: string;
|
|
699
|
+
original_maker_id: string;
|
|
700
|
+
public_reference: string;
|
|
701
|
+
report_state: ReportState;
|
|
702
|
+
policy_version_id: string | null;
|
|
703
|
+
target_reference: string;
|
|
704
|
+
participant_reference: string | null;
|
|
705
|
+
}>();
|
|
706
|
+
if (!appeal) throw new ApiError(404, "appeal_not_found", "The appeal does not exist.");
|
|
707
|
+
if (appeal.state !== "human_review") throw new ApiError(409, "appeal_not_decidable", "The appeal is not awaiting a human decision.");
|
|
708
|
+
if (appeal.original_maker_id === session.actor.id) {
|
|
709
|
+
throw new ApiError(409, "independent_appeal_reviewer_required", "The original decision maker cannot decide this appeal.");
|
|
710
|
+
}
|
|
711
|
+
const claim = await db.prepare(`SELECT reviewer_id, expires_at FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
|
|
712
|
+
.bind(appealId).first<{ reviewer_id: string; expires_at: string }>();
|
|
713
|
+
if (!claim || claim.reviewer_id !== session.actor.id || claim.expires_at <= createdAt) {
|
|
714
|
+
throw new ApiError(409, "active_appeal_claim_required", "Claim this appeal before deciding it.");
|
|
715
|
+
}
|
|
716
|
+
if (input.correctiveActionCode && (!options.actionEnabled || !options.allowedActionCodes.has(input.correctiveActionCode))) {
|
|
717
|
+
throw new ApiError(400, "action_not_allowed", "The corrective application action is not allowlisted for this deployment.");
|
|
718
|
+
}
|
|
719
|
+
const appealDecisionId = crypto.randomUUID();
|
|
720
|
+
const messageId = crypto.randomUUID();
|
|
721
|
+
const noticeId = crypto.randomUUID();
|
|
722
|
+
const notificationId = crypto.randomUUID();
|
|
723
|
+
const actionId = input.correctiveActionCode ? crypto.randomUUID() : null;
|
|
724
|
+
const templateId = appeal.appellant_audience === "affected_user" ? "appeal-outcome-v1" : "reporter-outcome-v1";
|
|
725
|
+
const jobs: DeliveryJob[] = [];
|
|
726
|
+
const statements: D1PreparedStatement[] = [
|
|
727
|
+
db.prepare(`
|
|
728
|
+
INSERT INTO appeal_decisions (
|
|
729
|
+
id, appeal_id, outcome, rationale, reviewer_id, corrective_action_id, created_at, idempotency_key
|
|
730
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
731
|
+
`).bind(appealDecisionId, appealId, input.outcome, input.rationale, session.actor.id, actionId, createdAt, input.idempotencyKey),
|
|
732
|
+
db.prepare(`UPDATE appeals SET state = ?2, resolved_at = ?3 WHERE id = ?1`).bind(appealId, input.outcome, createdAt),
|
|
733
|
+
db.prepare(`UPDATE reports SET state = 'closed', queue_id = NULL, updated_at = ?2 WHERE id = ?1`)
|
|
734
|
+
.bind(appeal.report_id, createdAt),
|
|
735
|
+
db.prepare(`
|
|
736
|
+
INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
|
|
737
|
+
VALUES (?1, ?2, ?3, 'closed', ?4, ?5, ?6, ?7)
|
|
738
|
+
`).bind(crypto.randomUUID(), appeal.report_id, appeal.report_state, `appeal_${input.outcome}`, session.actor.type, session.actor.id, createdAt),
|
|
739
|
+
db.prepare(`
|
|
740
|
+
INSERT INTO case_messages (
|
|
741
|
+
id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
|
|
742
|
+
automated, delivery_state, created_at, idempotency_key
|
|
743
|
+
) VALUES (?1, ?2, ?3, 'outbound', 'human', ?4, ?5, ?6, 0, ?7, ?8, ?9)
|
|
744
|
+
`).bind(
|
|
745
|
+
messageId, appeal.report_id, appeal.appellant_audience, session.actor.id, input.participantNotice,
|
|
746
|
+
templateId, options.notificationEnabled ? "queued" : "suppressed", createdAt,
|
|
747
|
+
`appeal-decision:${appealDecisionId}:message`,
|
|
748
|
+
),
|
|
749
|
+
db.prepare(`
|
|
750
|
+
INSERT INTO notices (
|
|
751
|
+
id, report_id, decision_id, audience, template_version_id, rendered_body,
|
|
752
|
+
delivery_state, created_at, updated_at, idempotency_key, message_id
|
|
753
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?9, ?10)
|
|
754
|
+
`).bind(
|
|
755
|
+
noticeId, appeal.report_id, appeal.decision_id, appeal.appellant_audience, templateId,
|
|
756
|
+
input.participantNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt,
|
|
757
|
+
`appeal-decision:${appealDecisionId}:notice`, messageId,
|
|
758
|
+
),
|
|
759
|
+
db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1`).bind(appealId),
|
|
760
|
+
auditEvent(db, {
|
|
761
|
+
idempotencyKey: `appeal-decision:${appealId}:${input.idempotencyKey}`,
|
|
762
|
+
action: "appeal.human_decision_recorded",
|
|
763
|
+
actorType: session.actor.type,
|
|
764
|
+
actorId: session.actor.id,
|
|
765
|
+
targetType: "appeal_decision",
|
|
766
|
+
targetId: appealDecisionId,
|
|
767
|
+
details: {
|
|
768
|
+
appealId,
|
|
769
|
+
reportId: appeal.report_id,
|
|
770
|
+
outcome: input.outcome,
|
|
771
|
+
correctiveActionCode: input.correctiveActionCode ?? null,
|
|
772
|
+
},
|
|
773
|
+
createdAt,
|
|
774
|
+
}),
|
|
775
|
+
];
|
|
776
|
+
if (input.outcome === "reversed") {
|
|
777
|
+
statements.push(db.prepare(`
|
|
778
|
+
INSERT INTO ai_usage_daily (usage_date, config_version_id, appeal_reversals, updated_at)
|
|
779
|
+
SELECT substr(?2, 1, 10), ar.config_version_id, 1, ?2
|
|
780
|
+
FROM decisions d JOIN ai_runs ar ON ar.id = d.ai_run_id
|
|
781
|
+
WHERE d.id = ?1
|
|
782
|
+
ON CONFLICT(usage_date, config_version_id) DO UPDATE SET
|
|
783
|
+
appeal_reversals = appeal_reversals + 1, updated_at = excluded.updated_at
|
|
784
|
+
`).bind(appeal.decision_id, createdAt));
|
|
785
|
+
}
|
|
786
|
+
if (options.notificationEnabled) {
|
|
787
|
+
statements.push(db.prepare(`
|
|
788
|
+
INSERT INTO notification_outbox (
|
|
789
|
+
id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
|
|
790
|
+
) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
|
|
791
|
+
`).bind(notificationId, noticeId, `notice:${noticeId}`, canonicalJson({
|
|
792
|
+
version: 1,
|
|
793
|
+
event: "appeal.outcome.available",
|
|
794
|
+
notification_id: notificationId,
|
|
795
|
+
report_reference: appeal.public_reference,
|
|
796
|
+
...(appeal.participant_reference ? { recipient_reference: appeal.participant_reference } : {}),
|
|
797
|
+
audience: appeal.appellant_audience,
|
|
798
|
+
message: input.participantNotice,
|
|
799
|
+
}), createdAt));
|
|
800
|
+
jobs.push({ version: 1, jobId: `notification:${notificationId}`, type: "deliver_notification", outboxId: notificationId });
|
|
801
|
+
}
|
|
802
|
+
if (input.correctiveActionCode && actionId) {
|
|
803
|
+
statements.push(db.prepare(`
|
|
804
|
+
INSERT INTO decision_action_outbox (
|
|
805
|
+
id, report_id, decision_id, idempotency_key, action_code, target_reference,
|
|
806
|
+
payload_json, status, next_attempt_at, created_at, updated_at
|
|
807
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?8, ?8)
|
|
808
|
+
`).bind(
|
|
809
|
+
actionId, appeal.report_id, appeal.decision_id,
|
|
810
|
+
`appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,
|
|
811
|
+
input.correctiveActionCode, appeal.target_reference,
|
|
812
|
+
canonicalJson({
|
|
813
|
+
version: 1,
|
|
814
|
+
action_id: actionId,
|
|
815
|
+
idempotency_key: `appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,
|
|
816
|
+
report_id: appeal.report_id,
|
|
817
|
+
report_reference: appeal.public_reference,
|
|
818
|
+
original_decision_id: appeal.decision_id,
|
|
819
|
+
appeal_id: appealId,
|
|
820
|
+
appeal_decision_id: appealDecisionId,
|
|
821
|
+
action_code: input.correctiveActionCode,
|
|
822
|
+
target_reference: appeal.target_reference,
|
|
823
|
+
policy_version_id: appeal.policy_version_id,
|
|
824
|
+
}),
|
|
825
|
+
createdAt,
|
|
826
|
+
));
|
|
827
|
+
jobs.push({ version: 1, jobId: `action:${actionId}`, type: "deliver_action", outboxId: actionId });
|
|
828
|
+
}
|
|
829
|
+
await db.batch(statements);
|
|
830
|
+
return { appealDecisionId, jobs, idempotentReplay: false };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export async function chainPendingAuditEvents(db: D1Database, limit = 50): Promise<void> {
|
|
834
|
+
const pending = await db.prepare(`
|
|
835
|
+
SELECT id, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|
|
836
|
+
FROM audit_events
|
|
837
|
+
WHERE chained_at IS NULL
|
|
838
|
+
ORDER BY created_at ASC, id ASC
|
|
839
|
+
LIMIT ?1
|
|
840
|
+
`).bind(Math.max(1, Math.min(100, limit))).all<{
|
|
841
|
+
id: string;
|
|
842
|
+
action: string;
|
|
843
|
+
actor_type: string;
|
|
844
|
+
actor_id: string;
|
|
845
|
+
target_type: string;
|
|
846
|
+
target_id: string;
|
|
847
|
+
details_json: string;
|
|
848
|
+
created_at: string;
|
|
849
|
+
}>();
|
|
850
|
+
for (const event of pending.results) {
|
|
851
|
+
const tip = await db.prepare(`SELECT sequence, entry_hash FROM audit_entries ORDER BY sequence DESC LIMIT 1`)
|
|
852
|
+
.first<{ sequence: number; entry_hash: string }>() ?? { sequence: 0, entry_hash: AUDIT_GENESIS_HASH };
|
|
853
|
+
const sequence = tip.sequence + 1;
|
|
854
|
+
const entryHash = await hashAuditEntry({
|
|
855
|
+
sequence,
|
|
856
|
+
action: event.action,
|
|
857
|
+
actorType: event.actor_type,
|
|
858
|
+
actorId: event.actor_id,
|
|
859
|
+
targetType: event.target_type,
|
|
860
|
+
targetId: event.target_id,
|
|
861
|
+
detailsJson: event.details_json,
|
|
862
|
+
previousHash: tip.entry_hash,
|
|
863
|
+
createdAt: event.created_at,
|
|
864
|
+
});
|
|
865
|
+
try {
|
|
866
|
+
await db.batch([
|
|
867
|
+
db.prepare(`
|
|
868
|
+
INSERT INTO audit_entries (
|
|
869
|
+
sequence, id, action, actor_type, actor_id, target_type, target_id,
|
|
870
|
+
details_json, previous_hash, entry_hash, created_at
|
|
871
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
|
872
|
+
`).bind(
|
|
873
|
+
sequence, event.id, event.action, event.actor_type, event.actor_id, event.target_type,
|
|
874
|
+
event.target_id, event.details_json, tip.entry_hash, entryHash, event.created_at,
|
|
875
|
+
),
|
|
876
|
+
db.prepare(`UPDATE audit_events SET chained_at = ?2, chain_error_code = NULL WHERE id = ?1 AND chained_at IS NULL`)
|
|
877
|
+
.bind(event.id, now()),
|
|
878
|
+
]);
|
|
879
|
+
} catch (error) {
|
|
880
|
+
await db.prepare(`UPDATE audit_events SET chain_error_code = 'chain_contention' WHERE id = ?1 AND chained_at IS NULL`)
|
|
881
|
+
.bind(event.id).run();
|
|
882
|
+
console.warn("audit event chaining will retry", { eventId: event.id, errorType: error instanceof Error ? error.name : typeof error });
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|