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,972 @@
|
|
|
1
|
+
import { AUDIT_GENESIS_HASH, canonicalJson, hashAuditEntry } from "./audit";
|
|
2
|
+
import { aiConfigurationForRouting } from "./report-ai";
|
|
3
|
+
import { ApiError } from "./report-http";
|
|
4
|
+
import { encryptReportContact, randomPublicReference, sha256 } from "./report-crypto";
|
|
5
|
+
import { analystAvatarColor } from "./report-presence";
|
|
6
|
+
import { activeRoutingAgent, executeRoutingAgent, type RoutingAiBinding } from "./report-router-agent";
|
|
7
|
+
import { evaluateHardSafeguards, queueMatches, selectRoute, type QueueRoutingPolicy, type RoutingInput } from "./report-routing";
|
|
8
|
+
import type { QueueManagementMode, QueueRolloutMode, RoutingCondition } from "./report-queue-validation";
|
|
9
|
+
import type { AiJob, DeliveryJob, InboxReport, IntakeReceipt, ReportSubmission, ReportState } from "./report-types";
|
|
10
|
+
import type { WorkflowDispatchJob } from "./report-types";
|
|
11
|
+
import { preparePinnedWorkflowRunsForRouting } from "./workflow-runs";
|
|
12
|
+
import { planIntakeProtection } from "./intake-abuse";
|
|
13
|
+
import { validateAnswersAgainstFormFields } from "./report-form-validation";
|
|
14
|
+
import type { JsonObject, JsonValue } from "./workflow-platform-types";
|
|
15
|
+
|
|
16
|
+
export interface IntegrationRow {
|
|
17
|
+
id: string;
|
|
18
|
+
key_id: string;
|
|
19
|
+
allowed_origins_json: string;
|
|
20
|
+
enabled: number;
|
|
21
|
+
channel_mode: "contextual" | "anonymous";
|
|
22
|
+
form_version_id: string | null;
|
|
23
|
+
context_schema_json: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FormRow {
|
|
27
|
+
id: string;
|
|
28
|
+
target_types_json: string;
|
|
29
|
+
reasons_json: string;
|
|
30
|
+
fields_json: string;
|
|
31
|
+
consent_notice: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ExistingReportRow {
|
|
35
|
+
id: string;
|
|
36
|
+
public_reference: string;
|
|
37
|
+
state: ReportState;
|
|
38
|
+
received_at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface AuditTipRow {
|
|
42
|
+
sequence: number;
|
|
43
|
+
entry_hash: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface InboxReportRow {
|
|
47
|
+
id: string;
|
|
48
|
+
reference: string;
|
|
49
|
+
state: ReportState;
|
|
50
|
+
priority: number;
|
|
51
|
+
reasonCode: string;
|
|
52
|
+
queueId: string | null;
|
|
53
|
+
targetType: string;
|
|
54
|
+
targetReference: string;
|
|
55
|
+
submittedAt: string;
|
|
56
|
+
receivedAt: string;
|
|
57
|
+
updatedAt: string;
|
|
58
|
+
dueAt: string | null;
|
|
59
|
+
claimReviewerId: string | null;
|
|
60
|
+
claimedAt: string | null;
|
|
61
|
+
claimExpiresAt: string | null;
|
|
62
|
+
analystDisplayName: string | null;
|
|
63
|
+
analystAvatarUrl: string | null;
|
|
64
|
+
analystAvatarColor: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface RoutingJobRow {
|
|
68
|
+
id: string;
|
|
69
|
+
report_id: string;
|
|
70
|
+
state: "pending" | "enqueued" | "processing" | "complete" | "failed" | "dead_letter";
|
|
71
|
+
reason_code: string;
|
|
72
|
+
trusted_facts_json: string;
|
|
73
|
+
report_state: ReportState;
|
|
74
|
+
source: string;
|
|
75
|
+
form_version_id: string;
|
|
76
|
+
target_type: string;
|
|
77
|
+
target_reference: string;
|
|
78
|
+
owner_reference: string | null;
|
|
79
|
+
customer_url: string | null;
|
|
80
|
+
public_reference: string;
|
|
81
|
+
installation_id: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface QueueRoutingRow {
|
|
85
|
+
queueId: string;
|
|
86
|
+
queueVersionId: string;
|
|
87
|
+
managementMode: QueueManagementMode;
|
|
88
|
+
rolloutMode: QueueRolloutMode;
|
|
89
|
+
priority: number;
|
|
90
|
+
responseSlaMinutes: number | null;
|
|
91
|
+
fallbackQueueId: string | null;
|
|
92
|
+
position: number;
|
|
93
|
+
aiBoundsJson: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface PublishedRoutingRule {
|
|
97
|
+
id: string;
|
|
98
|
+
queueId: string;
|
|
99
|
+
priority: number;
|
|
100
|
+
routingCriteria: { match: "all" | "any"; conditions: RoutingCondition[] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface PublishedRoutingRuleSet {
|
|
104
|
+
id: string;
|
|
105
|
+
defaultQueueId: string;
|
|
106
|
+
safeguardQueueId: string;
|
|
107
|
+
intakeEnrichments: Array<{ componentVersionId: string; inputMapping: Record<string, JsonValue> }>;
|
|
108
|
+
rules: PublishedRoutingRule[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface RoutingIntakeEnrichmentInput {
|
|
112
|
+
reportId: string;
|
|
113
|
+
componentVersionId: string;
|
|
114
|
+
inputMapping: Record<string, JsonValue>;
|
|
115
|
+
context: JsonObject;
|
|
116
|
+
operationId: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type RoutingIntakeEnrichmentExecutor = (input: RoutingIntakeEnrichmentInput) => Promise<{
|
|
120
|
+
componentKey: string;
|
|
121
|
+
data: JsonObject;
|
|
122
|
+
}>;
|
|
123
|
+
|
|
124
|
+
function isoNow(): string {
|
|
125
|
+
return new Date().toISOString();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function addMinutes(timestamp: string, minutes: number): string {
|
|
129
|
+
return new Date(Date.parse(timestamp) + minutes * 60_000).toISOString();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseJsonArray(value: string): unknown[] {
|
|
133
|
+
try {
|
|
134
|
+
const parsed: unknown = JSON.parse(value);
|
|
135
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
136
|
+
} catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function d1Message(error: unknown): string {
|
|
142
|
+
return error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isConstraintError(error: unknown): boolean {
|
|
146
|
+
const message = d1Message(error);
|
|
147
|
+
return message.includes("constraint") || message.includes("unique") || message.includes("foreign key");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function receipt(row: ExistingReportRow, idempotentReplay: boolean): IntakeReceipt {
|
|
151
|
+
return {
|
|
152
|
+
reportId: row.id,
|
|
153
|
+
reference: row.public_reference,
|
|
154
|
+
state: row.state,
|
|
155
|
+
receivedAt: row.received_at,
|
|
156
|
+
idempotentReplay,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function loadIntegration(db: D1Database, integrationId: string): Promise<IntegrationRow | null> {
|
|
161
|
+
return db.prepare(`
|
|
162
|
+
SELECT id, key_id, allowed_origins_json, enabled,
|
|
163
|
+
channel_mode, form_version_id, context_schema_json
|
|
164
|
+
FROM application_integrations
|
|
165
|
+
WHERE id = ?1
|
|
166
|
+
LIMIT 1
|
|
167
|
+
`).bind(integrationId).first<IntegrationRow>();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function integrationAllowsOrigin(integration: IntegrationRow, origin: string): boolean {
|
|
171
|
+
return parseJsonArray(integration.allowed_origins_json).some((value) => value === origin);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function installationAllowsWidgetOrigin(db: D1Database, origin: string): Promise<boolean> {
|
|
175
|
+
const rows = await db.prepare(`SELECT allowed_origins_json AS originsJson FROM application_integrations WHERE installation_id = 'default' AND enabled = 1`)
|
|
176
|
+
.all<{ originsJson: string }>();
|
|
177
|
+
return rows.results.some((row) => parseJsonArray(row.originsJson).some((value) => value === origin));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function loadExisting(db: D1Database, integrationId: string, idempotencyKey: string): Promise<ExistingReportRow | null> {
|
|
181
|
+
return db.prepare(`
|
|
182
|
+
SELECT id, public_reference, state, received_at
|
|
183
|
+
FROM reports
|
|
184
|
+
WHERE integration_id = ?1 AND idempotency_key = ?2
|
|
185
|
+
LIMIT 1
|
|
186
|
+
`).bind(integrationId, idempotencyKey).first<ExistingReportRow>();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function loadContextReportReplay(
|
|
190
|
+
db: D1Database,
|
|
191
|
+
integrationId: string,
|
|
192
|
+
contextJti: string,
|
|
193
|
+
idempotencyKey: string,
|
|
194
|
+
): Promise<IntakeReceipt | null> {
|
|
195
|
+
const row = await db.prepare(`
|
|
196
|
+
SELECT r.id, r.public_reference, r.state, r.received_at
|
|
197
|
+
FROM context_token_uses u
|
|
198
|
+
JOIN reports r ON r.id = u.report_id
|
|
199
|
+
WHERE u.jti = ?1 AND u.integration_id = ?2
|
|
200
|
+
AND r.integration_id = ?2 AND r.idempotency_key = ?3
|
|
201
|
+
LIMIT 1
|
|
202
|
+
`).bind(contextJti, integrationId, idempotencyKey).first<ExistingReportRow>();
|
|
203
|
+
if (!row) return null;
|
|
204
|
+
return receipt(row, true);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function loadForm(db: D1Database, formVersionId: string): Promise<FormRow | null> {
|
|
208
|
+
return db.prepare(`
|
|
209
|
+
SELECT id, target_types_json, reasons_json, fields_json, consent_notice
|
|
210
|
+
FROM report_form_versions
|
|
211
|
+
WHERE id = ?1 AND published_at IS NOT NULL AND retired_at IS NULL
|
|
212
|
+
LIMIT 1
|
|
213
|
+
`).bind(formVersionId).first<FormRow>();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validateAgainstForm(submission: ReportSubmission, form: FormRow): void {
|
|
217
|
+
const targets = new Set(parseJsonArray(form.target_types_json).filter((value): value is string => typeof value === "string"));
|
|
218
|
+
if (!targets.has(submission.target.type)) {
|
|
219
|
+
throw new ApiError(400, "target_type_not_allowed", "The selected form does not accept this target type.", { field: "target.type" });
|
|
220
|
+
}
|
|
221
|
+
const reasons = new Set(parseJsonArray(form.reasons_json)
|
|
222
|
+
.map((value) => value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>).code : null)
|
|
223
|
+
.filter((value): value is string => typeof value === "string"));
|
|
224
|
+
if (!reasons.has(submission.reasonCode)) {
|
|
225
|
+
throw new ApiError(400, "reason_not_allowed", "The selected form does not accept this reason.", { field: "reason_code" });
|
|
226
|
+
}
|
|
227
|
+
if (submission.consentNotice !== form.consent_notice) {
|
|
228
|
+
throw new ApiError(409, "form_notice_changed", "The form notice changed. Reload the form before submitting.");
|
|
229
|
+
}
|
|
230
|
+
validateAnswersAgainstFormFields(submission.answers, parseJsonArray(form.fields_json));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function auditTip(db: D1Database): Promise<AuditTipRow> {
|
|
234
|
+
return await db.prepare(`SELECT sequence, entry_hash FROM audit_entries ORDER BY sequence DESC LIMIT 1`).first<AuditTipRow>()
|
|
235
|
+
?? { sequence: 0, entry_hash: AUDIT_GENESIS_HASH };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface CreateReportOptions {
|
|
239
|
+
contactEncryptionKey: string;
|
|
240
|
+
emailEnabled: boolean;
|
|
241
|
+
notificationEnabled: boolean;
|
|
242
|
+
abusePepper: string;
|
|
243
|
+
networkAddress?: string | null;
|
|
244
|
+
turnstileOutcome?: string | null;
|
|
245
|
+
surgeThresholdPerFiveMinutes: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function createReport(
|
|
249
|
+
db: D1Database,
|
|
250
|
+
submission: ReportSubmission,
|
|
251
|
+
options: CreateReportOptions,
|
|
252
|
+
): Promise<{ receipt: IntakeReceipt; jobId: string; deliveryJobs: DeliveryJob[]; created: boolean; route: boolean }> {
|
|
253
|
+
if (submission.source === "public_page" && submission.reporter.email && !options.emailEnabled && !options.notificationEnabled) {
|
|
254
|
+
throw new ApiError(503, "reporter_notifications_unavailable", "Anonymous reporting is temporarily unavailable because its secure email notification channel is not configured.");
|
|
255
|
+
}
|
|
256
|
+
const integration = await loadIntegration(db, submission.integrationId);
|
|
257
|
+
if (!integration || integration.enabled !== 1) throw new ApiError(401, "integration_not_available", "The application integration is not enabled.");
|
|
258
|
+
const existing = await loadExisting(db, submission.integrationId, submission.idempotencyKey);
|
|
259
|
+
if (existing) {
|
|
260
|
+
return { receipt: receipt(existing, true), jobId: `route:${existing.id}`, deliveryJobs: [], created: false, route: false };
|
|
261
|
+
}
|
|
262
|
+
const form = await loadForm(db, submission.formVersionId);
|
|
263
|
+
if (!form) throw new ApiError(400, "form_not_available", "The selected report form is not available.");
|
|
264
|
+
validateAgainstForm(submission, form);
|
|
265
|
+
|
|
266
|
+
const intakePlan = await planIntakeProtection(db, submission, {
|
|
267
|
+
pepper: options.abusePepper,
|
|
268
|
+
networkAddress: options.networkAddress ?? null,
|
|
269
|
+
turnstileOutcome: options.turnstileOutcome ?? null,
|
|
270
|
+
surgeThresholdPerFiveMinutes: options.surgeThresholdPerFiveMinutes,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const reportId = crypto.randomUUID();
|
|
274
|
+
const publicReference = randomPublicReference();
|
|
275
|
+
const receivedAt = isoNow();
|
|
276
|
+
const jobId = `route:${reportId}`;
|
|
277
|
+
const reporterParticipantId = crypto.randomUUID();
|
|
278
|
+
const contactCiphertext = submission.reporter.email
|
|
279
|
+
? await encryptReportContact(options.contactEncryptionKey, submission.reporter.email, reportId, "reporter")
|
|
280
|
+
: null;
|
|
281
|
+
const receiptMessageId = crypto.randomUUID();
|
|
282
|
+
const receiptNoticeId = crypto.randomUUID();
|
|
283
|
+
const receiptDeliveryId = crypto.randomUUID();
|
|
284
|
+
const receiptBody = `We received report ${publicReference}. The safety team will send updates through your configured notification channel.`;
|
|
285
|
+
const directEmail = Boolean(submission.reporter.email && options.emailEnabled);
|
|
286
|
+
const webhookNotification = Boolean(!directEmail && options.notificationEnabled
|
|
287
|
+
&& (submission.reporter.email || submission.reporter.reference));
|
|
288
|
+
const deliveryJobs: DeliveryJob[] = directEmail
|
|
289
|
+
? [{ version: 1, jobId: `email:${receiptDeliveryId}`, type: "deliver_email", outboxId: receiptDeliveryId }]
|
|
290
|
+
: webhookNotification
|
|
291
|
+
? [{ version: 1, jobId: `notification:${receiptDeliveryId}`, type: "deliver_notification", outboxId: receiptDeliveryId }]
|
|
292
|
+
: [];
|
|
293
|
+
const receiptDeliveryState = deliveryJobs.length ? "queued" : "suppressed";
|
|
294
|
+
const detailsJson = canonicalJson({
|
|
295
|
+
source: submission.source,
|
|
296
|
+
formVersionId: submission.formVersionId,
|
|
297
|
+
reasonCode: submission.reasonCode,
|
|
298
|
+
targetType: submission.target.type,
|
|
299
|
+
evidenceMode: submission.evidence.mode,
|
|
300
|
+
intakeDecision: intakePlan.disposition,
|
|
301
|
+
duplicateGroupId: intakePlan.duplicateGroupId,
|
|
302
|
+
});
|
|
303
|
+
const auditAction = intakePlan.disposition === "quarantine"
|
|
304
|
+
? "report.quarantined" : intakePlan.disposition === "group" ? "report.grouped" : "report.accepted";
|
|
305
|
+
|
|
306
|
+
for (let auditAttempt = 0; auditAttempt < 3; auditAttempt += 1) {
|
|
307
|
+
const tip = await auditTip(db);
|
|
308
|
+
const auditSequence = tip.sequence + 1;
|
|
309
|
+
const auditId = crypto.randomUUID();
|
|
310
|
+
const auditHash = await hashAuditEntry({
|
|
311
|
+
sequence: auditSequence,
|
|
312
|
+
action: auditAction,
|
|
313
|
+
actorType: "integration",
|
|
314
|
+
actorId: submission.integrationId,
|
|
315
|
+
targetType: "report",
|
|
316
|
+
targetId: reportId,
|
|
317
|
+
detailsJson,
|
|
318
|
+
previousHash: tip.entry_hash,
|
|
319
|
+
createdAt: receivedAt,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const statements: D1PreparedStatement[] = [
|
|
323
|
+
db.prepare(`
|
|
324
|
+
INSERT INTO duplicate_groups (
|
|
325
|
+
id, installation_id, fingerprint_version, fingerprint,
|
|
326
|
+
target_type, target_reference_hash, reason_family, state,
|
|
327
|
+
canonical_report_id, member_count, first_seen_at, last_seen_at,
|
|
328
|
+
created_at, updated_at
|
|
329
|
+
) VALUES (?1, 'default', 'v1', ?2, ?3, ?4, ?5, 'active', NULL, 0, ?6, ?6, ?6, ?6)
|
|
330
|
+
ON CONFLICT(installation_id, fingerprint_version, fingerprint) DO UPDATE SET
|
|
331
|
+
last_seen_at = excluded.last_seen_at, updated_at = excluded.updated_at
|
|
332
|
+
`).bind(
|
|
333
|
+
intakePlan.duplicateGroupId, intakePlan.campaignFingerprint,
|
|
334
|
+
submission.target.type, intakePlan.targetKeyHash, submission.reasonCode, receivedAt,
|
|
335
|
+
),
|
|
336
|
+
db.prepare(`
|
|
337
|
+
INSERT INTO reports (
|
|
338
|
+
id, public_reference, installation_id, integration_id, idempotency_key, source,
|
|
339
|
+
form_version_id, policy_version_id, state, priority, reason_code, locale,
|
|
340
|
+
consent_notice, consented_at, submitted_at, received_at, updated_at, routing_status,
|
|
341
|
+
duplicate_group_id, quarantine_reason_code
|
|
342
|
+
) VALUES (?1, ?2, 'default', ?3, ?4, ?5, ?6, 'general-policy-v1', 'received', 50, ?7, ?8, ?9, ?10, ?11, ?12, ?12, ?13, ?14, ?15)
|
|
343
|
+
`).bind(
|
|
344
|
+
reportId, publicReference, submission.integrationId, submission.idempotencyKey, submission.source,
|
|
345
|
+
submission.formVersionId, submission.reasonCode, submission.locale, submission.consentNotice,
|
|
346
|
+
submission.consentedAt, submission.submittedAt, receivedAt,
|
|
347
|
+
intakePlan.route ? "pending" : "complete", intakePlan.duplicateGroupId,
|
|
348
|
+
intakePlan.disposition === "quarantine" ? intakePlan.reasonCodes[0] : null,
|
|
349
|
+
),
|
|
350
|
+
db.prepare(`
|
|
351
|
+
INSERT INTO report_participants (id, report_id, audience, reference, contact_mode, contact_ciphertext, created_at)
|
|
352
|
+
VALUES (?1, ?2, 'reporter', ?3, ?4, ?5, ?6)
|
|
353
|
+
`).bind(reporterParticipantId, reportId, submission.reporter.reference ?? null, submission.reporter.contactMode, contactCiphertext, receivedAt),
|
|
354
|
+
db.prepare(`
|
|
355
|
+
INSERT INTO report_targets (
|
|
356
|
+
id, report_id, target_type, target_reference, owner_reference, customer_url,
|
|
357
|
+
signed_facts_json, routing_facts_json, created_at
|
|
358
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
359
|
+
`).bind(
|
|
360
|
+
crypto.randomUUID(), reportId, submission.target.type, submission.target.reference,
|
|
361
|
+
submission.target.ownerReference ?? null, submission.target.customerUrl ?? null,
|
|
362
|
+
canonicalJson(submission.trustedFacts), canonicalJson(submission.routingFacts), receivedAt,
|
|
363
|
+
),
|
|
364
|
+
db.prepare(`
|
|
365
|
+
INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
|
|
366
|
+
VALUES (?1, ?2, NULL, 'received', ?3, 'integration', ?4, ?5)
|
|
367
|
+
`).bind(
|
|
368
|
+
crypto.randomUUID(), reportId,
|
|
369
|
+
intakePlan.disposition === "quarantine" ? "intake_quarantined" : intakePlan.disposition === "group" ? "intake_grouped" : "durable_intake",
|
|
370
|
+
submission.integrationId, receivedAt,
|
|
371
|
+
),
|
|
372
|
+
db.prepare(`
|
|
373
|
+
INSERT INTO background_jobs (
|
|
374
|
+
id, report_id, job_type, idempotency_key, state, payload_json, available_at, created_at, updated_at
|
|
375
|
+
) VALUES (?1, ?2, 'route_report', ?1, ?3, ?4, ?5, ?5, ?5)
|
|
376
|
+
`).bind(
|
|
377
|
+
jobId, reportId, intakePlan.route ? "pending" : "complete",
|
|
378
|
+
canonicalJson({ reportId, intakeDecisionId: intakePlan.decisionId, disposition: intakePlan.disposition }), receivedAt,
|
|
379
|
+
),
|
|
380
|
+
db.prepare(`
|
|
381
|
+
INSERT INTO intake_decisions (
|
|
382
|
+
id, installation_id, integration_id, report_id, request_id,
|
|
383
|
+
decision, reason_codes_json, source, network_key_hash,
|
|
384
|
+
contact_key_hash, target_key_hash, campaign_fingerprint,
|
|
385
|
+
turnstile_outcome, rate_summary_json, created_at
|
|
386
|
+
) VALUES (?1, 'default', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
|
387
|
+
`).bind(
|
|
388
|
+
intakePlan.decisionId, submission.integrationId, reportId, intakePlan.requestId,
|
|
389
|
+
intakePlan.disposition, canonicalJson(intakePlan.reasonCodes), submission.source,
|
|
390
|
+
intakePlan.networkKeyHash, intakePlan.contactKeyHash, intakePlan.targetKeyHash,
|
|
391
|
+
intakePlan.campaignFingerprint, options.turnstileOutcome ?? null,
|
|
392
|
+
canonicalJson(intakePlan.rateSummary), receivedAt,
|
|
393
|
+
),
|
|
394
|
+
db.prepare(`UPDATE reports SET intake_decision_id = ?2 WHERE id = ?1`).bind(reportId, intakePlan.decisionId),
|
|
395
|
+
db.prepare(`
|
|
396
|
+
INSERT INTO duplicate_group_members (group_id, report_id, similarity_basis_json, added_at)
|
|
397
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
398
|
+
`).bind(
|
|
399
|
+
intakePlan.duplicateGroupId, reportId,
|
|
400
|
+
canonicalJson({ fingerprint_version: "v1", exact_fingerprint: true, target_hash: intakePlan.targetKeyHash }), receivedAt,
|
|
401
|
+
),
|
|
402
|
+
db.prepare(`
|
|
403
|
+
UPDATE duplicate_groups SET canonical_report_id = COALESCE(canonical_report_id, ?2),
|
|
404
|
+
member_count = (SELECT COUNT(*) FROM duplicate_group_members WHERE group_id = ?1 AND removed_at IS NULL),
|
|
405
|
+
last_seen_at = ?3, updated_at = ?3 WHERE id = ?1
|
|
406
|
+
`).bind(intakePlan.duplicateGroupId, reportId, receivedAt),
|
|
407
|
+
db.prepare(`
|
|
408
|
+
INSERT INTO audit_entries (
|
|
409
|
+
sequence, id, action, actor_type, actor_id, target_type, target_id, details_json,
|
|
410
|
+
previous_hash, entry_hash, created_at
|
|
411
|
+
) VALUES (?1, ?2, ?3, 'integration', ?4, 'report', ?5, ?6, ?7, ?8, ?9)
|
|
412
|
+
`).bind(auditSequence, auditId, auditAction, submission.integrationId, reportId, detailsJson, tip.entry_hash, auditHash, receivedAt),
|
|
413
|
+
db.prepare(`
|
|
414
|
+
INSERT INTO case_messages (
|
|
415
|
+
id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
|
|
416
|
+
automated, delivery_state, created_at, idempotency_key
|
|
417
|
+
) VALUES (?1, ?2, 'reporter', 'outbound', 'system', 'intake', ?3, 'report-received-v1', 1, ?4, ?5, ?6)
|
|
418
|
+
`).bind(receiptMessageId, reportId, receiptBody, receiptDeliveryState, receivedAt, `report-received:${reportId}`),
|
|
419
|
+
db.prepare(`
|
|
420
|
+
INSERT INTO notices (
|
|
421
|
+
id, report_id, audience, template_version_id, rendered_body, delivery_state,
|
|
422
|
+
created_at, updated_at, idempotency_key, message_id
|
|
423
|
+
) VALUES (?1, ?2, 'reporter', 'report-received-v1', ?3, ?4, ?5, ?5, ?6, ?7)
|
|
424
|
+
`).bind(receiptNoticeId, reportId, receiptBody, receiptDeliveryState, receivedAt, `report-received:${reportId}`, receiptMessageId),
|
|
425
|
+
];
|
|
426
|
+
|
|
427
|
+
if (directEmail && submission.reporter.email) {
|
|
428
|
+
statements.push(db.prepare(`
|
|
429
|
+
INSERT INTO email_deliveries (
|
|
430
|
+
id, installation_id, message_id, audience, recipient_hash,
|
|
431
|
+
provider, state, attempt_count, next_attempt_at, created_at, updated_at
|
|
432
|
+
) VALUES (?1, 'default', ?2, 'reporter', ?3, 'cloudflare_email_service', 'pending', 0, ?4, ?4, ?4)
|
|
433
|
+
`).bind(
|
|
434
|
+
receiptDeliveryId, receiptMessageId,
|
|
435
|
+
`sha256:${await sha256(`email-recipient-v1:${options.abusePepper}:${submission.reporter.email}`)}`,
|
|
436
|
+
receivedAt,
|
|
437
|
+
));
|
|
438
|
+
} else if (webhookNotification) {
|
|
439
|
+
statements.push(db.prepare(`
|
|
440
|
+
INSERT INTO notification_outbox (
|
|
441
|
+
id, notice_id, idempotency_key, channel, payload_json, status,
|
|
442
|
+
next_attempt_at, created_at, updated_at
|
|
443
|
+
) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
|
|
444
|
+
`).bind(receiptDeliveryId, receiptNoticeId, `notice:${receiptNoticeId}`, canonicalJson({
|
|
445
|
+
version: 1,
|
|
446
|
+
event: "report.received",
|
|
447
|
+
notification_id: receiptDeliveryId,
|
|
448
|
+
report_reference: publicReference,
|
|
449
|
+
...(submission.reporter.reference ? { recipient_reference: submission.reporter.reference } : {}),
|
|
450
|
+
audience: "reporter",
|
|
451
|
+
message: receiptBody,
|
|
452
|
+
}), receivedAt));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (intakePlan.activateSurge) {
|
|
456
|
+
statements.push(db.prepare(`
|
|
457
|
+
INSERT INTO abuse_surge_states (
|
|
458
|
+
installation_id, state, reason_codes_json, activated_at,
|
|
459
|
+
recover_after, revision, updated_by, updated_at
|
|
460
|
+
) VALUES ('default', 'surge', '["public_intake_velocity"]', ?1, ?2, 1, 'system:intake-guard', ?1)
|
|
461
|
+
ON CONFLICT(installation_id) DO UPDATE SET state = 'surge',
|
|
462
|
+
reason_codes_json = excluded.reason_codes_json,
|
|
463
|
+
activated_at = COALESCE(abuse_surge_states.activated_at, excluded.activated_at),
|
|
464
|
+
recover_after = excluded.recover_after, revision = abuse_surge_states.revision + 1,
|
|
465
|
+
updated_by = excluded.updated_by, updated_at = excluded.updated_at
|
|
466
|
+
`).bind(receivedAt, addMinutes(receivedAt, 15)));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (submission.contextJti) {
|
|
470
|
+
statements.push(db.prepare(`
|
|
471
|
+
INSERT INTO context_token_uses (jti, integration_id, report_id, used_at)
|
|
472
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
473
|
+
`).bind(submission.contextJti, submission.integrationId, reportId, receivedAt));
|
|
474
|
+
}
|
|
475
|
+
if (submission.contextId) {
|
|
476
|
+
statements.push(db.prepare(`
|
|
477
|
+
UPDATE report_contexts SET used_at = ?2, report_id = ?3
|
|
478
|
+
WHERE id = ?1 AND integration_id = ?4 AND used_at IS NULL
|
|
479
|
+
`).bind(submission.contextId, receivedAt, reportId, submission.integrationId));
|
|
480
|
+
}
|
|
481
|
+
for (const [key, value] of Object.entries(submission.answers)) {
|
|
482
|
+
statements.push(db.prepare(`
|
|
483
|
+
INSERT INTO report_answers (report_id, field_key, value_json, created_at)
|
|
484
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
485
|
+
`).bind(reportId, key, canonicalJson(value), receivedAt));
|
|
486
|
+
}
|
|
487
|
+
const evidenceReferences = new Set([submission.target.reference, ...submission.evidence.references]);
|
|
488
|
+
for (const reference of evidenceReferences) {
|
|
489
|
+
statements.push(db.prepare(`
|
|
490
|
+
INSERT INTO evidence_references (id, report_id, evidence_kind, reference, created_at)
|
|
491
|
+
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
492
|
+
`).bind(
|
|
493
|
+
crypto.randomUUID(), reportId, submission.source === "public_page" ? "allegation" : "customer_reference",
|
|
494
|
+
reference, receivedAt,
|
|
495
|
+
));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
await db.batch(statements);
|
|
500
|
+
const row: ExistingReportRow = { id: reportId, public_reference: publicReference, state: "received", received_at: receivedAt };
|
|
501
|
+
return { receipt: receipt(row, false), jobId, deliveryJobs, created: true, route: intakePlan.route };
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (!isConstraintError(error)) throw error;
|
|
504
|
+
const concurrent = await loadExisting(db, submission.integrationId, submission.idempotencyKey);
|
|
505
|
+
if (concurrent) {
|
|
506
|
+
return { receipt: receipt(concurrent, true), jobId: `route:${concurrent.id}`, deliveryJobs: [], created: false, route: false };
|
|
507
|
+
}
|
|
508
|
+
if (submission.contextJti) {
|
|
509
|
+
const used = await db.prepare(`SELECT report_id FROM context_token_uses WHERE jti = ?1 LIMIT 1`)
|
|
510
|
+
.bind(submission.contextJti).first<{ report_id: string }>();
|
|
511
|
+
if (used) throw new ApiError(409, "context_token_replayed", "This signed context token has already been used.");
|
|
512
|
+
}
|
|
513
|
+
if (auditAttempt === 2) throw new ApiError(503, "audit_contention", "The report could not be accepted yet. Retry with the same idempotency key.");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
throw new ApiError(503, "intake_unavailable", "The report could not be accepted yet. Retry with the same idempotency key.");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function markJobEnqueued(db: D1Database, jobId: string): Promise<void> {
|
|
520
|
+
await db.prepare(`
|
|
521
|
+
UPDATE background_jobs SET state = 'enqueued', last_error_code = NULL, updated_at = ?2
|
|
522
|
+
WHERE id = ?1 AND state IN ('pending', 'failed')
|
|
523
|
+
`).bind(jobId, isoNow()).run();
|
|
524
|
+
await db.prepare(`
|
|
525
|
+
UPDATE reports SET routing_status = 'enqueued', routing_error_code = NULL, updated_at = ?2
|
|
526
|
+
WHERE id = (SELECT report_id FROM background_jobs WHERE id = ?1) AND routing_status IN ('pending', 'failed')
|
|
527
|
+
`).bind(jobId, isoNow()).run();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export async function markJobEnqueueFailed(db: D1Database, jobId: string, errorCode: string): Promise<void> {
|
|
531
|
+
const now = isoNow();
|
|
532
|
+
await db.batch([
|
|
533
|
+
db.prepare(`
|
|
534
|
+
UPDATE background_jobs
|
|
535
|
+
SET state = 'failed', attempt_count = attempt_count + 1, last_error_code = ?2, updated_at = ?3
|
|
536
|
+
WHERE id = ?1 AND state != 'complete'
|
|
537
|
+
`).bind(jobId, errorCode.slice(0, 100), now),
|
|
538
|
+
db.prepare(`
|
|
539
|
+
UPDATE reports
|
|
540
|
+
SET routing_status = 'failed', routing_attempts = routing_attempts + 1, routing_error_code = ?2, updated_at = ?3
|
|
541
|
+
WHERE id = (SELECT report_id FROM background_jobs WHERE id = ?1) AND routing_status != 'complete'
|
|
542
|
+
`).bind(jobId, errorCode.slice(0, 100), now),
|
|
543
|
+
]);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export async function pendingRoutingJobs(db: D1Database, limit = 50): Promise<Array<{ id: string; reportId: string }>> {
|
|
547
|
+
const staleBefore = new Date(Date.now() - 5 * 60_000).toISOString();
|
|
548
|
+
const result = await db.prepare(`
|
|
549
|
+
SELECT id, report_id AS reportId
|
|
550
|
+
FROM background_jobs
|
|
551
|
+
WHERE job_type = 'route_report'
|
|
552
|
+
AND (state IN ('pending', 'failed') OR (state = 'processing' AND updated_at <= ?3))
|
|
553
|
+
AND available_at <= ?1
|
|
554
|
+
ORDER BY available_at ASC, id ASC
|
|
555
|
+
LIMIT ?2
|
|
556
|
+
`).bind(isoNow(), Math.max(1, Math.min(100, limit)), staleBefore).all<{ id: string; reportId: string }>();
|
|
557
|
+
return result.results;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function parsedRoutingPolicy(row: QueueRoutingRow): QueueRoutingPolicy | null {
|
|
561
|
+
try {
|
|
562
|
+
const aiBounds: unknown = JSON.parse(row.aiBoundsJson);
|
|
563
|
+
return {
|
|
564
|
+
queueId: row.queueId,
|
|
565
|
+
queueVersionId: row.queueVersionId,
|
|
566
|
+
managementMode: row.managementMode,
|
|
567
|
+
rolloutMode: row.rolloutMode,
|
|
568
|
+
priority: row.priority,
|
|
569
|
+
responseSlaMinutes: row.responseSlaMinutes,
|
|
570
|
+
fallbackQueueId: row.fallbackQueueId,
|
|
571
|
+
position: row.position,
|
|
572
|
+
routingCriteria: { match: "all", conditions: [] },
|
|
573
|
+
adjudicationPolicyVersionId: aiBounds && typeof aiBounds === "object" && !Array.isArray(aiBounds)
|
|
574
|
+
&& typeof (aiBounds as Record<string, unknown>).policyVersionId === "string"
|
|
575
|
+
? String((aiBounds as Record<string, unknown>).policyVersionId)
|
|
576
|
+
: "general-policy-v1",
|
|
577
|
+
};
|
|
578
|
+
} catch {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function activeQueueRoutingPolicies(db: D1Database): Promise<QueueRoutingPolicy[]> {
|
|
584
|
+
const rows = await db.prepare(`
|
|
585
|
+
SELECT q.id AS queueId, v.id AS queueVersionId, v.management_mode AS managementMode,
|
|
586
|
+
v.mode AS rolloutMode, v.priority, v.response_sla_minutes AS responseSlaMinutes,
|
|
587
|
+
v.fallback_queue_id AS fallbackQueueId, q.position, v.ai_bounds_json AS aiBoundsJson
|
|
588
|
+
FROM queue_definitions q JOIN queue_versions v ON v.id = q.active_version_id
|
|
589
|
+
WHERE q.purpose = 'reports' AND q.status = 'active'
|
|
590
|
+
ORDER BY q.position, q.id
|
|
591
|
+
`).all<QueueRoutingRow>();
|
|
592
|
+
return rows.results.map(parsedRoutingPolicy).filter((queue): queue is QueueRoutingPolicy => Boolean(queue));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function activePublishedRoutingRules(db: D1Database): Promise<PublishedRoutingRuleSet> {
|
|
596
|
+
const row = await db.prepare(`
|
|
597
|
+
SELECT id, rules_json AS rulesJson FROM routing_rule_versions
|
|
598
|
+
WHERE installation_id = 'default' AND published_at IS NOT NULL
|
|
599
|
+
ORDER BY version DESC, id DESC LIMIT 1
|
|
600
|
+
`).first<{ id: string; rulesJson: string }>();
|
|
601
|
+
if (!row) throw new ApiError(503, "published_routing_rules_missing", "A published routing rule version is required before reports can be routed.");
|
|
602
|
+
try {
|
|
603
|
+
const content = JSON.parse(row.rulesJson) as Record<string, unknown>;
|
|
604
|
+
if (!content || typeof content !== "object" || Array.isArray(content)
|
|
605
|
+
|| typeof content.default_queue_id !== "string" || typeof content.safeguard_queue_id !== "string"
|
|
606
|
+
|| !Array.isArray(content.rules)) throw new Error("invalid");
|
|
607
|
+
const rules = content.rules.map((value): PublishedRoutingRule => {
|
|
608
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
|
|
609
|
+
const rule = value as Record<string, unknown>;
|
|
610
|
+
if (typeof rule.id !== "string" || typeof rule.queue_id !== "string" || !Number.isInteger(rule.priority)
|
|
611
|
+
|| (rule.match !== "all" && rule.match !== "any") || !Array.isArray(rule.conditions)) throw new Error("invalid");
|
|
612
|
+
return {
|
|
613
|
+
id: rule.id,
|
|
614
|
+
queueId: rule.queue_id,
|
|
615
|
+
priority: Number(rule.priority),
|
|
616
|
+
routingCriteria: { match: rule.match, conditions: rule.conditions as RoutingCondition[] },
|
|
617
|
+
};
|
|
618
|
+
}).sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id));
|
|
619
|
+
const intakeEnrichments: Array<{ componentVersionId: string; inputMapping: Record<string, JsonValue> }> = [];
|
|
620
|
+
if (Array.isArray(content.intake_enrichments)) {
|
|
621
|
+
for (const value of content.intake_enrichments) {
|
|
622
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid");
|
|
623
|
+
const entry = value as Record<string, unknown>;
|
|
624
|
+
if (typeof entry.component_version_id !== "string" || !entry.input_mapping || typeof entry.input_mapping !== "object" || Array.isArray(entry.input_mapping)) throw new Error("invalid");
|
|
625
|
+
intakeEnrichments.push({ componentVersionId: entry.component_version_id, inputMapping: entry.input_mapping as Record<string, JsonValue> });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (intakeEnrichments.length > 5 || new Set(intakeEnrichments.map((entry) => entry.componentVersionId)).size !== intakeEnrichments.length) throw new Error("invalid");
|
|
629
|
+
return {
|
|
630
|
+
id: row.id,
|
|
631
|
+
defaultQueueId: content.default_queue_id,
|
|
632
|
+
safeguardQueueId: content.safeguard_queue_id,
|
|
633
|
+
intakeEnrichments,
|
|
634
|
+
rules,
|
|
635
|
+
};
|
|
636
|
+
} catch {
|
|
637
|
+
throw new ApiError(503, "published_routing_rules_invalid", "The published routing rule version cannot be evaluated safely.");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function processRoutingJob(
|
|
642
|
+
db: D1Database,
|
|
643
|
+
jobId: string,
|
|
644
|
+
ai?: RoutingAiBinding,
|
|
645
|
+
intakeExecutor?: RoutingIntakeEnrichmentExecutor,
|
|
646
|
+
): Promise<Array<AiJob | WorkflowDispatchJob>> {
|
|
647
|
+
const job = await db.prepare(`
|
|
648
|
+
SELECT j.id, j.report_id, j.state, r.reason_code, r.source, r.form_version_id,
|
|
649
|
+
r.installation_id, r.public_reference,
|
|
650
|
+
t.target_type, t.target_reference, t.owner_reference, t.customer_url,
|
|
651
|
+
t.routing_facts_json AS trusted_facts_json, r.state AS report_state
|
|
652
|
+
FROM background_jobs j
|
|
653
|
+
JOIN reports r ON r.id = j.report_id
|
|
654
|
+
JOIN report_targets t ON t.report_id = r.id
|
|
655
|
+
WHERE j.id = ?1 AND j.job_type = 'route_report'
|
|
656
|
+
LIMIT 1
|
|
657
|
+
`).bind(jobId).first<RoutingJobRow>();
|
|
658
|
+
if (!job) throw new ApiError(404, "job_not_found", "The routing job does not exist.");
|
|
659
|
+
if (job.state === "complete") return [];
|
|
660
|
+
if (job.report_state !== "received") {
|
|
661
|
+
await db.prepare(`UPDATE background_jobs SET state = 'complete', updated_at = ?2 WHERE id = ?1`).bind(jobId, isoNow()).run();
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
const claimedAt = isoNow();
|
|
665
|
+
const staleBefore = new Date(Date.now() - 5 * 60_000).toISOString();
|
|
666
|
+
const claim = await db.prepare(`
|
|
667
|
+
UPDATE background_jobs SET state = 'processing', attempt_count = attempt_count + 1,
|
|
668
|
+
last_error_code = NULL, updated_at = ?2
|
|
669
|
+
WHERE id = ?1 AND job_type = 'route_report'
|
|
670
|
+
AND (state IN ('pending','enqueued','failed') OR (state = 'processing' AND updated_at <= ?3))
|
|
671
|
+
`).bind(jobId, claimedAt, staleBefore).run();
|
|
672
|
+
if ((claim.meta.changes ?? 0) !== 1) return [];
|
|
673
|
+
await db.prepare(`UPDATE reports SET routing_status = 'processing', routing_error_code = NULL, updated_at = ?2 WHERE id = ?1 AND state = 'received'`)
|
|
674
|
+
.bind(job.report_id, claimedAt).run();
|
|
675
|
+
let trustedFacts: Record<string, string | number | boolean | null> = {};
|
|
676
|
+
try {
|
|
677
|
+
const parsed: unknown = JSON.parse(job.trusted_facts_json);
|
|
678
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) trustedFacts = parsed as typeof trustedFacts;
|
|
679
|
+
} catch {
|
|
680
|
+
trustedFacts = { trusted_facts_conflict: true };
|
|
681
|
+
}
|
|
682
|
+
const answersResult = await db.prepare(`SELECT field_key, value_json FROM report_answers WHERE report_id = ?1`)
|
|
683
|
+
.bind(job.report_id).all<{ field_key: string; value_json: string }>();
|
|
684
|
+
const answers: Record<string, unknown> = {};
|
|
685
|
+
for (const row of answersResult.results) {
|
|
686
|
+
try { answers[row.field_key] = JSON.parse(row.value_json) as unknown; } catch { answers[row.field_key] = null; }
|
|
687
|
+
}
|
|
688
|
+
const routingInput: RoutingInput = {
|
|
689
|
+
reasonCode: job.reason_code,
|
|
690
|
+
source: job.source,
|
|
691
|
+
targetType: job.target_type,
|
|
692
|
+
trustedFacts,
|
|
693
|
+
answers,
|
|
694
|
+
priority: 50,
|
|
695
|
+
};
|
|
696
|
+
const queues = await activeQueueRoutingPolicies(db);
|
|
697
|
+
const routingRules = await activePublishedRoutingRules(db);
|
|
698
|
+
const routingAgent = await activeRoutingAgent(db);
|
|
699
|
+
const defaultQueueId = routingRules.defaultQueueId;
|
|
700
|
+
const safeguardQueueId = routingRules.safeguardQueueId;
|
|
701
|
+
if (!defaultQueueId || !safeguardQueueId) {
|
|
702
|
+
throw new ApiError(503, "routing_human_fallback_missing", "Routing requires active default and safeguard human queues.");
|
|
703
|
+
}
|
|
704
|
+
const safeguard = evaluateHardSafeguards(routingInput);
|
|
705
|
+
const byQueueId = new Map(queues.map((queue) => [queue.queueId, queue]));
|
|
706
|
+
let matchedRules = routingRules.rules.filter((rule) => {
|
|
707
|
+
const queue = byQueueId.get(rule.queueId);
|
|
708
|
+
return queue ? queueMatches(routingInput, { ...queue, routingCriteria: rule.routingCriteria }, safeguard) : false;
|
|
709
|
+
});
|
|
710
|
+
let intakeEnrichmentFailures: string[] = [];
|
|
711
|
+
if (!safeguard.requiresHuman && matchedRules.length === 0 && routingRules.intakeEnrichments.length) {
|
|
712
|
+
if (intakeExecutor) {
|
|
713
|
+
const context = JSON.parse(canonicalJson({
|
|
714
|
+
report: {
|
|
715
|
+
id: job.report_id, public_reference: job.public_reference, reason_code: job.reason_code,
|
|
716
|
+
source: job.source, target_type: job.target_type, priority: 50,
|
|
717
|
+
},
|
|
718
|
+
target: {
|
|
719
|
+
type: job.target_type, reference: job.target_reference,
|
|
720
|
+
owner_reference: job.owner_reference, customer_url: job.customer_url,
|
|
721
|
+
signed_facts: trustedFacts,
|
|
722
|
+
},
|
|
723
|
+
answers,
|
|
724
|
+
trusted_facts: trustedFacts,
|
|
725
|
+
})) as JsonObject;
|
|
726
|
+
const settled = await Promise.allSettled(routingRules.intakeEnrichments.map((entry) => intakeExecutor({
|
|
727
|
+
reportId: job.report_id,
|
|
728
|
+
componentVersionId: entry.componentVersionId,
|
|
729
|
+
inputMapping: entry.inputMapping,
|
|
730
|
+
context,
|
|
731
|
+
operationId: `${jobId}:intake:${entry.componentVersionId}`,
|
|
732
|
+
})));
|
|
733
|
+
const enrichments: Record<string, unknown> = {};
|
|
734
|
+
settled.forEach((result, index) => {
|
|
735
|
+
const componentVersionId = routingRules.intakeEnrichments[index]!.componentVersionId;
|
|
736
|
+
if (result.status === "fulfilled") enrichments[result.value.componentKey] = result.value.data;
|
|
737
|
+
else intakeEnrichmentFailures.push(componentVersionId);
|
|
738
|
+
});
|
|
739
|
+
routingInput.enrichments = enrichments;
|
|
740
|
+
matchedRules = routingRules.rules.filter((rule) => {
|
|
741
|
+
const queue = byQueueId.get(rule.queueId);
|
|
742
|
+
return queue ? queueMatches(routingInput, { ...queue, routingCriteria: rule.routingCriteria }, safeguard) : false;
|
|
743
|
+
});
|
|
744
|
+
} else {
|
|
745
|
+
intakeEnrichmentFailures = routingRules.intakeEnrichments.map((entry) => entry.componentVersionId);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const deterministicQueueId = matchedRules[0]?.queueId ?? null;
|
|
749
|
+
const agentDecision = routingAgent && !safeguard.requiresHuman && !deterministicQueueId
|
|
750
|
+
? await executeRoutingAgent(db, ai, routingAgent, job.report_id, jobId, routingInput, queues)
|
|
751
|
+
: { queueId: null, accepted: false };
|
|
752
|
+
let route = selectRoute(routingInput, queues, {
|
|
753
|
+
defaultQueueId,
|
|
754
|
+
safeguardQueueId,
|
|
755
|
+
deterministicQueueId,
|
|
756
|
+
agentQueueId: agentDecision.queueId,
|
|
757
|
+
agentAccepted: agentDecision.accepted,
|
|
758
|
+
});
|
|
759
|
+
if (!route) throw new ApiError(503, "routing_policy_unavailable", "No safe active queue policy can receive this report.");
|
|
760
|
+
let aiConfiguration = await aiConfigurationForRouting(db, route.queueId, route.queueVersionId);
|
|
761
|
+
if (route.state === "ai_review" && !aiConfiguration) {
|
|
762
|
+
const selectedQueue = queues.find((queue) => queue.queueId === route!.queueId);
|
|
763
|
+
const fallback = queues.find((queue) => queue.queueId === selectedQueue?.fallbackQueueId && queue.managementMode === "human")
|
|
764
|
+
?? queues.find((queue) => queue.queueId === defaultQueueId && queue.managementMode === "human");
|
|
765
|
+
if (!fallback) throw new ApiError(503, "routing_ai_fallback_missing", "The selected AI queue has no available human fallback.");
|
|
766
|
+
route = {
|
|
767
|
+
queueId: fallback.queueId,
|
|
768
|
+
queueVersionId: fallback.queueVersionId,
|
|
769
|
+
state: "human_review",
|
|
770
|
+
priority: Math.max(60, fallback.priority),
|
|
771
|
+
reasonCode: "ai_runtime_unavailable_human_fallback",
|
|
772
|
+
responseSlaMinutes: fallback.responseSlaMinutes,
|
|
773
|
+
safeguardReasons: [],
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
const now = isoNow();
|
|
777
|
+
const aiJobId = aiConfiguration && (route.state === "ai_review" || aiConfiguration.mode === "shadow")
|
|
778
|
+
? `ai:${job.report_id}:${aiConfiguration.id}`
|
|
779
|
+
: null;
|
|
780
|
+
const routedQueue = queues.find((queue) => queue.queueId === route.queueId);
|
|
781
|
+
const workflowRuns = await preparePinnedWorkflowRunsForRouting(db, {
|
|
782
|
+
reportId: job.report_id,
|
|
783
|
+
installationId: job.installation_id,
|
|
784
|
+
queueId: route.queueId,
|
|
785
|
+
queueVersionId: route.queueVersionId,
|
|
786
|
+
createdAt: now,
|
|
787
|
+
});
|
|
788
|
+
const statements = [
|
|
789
|
+
db.prepare(`
|
|
790
|
+
UPDATE reports
|
|
791
|
+
SET queue_id = ?2, queue_version_id = ?3, policy_version_id = ?4, state = ?5, priority = ?6, due_at = ?7,
|
|
792
|
+
routing_rule_version_id = ?8, routing_agent_version_id = ?9, routing_reason_json = ?10,
|
|
793
|
+
routing_status = 'complete', routing_error_code = NULL, updated_at = ?11
|
|
794
|
+
WHERE id = ?1 AND state = 'received'
|
|
795
|
+
`).bind(
|
|
796
|
+
job.report_id, route.queueId, route.queueVersionId, routedQueue?.adjudicationPolicyVersionId ?? "general-policy-v1", route.state, route.priority,
|
|
797
|
+
route.responseSlaMinutes === null ? null : addMinutes(now, route.responseSlaMinutes),
|
|
798
|
+
routingRules.id,
|
|
799
|
+
routingAgent?.id ?? null,
|
|
800
|
+
canonicalJson({ reasonCode: route.reasonCode, safeguards: route.safeguardReasons, matchedRuleIds: matchedRules.map((rule) => rule.id), intakeEnrichmentFailures, routingAgentAccepted: agentDecision.accepted }),
|
|
801
|
+
now,
|
|
802
|
+
),
|
|
803
|
+
db.prepare(`
|
|
804
|
+
INSERT OR IGNORE INTO report_state_history (
|
|
805
|
+
id, report_id, background_job_id, from_state, to_state, reason_code, actor_type, actor_id, created_at
|
|
806
|
+
) VALUES (?1, ?2, ?3, 'received', ?4, ?5, 'system', ?6, ?7)
|
|
807
|
+
`).bind(crypto.randomUUID(), job.report_id, jobId, route.state, route.reasonCode, routingAgent?.id ?? "queue-policy-router-v2", now),
|
|
808
|
+
db.prepare(`
|
|
809
|
+
INSERT OR IGNORE INTO report_queue_assignments (
|
|
810
|
+
id, report_id, queue_id, queue_version_id, routing_rule_version_id, routing_agent_version_id,
|
|
811
|
+
assignment_kind, reason_json, assigned_by_type, assigned_by_id, assigned_at
|
|
812
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'initial', ?7, 'system', ?8, ?9)
|
|
813
|
+
`).bind(
|
|
814
|
+
crypto.randomUUID(), job.report_id, route.queueId, route.queueVersionId,
|
|
815
|
+
routingRules.id, routingAgent?.id ?? null,
|
|
816
|
+
canonicalJson({ reasonCode: route.reasonCode, safeguards: route.safeguardReasons, matchedRuleIds: matchedRules.map((rule) => rule.id), intakeEnrichmentFailures, routingAgentAccepted: agentDecision.accepted }),
|
|
817
|
+
routingAgent?.id ?? routingRules.id, now,
|
|
818
|
+
),
|
|
819
|
+
db.prepare(`
|
|
820
|
+
UPDATE background_jobs
|
|
821
|
+
SET state = 'complete', last_error_code = NULL, updated_at = ?2
|
|
822
|
+
WHERE id = ?1
|
|
823
|
+
`).bind(jobId, now),
|
|
824
|
+
...workflowRuns.statements,
|
|
825
|
+
];
|
|
826
|
+
if (aiJobId && aiConfiguration) {
|
|
827
|
+
statements.push(db.prepare(`
|
|
828
|
+
INSERT OR IGNORE INTO background_jobs (
|
|
829
|
+
id, report_id, job_type, idempotency_key, state, payload_json, available_at,
|
|
830
|
+
created_at, updated_at, ai_config_version_id
|
|
831
|
+
) VALUES (?1, ?2, 'resume_ai', ?1, 'pending', ?3, ?4, ?4, ?4, ?5)
|
|
832
|
+
`).bind(
|
|
833
|
+
aiJobId, job.report_id,
|
|
834
|
+
canonicalJson({ version: 1, reportId: job.report_id, configVersionId: aiConfiguration.id }),
|
|
835
|
+
now, aiConfiguration.id,
|
|
836
|
+
));
|
|
837
|
+
}
|
|
838
|
+
await db.batch(statements);
|
|
839
|
+
return [
|
|
840
|
+
...workflowRuns.jobs,
|
|
841
|
+
...(aiJobId
|
|
842
|
+
? [{ version: 1, jobId: aiJobId, type: "process_ai_report", reportId: job.report_id } satisfies AiJob]
|
|
843
|
+
: []),
|
|
844
|
+
];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export type InboxCursor = {
|
|
848
|
+
priority: number;
|
|
849
|
+
receivedAt: string;
|
|
850
|
+
id: string;
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
export async function listInboxReports(
|
|
854
|
+
db: D1Database,
|
|
855
|
+
options: {
|
|
856
|
+
queueId?: string;
|
|
857
|
+
allowedQueueIds?: string[] | null;
|
|
858
|
+
state?: ReportState;
|
|
859
|
+
cursor?: InboxCursor | null;
|
|
860
|
+
limit: number;
|
|
861
|
+
},
|
|
862
|
+
): Promise<{ reports: InboxReport[]; nextPosition: InboxCursor | null }> {
|
|
863
|
+
const clauses = ["1 = 1"];
|
|
864
|
+
const bindings: unknown[] = [];
|
|
865
|
+
if (options.queueId) {
|
|
866
|
+
bindings.push(options.queueId);
|
|
867
|
+
clauses.push(`r.queue_id = ?${bindings.length}`);
|
|
868
|
+
}
|
|
869
|
+
if (options.allowedQueueIds !== undefined && options.allowedQueueIds !== null) {
|
|
870
|
+
if (!options.allowedQueueIds.length) return { reports: [], nextPosition: null };
|
|
871
|
+
const placeholders = options.allowedQueueIds.map((queueId) => {
|
|
872
|
+
bindings.push(queueId);
|
|
873
|
+
return `?${bindings.length}`;
|
|
874
|
+
});
|
|
875
|
+
clauses.push(`r.queue_id IN (${placeholders.join(",")})`);
|
|
876
|
+
}
|
|
877
|
+
if (options.state) {
|
|
878
|
+
bindings.push(options.state);
|
|
879
|
+
clauses.push(`r.state = ?${bindings.length}`);
|
|
880
|
+
}
|
|
881
|
+
if (options.cursor) {
|
|
882
|
+
bindings.push(options.cursor.priority);
|
|
883
|
+
const priorityBinding = bindings.length;
|
|
884
|
+
bindings.push(options.cursor.receivedAt);
|
|
885
|
+
const receivedAtBinding = bindings.length;
|
|
886
|
+
bindings.push(options.cursor.id);
|
|
887
|
+
const idBinding = bindings.length;
|
|
888
|
+
clauses.push(`(
|
|
889
|
+
r.priority < ?${priorityBinding}
|
|
890
|
+
OR (r.priority = ?${priorityBinding} AND r.received_at > ?${receivedAtBinding})
|
|
891
|
+
OR (r.priority = ?${priorityBinding} AND r.received_at = ?${receivedAtBinding} AND r.id > ?${idBinding})
|
|
892
|
+
)`);
|
|
893
|
+
}
|
|
894
|
+
bindings.push(isoNow());
|
|
895
|
+
const atBinding = bindings.length;
|
|
896
|
+
const limit = Math.max(1, Math.min(100, options.limit));
|
|
897
|
+
bindings.push(limit + 1);
|
|
898
|
+
const limitBinding = bindings.length;
|
|
899
|
+
const result = await db.prepare(`
|
|
900
|
+
SELECT r.id, r.public_reference AS reference, r.state, r.priority, r.reason_code AS reasonCode,
|
|
901
|
+
r.queue_id AS queueId, t.target_type AS targetType, t.target_reference AS targetReference,
|
|
902
|
+
r.submitted_at AS submittedAt, r.received_at AS receivedAt, r.updated_at AS updatedAt, r.due_at AS dueAt,
|
|
903
|
+
c.reviewer_id AS claimReviewerId, c.claimed_at AS claimedAt, c.expires_at AS claimExpiresAt,
|
|
904
|
+
p.display_name AS analystDisplayName, p.avatar_url AS analystAvatarUrl,
|
|
905
|
+
p.avatar_color AS analystAvatarColor
|
|
906
|
+
FROM reports r
|
|
907
|
+
JOIN report_targets t ON t.report_id = r.id
|
|
908
|
+
LEFT JOIN review_claims c ON c.report_id = r.id AND c.expires_at > ?${atBinding}
|
|
909
|
+
LEFT JOIN analyst_profiles p ON p.actor_id = c.reviewer_id
|
|
910
|
+
WHERE ${clauses.join(" AND ")}
|
|
911
|
+
ORDER BY r.priority DESC, r.received_at ASC, r.id ASC
|
|
912
|
+
LIMIT ?${limitBinding}
|
|
913
|
+
`).bind(...bindings).all<InboxReportRow>();
|
|
914
|
+
const reports = result.results.slice(0, limit).map((row): InboxReport => ({
|
|
915
|
+
id: row.id,
|
|
916
|
+
reference: row.reference,
|
|
917
|
+
state: row.state,
|
|
918
|
+
priority: row.priority,
|
|
919
|
+
reasonCode: row.reasonCode,
|
|
920
|
+
queueId: row.queueId,
|
|
921
|
+
targetType: row.targetType,
|
|
922
|
+
targetReference: row.targetReference,
|
|
923
|
+
submittedAt: row.submittedAt,
|
|
924
|
+
receivedAt: row.receivedAt,
|
|
925
|
+
updatedAt: row.updatedAt,
|
|
926
|
+
dueAt: row.dueAt,
|
|
927
|
+
claim: row.claimReviewerId && row.claimedAt && row.claimExpiresAt ? {
|
|
928
|
+
reviewerId: row.claimReviewerId,
|
|
929
|
+
claimedAt: row.claimedAt,
|
|
930
|
+
expiresAt: row.claimExpiresAt,
|
|
931
|
+
analyst: {
|
|
932
|
+
displayName: row.analystDisplayName ?? row.claimReviewerId,
|
|
933
|
+
avatarUrl: row.analystAvatarUrl,
|
|
934
|
+
avatarColor: row.analystAvatarColor ?? analystAvatarColor(row.claimReviewerId),
|
|
935
|
+
},
|
|
936
|
+
} : null,
|
|
937
|
+
}));
|
|
938
|
+
const last = reports.at(-1);
|
|
939
|
+
return {
|
|
940
|
+
reports,
|
|
941
|
+
nextPosition: result.results.length > limit && last
|
|
942
|
+
? { priority: last.priority, receivedAt: last.receivedAt, id: last.id }
|
|
943
|
+
: null,
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export async function publicForm(db: D1Database, formVersionId = "general-v1"): Promise<{
|
|
948
|
+
id: string;
|
|
949
|
+
title: string;
|
|
950
|
+
description: string;
|
|
951
|
+
targetTypes: unknown[];
|
|
952
|
+
reasons: unknown[];
|
|
953
|
+
fields: unknown[];
|
|
954
|
+
consentNotice: string;
|
|
955
|
+
} | null> {
|
|
956
|
+
const row = await db.prepare(`
|
|
957
|
+
SELECT id, title, description, target_types_json, reasons_json, fields_json, consent_notice
|
|
958
|
+
FROM report_form_versions
|
|
959
|
+
WHERE id = ?1 AND published_at IS NOT NULL AND retired_at IS NULL
|
|
960
|
+
LIMIT 1
|
|
961
|
+
`).bind(formVersionId).first<FormRow & { title: string; description: string }>();
|
|
962
|
+
if (!row) return null;
|
|
963
|
+
return {
|
|
964
|
+
id: row.id,
|
|
965
|
+
title: row.title,
|
|
966
|
+
description: row.description,
|
|
967
|
+
targetTypes: parseJsonArray(row.target_types_json),
|
|
968
|
+
reasons: parseJsonArray(row.reasons_json),
|
|
969
|
+
fields: parseJsonArray(row.fields_json),
|
|
970
|
+
consentNotice: row.consent_notice,
|
|
971
|
+
};
|
|
972
|
+
}
|