create-safest-tools 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/src/update.mjs +10 -1
- package/template/README.md +2 -2
- package/template/console/README.md +2 -2
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +101 -64
- package/template/console/command/controller.ts +80 -70
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +29 -0
- package/template/console/command/store.ts +4 -4
- package/template/console/command/types.ts +26 -21
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
- package/template/console/registry/RegistryDialog.tsx +56 -27
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +122 -67
- package/template/console/reports/ReportWorkspace.tsx +112 -23
- package/template/console/reports/controller.ts +125 -60
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +50 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +23 -16
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +31 -22
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +22151 -14847
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +599 -83
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +5 -4
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +14 -0
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +191 -29
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +149 -148
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-email.ts +7 -30
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +30 -67
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +183 -249
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +20 -9
- package/template/src/workflow-effects.ts +117 -39
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +61 -13
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { changeReportOwner } from "./report-ownership";
|
|
2
|
+
export { decideAppeal } from "./appeal-decisions";
|
|
1
3
|
import { AUDIT_GENESIS_HASH, canonicalJson, hashAuditEntry } from "./audit";
|
|
2
4
|
import { decryptReportContact } from "./report-crypto";
|
|
3
5
|
import { selectParticipantDelivery, type ParticipantDelivery } from "./report-email";
|
|
4
6
|
import { ApiError } from "./report-http";
|
|
5
7
|
import { analystAvatarColor } from "./report-presence";
|
|
8
|
+
import { assertOperatorReportAccess } from "./operator-scope";
|
|
6
9
|
import type { DeliveryJob, OperatorSession, ParticipantAudience, ReportState } from "./report-types";
|
|
7
|
-
import type {
|
|
10
|
+
import type { DecisionInput, ReviewerMessageInput } from "./report-workflow-validation";
|
|
8
11
|
|
|
9
12
|
interface WorkflowReportRow {
|
|
10
13
|
id: string;
|
|
@@ -86,11 +89,33 @@ async function requireActiveClaim(db: D1Database, reportId: string, reviewerId:
|
|
|
86
89
|
LIMIT 1
|
|
87
90
|
`).bind(reportId).first<ClaimRow>();
|
|
88
91
|
if (!claim || claim.reviewer_id !== reviewerId || claim.expires_at <= at) {
|
|
89
|
-
throw new ApiError(409, "active_claim_required", "
|
|
92
|
+
throw new ApiError(409, "active_claim_required", "Start or resume review to edit this case. Your private draft is unchanged.");
|
|
90
93
|
}
|
|
91
94
|
return claim;
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
async function commitReviewerMutation(db: D1Database, report: WorkflowReportRow, claim: ClaimRow, session: OperatorSession, statements: D1PreparedStatement[], decision = false): Promise<void> {
|
|
98
|
+
const id = crypto.randomUUID();
|
|
99
|
+
try {
|
|
100
|
+
await db.batch([
|
|
101
|
+
db.prepare(`INSERT INTO report_edit_guards (id,authorized)
|
|
102
|
+
SELECT ?1,EXISTS (SELECT 1 FROM reports r JOIN review_claims c ON c.report_id = r.id
|
|
103
|
+
WHERE r.id = ?2 AND r.state = ?3 AND r.policy_version_id IS ?4 AND r.queue_version_id IS ?5
|
|
104
|
+
AND c.reviewer_id = ?6 AND c.version = ?7 AND c.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
105
|
+
AND (?8 = 1 OR r.queue_id IN (SELECT value FROM json_each(?9)))
|
|
106
|
+
AND (?10 = 0 OR NOT EXISTS (SELECT 1 FROM workflow_runs wr JOIN workflow_versions wv ON wv.id = wr.workflow_version_id
|
|
107
|
+
WHERE wr.report_id = r.id AND wr.run_kind = 'primary' AND json_type(wv.graph_json,'$.playbook') = 'object'
|
|
108
|
+
AND wr.state NOT IN ('succeeded','failed','terminated','superseded'))))`)
|
|
109
|
+
.bind(id,report.id,report.state,report.policy_version_id,report.queue_version_id,session.actor.id,claim.version,session.queueScope == null ? 1 : 0,canonicalJson(session.queueScope ?? []),decision ? 1 : 0),
|
|
110
|
+
...statements,
|
|
111
|
+
db.prepare("DELETE FROM report_edit_guards WHERE id = ?1").bind(id),
|
|
112
|
+
]);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (String(error).includes("active_report_edit_access")) throw new ApiError(409,"active_claim_required","Case ownership, editing access or the review requirements changed before saving. Refresh the case and resume review. Your private draft is unchanged.");
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
export async function claimReport(
|
|
95
120
|
db: D1Database,
|
|
96
121
|
reportId: string,
|
|
@@ -104,37 +129,26 @@ export async function claimReport(
|
|
|
104
129
|
}
|
|
105
130
|
const claimedAt = now();
|
|
106
131
|
const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, claimTtlMinutes)));
|
|
107
|
-
await db.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
throw new ApiError(409, "report_already_claimed", "Another reviewer currently holds this report.", {
|
|
123
|
-
expires_at: claim?.expires_at,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
await db.batch([
|
|
127
|
-
auditEvent(db, {
|
|
128
|
-
idempotencyKey: `claim:${reportId}:${claim.version}`,
|
|
129
|
-
action: "report.claimed",
|
|
130
|
-
actorType: session.actor.type,
|
|
131
|
-
actorId: session.actor.id,
|
|
132
|
-
targetType: "report",
|
|
133
|
-
targetId: reportId,
|
|
134
|
-
details: { expiresAt: claim.expires_at, version: claim.version },
|
|
135
|
-
createdAt: claimedAt,
|
|
136
|
-
}),
|
|
132
|
+
const written = await db.batch([
|
|
133
|
+
db.prepare(`
|
|
134
|
+
INSERT INTO review_claims (report_id, reviewer_id, claimed_at, expires_at, version)
|
|
135
|
+
SELECT r.id, ?2, ?3, ?4, COALESCE((SELECT MAX(CAST(json_extract(details_json, '$.version') AS INTEGER))
|
|
136
|
+
FROM audit_events WHERE target_type = 'report' AND target_id = ?1), 0) + 1
|
|
137
|
+
FROM reports r WHERE r.id = ?1 AND r.state IN ('human_review','awaiting_reporter','human_review_requested')
|
|
138
|
+
AND (?5 = 1 OR r.queue_id IN (SELECT value FROM json_each(?6)))
|
|
139
|
+
ON CONFLICT(report_id) DO UPDATE SET claimed_at = excluded.claimed_at,
|
|
140
|
+
expires_at = excluded.expires_at, version = review_claims.version + 1
|
|
141
|
+
WHERE review_claims.reviewer_id = ?2
|
|
142
|
+
`).bind(reportId, session.actor.id, claimedAt, expiresAt, session.queueScope == null ? 1 : 0, canonicalJson(session.queueScope ?? [])),
|
|
143
|
+
db.prepare(`INSERT INTO audit_events (id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
|
|
144
|
+
SELECT ?1,?1,'report.claimed',?2,?3,'report',?4,json_object('expiresAt',expires_at,'version',version),?5
|
|
145
|
+
FROM review_claims WHERE report_id = ?4 AND changes() = 1`)
|
|
146
|
+
.bind(crypto.randomUUID(),session.actor.type,session.actor.id,reportId,claimedAt),
|
|
137
147
|
]);
|
|
148
|
+
const claim = await db.prepare(`SELECT report_id,reviewer_id,claimed_at,expires_at,version FROM review_claims WHERE report_id = ?1`).bind(reportId).first<ClaimRow>();
|
|
149
|
+
if (written[0]?.meta.changes !== 1 || !claim || claim.reviewer_id !== session.actor.id) {
|
|
150
|
+
throw new ApiError(409, "report_already_claimed", "This case is assigned to another reviewer or is no longer ready for review. Refresh to see its current owner.");
|
|
151
|
+
}
|
|
138
152
|
return { reviewerId: claim.reviewer_id, claimedAt: claim.claimed_at, expiresAt: claim.expires_at, version: claim.version };
|
|
139
153
|
}
|
|
140
154
|
|
|
@@ -143,28 +157,16 @@ export async function releaseReportClaim(db: D1Database, reportId: string, sessi
|
|
|
143
157
|
.bind(reportId).first<{ reviewer_id: string; version: number }>();
|
|
144
158
|
if (!claim) return;
|
|
145
159
|
if (claim.reviewer_id !== session.actor.id && !session.permissions.manageConfiguration) {
|
|
146
|
-
throw new ApiError(409, "claim_owned_by_another_reviewer", "Only the
|
|
160
|
+
throw new ApiError(409, "claim_owned_by_another_reviewer", "Only the case owner or an administrator can return this case.");
|
|
147
161
|
}
|
|
148
|
-
|
|
149
|
-
await db.
|
|
150
|
-
db.prepare(`DELETE FROM review_claims WHERE report_id = ?1`).bind(reportId),
|
|
151
|
-
auditEvent(db, {
|
|
152
|
-
idempotencyKey: `claim-release:${reportId}:${claim.version}`,
|
|
153
|
-
action: "report.claim_released",
|
|
154
|
-
actorType: session.actor.type,
|
|
155
|
-
actorId: session.actor.id,
|
|
156
|
-
targetType: "report",
|
|
157
|
-
targetId: reportId,
|
|
158
|
-
details: { priorReviewerId: claim.reviewer_id, version: claim.version },
|
|
159
|
-
createdAt,
|
|
160
|
-
}),
|
|
161
|
-
]);
|
|
162
|
+
// Keep the legacy endpoint compatible while using the same atomic handoff.
|
|
163
|
+
await changeReportOwner(db,reportId,session,{ reviewerId:null,version:claim.version,note:"Returned to the queue." });
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
export async function loadReportDetail(
|
|
165
167
|
db: D1Database,
|
|
166
168
|
reportId: string,
|
|
167
|
-
options: { contactEncryptionKey?: string; includeReporterEmail?: boolean } = {},
|
|
169
|
+
options: { contactEncryptionKey?: string; includeReporterEmail?: boolean; includeCustomerContext?: boolean; allowedQueueIds?: string[] | null } = {},
|
|
168
170
|
): Promise<Record<string, unknown> | null> {
|
|
169
171
|
const report = await db.prepare(`
|
|
170
172
|
SELECT r.id, r.public_reference AS reference, r.source, r.state, r.priority, r.reason_code AS reasonCode,
|
|
@@ -176,10 +178,13 @@ export async function loadReportDetail(
|
|
|
176
178
|
policy.rules_json AS policyRulesJson, r.queue_version_id AS queuePolicyVersionId,
|
|
177
179
|
qv.management_mode AS queueManagementMode, qv.reviewer_instructions AS reviewerInstructions,
|
|
178
180
|
t.target_type AS targetType, t.target_reference AS targetReference,
|
|
179
|
-
t.owner_reference AS ownerReference, t.customer_url AS customerUrl,
|
|
181
|
+
t.owner_reference AS ownerReference, t.customer_url AS customerUrl, t.signed_facts_json AS customerFactsJson,
|
|
180
182
|
p.reference AS reporterReference, p.contact_mode AS reporterContactMode,
|
|
181
183
|
p.contact_ciphertext AS reporterContactCiphertext,
|
|
182
|
-
p.reference_redacted_at AS reporterReferenceRedactedAt
|
|
184
|
+
p.reference_redacted_at AS reporterReferenceRedactedAt,
|
|
185
|
+
EXISTS (SELECT 1 FROM workflow_runs wr JOIN workflow_versions wv ON wv.id = wr.workflow_version_id
|
|
186
|
+
WHERE wr.report_id = r.id AND wr.run_kind = 'primary' AND json_type(wv.graph_json, '$.playbook') = 'object'
|
|
187
|
+
AND wr.state NOT IN ('succeeded','failed','terminated','superseded')) AS playbookManaged
|
|
183
188
|
FROM reports r
|
|
184
189
|
JOIN report_targets t ON t.report_id = r.id
|
|
185
190
|
JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
|
|
@@ -190,7 +195,7 @@ export async function loadReportDetail(
|
|
|
190
195
|
LIMIT 1
|
|
191
196
|
`).bind(reportId).first<Record<string, unknown>>();
|
|
192
197
|
if (!report) return null;
|
|
193
|
-
const [answers, evidence, messages, decisions, actions, notices, aiRuns, history, claim, findings] = await Promise.all([
|
|
198
|
+
const [answers, evidence, messages, decisions, actions, notices, aiRuns, history, claim, findings, related, appeals, handoffs] = await Promise.all([
|
|
194
199
|
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>>(),
|
|
195
200
|
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>>(),
|
|
196
201
|
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>>(),
|
|
@@ -201,8 +206,15 @@ export async function loadReportDetail(
|
|
|
201
206
|
FROM decision_action_outbox WHERE report_id = ?1 ORDER BY created_at DESC, id DESC
|
|
202
207
|
`).bind(reportId).all<Record<string, unknown>>(),
|
|
203
208
|
db.prepare(`
|
|
204
|
-
SELECT id, decision_id AS decisionId, audience, delivery_state AS deliveryState, updated_at AS updatedAt
|
|
205
|
-
|
|
209
|
+
SELECT n.id, n.message_id AS messageId, n.decision_id AS decisionId, n.audience, n.delivery_state AS deliveryState, n.updated_at AS updatedAt,
|
|
210
|
+
CASE WHEN n.idempotency_key LIKE 'appeal-decision:%' THEN 'appeal' ELSE 'case' END AS purpose,
|
|
211
|
+
CASE WHEN EXISTS (SELECT 1 FROM email_deliveries e WHERE e.message_id = n.message_id) THEN 'email'
|
|
212
|
+
WHEN EXISTS (SELECT 1 FROM notification_outbox o WHERE o.notice_id = n.id) THEN 'customer_webhook' ELSE NULL END AS channel,
|
|
213
|
+
COALESCE((SELECT SUM(attempt_count) FROM email_deliveries e WHERE e.message_id = n.message_id), 0)
|
|
214
|
+
+ COALESCE((SELECT SUM(attempt_count) FROM notification_outbox o WHERE o.notice_id = n.id), 0) AS attemptCount,
|
|
215
|
+
COALESCE((SELECT last_error_code FROM email_deliveries e WHERE e.message_id = n.message_id ORDER BY e.updated_at DESC LIMIT 1),
|
|
216
|
+
(SELECT last_error_code FROM notification_outbox o WHERE o.notice_id = n.id ORDER BY o.updated_at DESC LIMIT 1)) AS errorCode
|
|
217
|
+
FROM notices n WHERE n.report_id = ?1 ORDER BY n.created_at DESC, n.id DESC
|
|
206
218
|
`).bind(reportId).all<Record<string, unknown>>(),
|
|
207
219
|
db.prepare(`
|
|
208
220
|
SELECT ar.id, ar.status, ar.mode, ar.model, ar.prompt_version AS promptVersion,
|
|
@@ -222,9 +234,9 @@ export async function loadReportDetail(
|
|
|
222
234
|
p.avatar_color AS analystAvatarColor
|
|
223
235
|
FROM review_claims c
|
|
224
236
|
LEFT JOIN analyst_profiles p ON p.actor_id = c.reviewer_id
|
|
225
|
-
WHERE c.report_id = ?1
|
|
237
|
+
WHERE c.report_id = ?1
|
|
226
238
|
LIMIT 1
|
|
227
|
-
`).bind(reportId
|
|
239
|
+
`).bind(reportId).first<Record<string, unknown>>(),
|
|
228
240
|
db.prepare(`
|
|
229
241
|
SELECT f.id, f.run_id AS runId, f.step_run_id AS stepRunId,
|
|
230
242
|
f.component_version_id AS componentVersionId, f.ai_run_id AS aiRunId,
|
|
@@ -241,6 +253,22 @@ export async function loadReportDetail(
|
|
|
241
253
|
WHERE f.report_id = ?1 ORDER BY f.created_at DESC, f.id DESC
|
|
242
254
|
LIMIT 500
|
|
243
255
|
`).bind(reportId).all<Record<string, unknown>>(),
|
|
256
|
+
db.prepare(`SELECT r.id, r.public_reference AS reference, r.state, r.reason_code AS reasonCode, r.submitted_at AS submittedAt, q.name AS queueName
|
|
257
|
+
FROM duplicate_group_members source JOIN duplicate_group_members member ON member.group_id = source.group_id
|
|
258
|
+
JOIN reports origin ON origin.id = source.report_id JOIN reports r ON r.id = member.report_id
|
|
259
|
+
LEFT JOIN queue_definitions q ON q.id = r.queue_id
|
|
260
|
+
WHERE source.report_id = ?1 AND source.removed_at IS NULL AND member.removed_at IS NULL AND r.id != ?1
|
|
261
|
+
AND (?2 = 1 OR (r.queue_id IN (SELECT value FROM json_each(?3)) AND origin.queue_id IN (SELECT value FROM json_each(?3))))
|
|
262
|
+
ORDER BY r.submitted_at, r.id LIMIT 50`)
|
|
263
|
+
.bind(reportId,options.allowedQueueIds===null?1:0,canonicalJson(options.allowedQueueIds??[])).all<Record<string,unknown>>(),
|
|
264
|
+
db.prepare(`SELECT a.id, a.appellant_audience AS audience, a.state, a.submitted_at AS submittedAt, ad.completion_state AS completionState,
|
|
265
|
+
ad.correction_required AS correctionRequired, ad.correction_confirmed_at AS correctionConfirmedAt,
|
|
266
|
+
n.delivery_state AS noticeState FROM appeals a LEFT JOIN appeal_decisions ad ON ad.appeal_id = a.id
|
|
267
|
+
LEFT JOIN notices n ON n.idempotency_key = 'appeal-decision:' || ad.id || ':notice' WHERE a.report_id = ?1 ORDER BY a.submitted_at, a.id`)
|
|
268
|
+
.bind(reportId).all<Record<string,unknown>>(),
|
|
269
|
+
db.prepare(`SELECT h.id,h.from_reviewer_id AS fromReviewerId,h.to_reviewer_id AS toReviewerId,h.note,h.created_at AS createdAt,
|
|
270
|
+
COALESCE(p.display_name,h.actor_id) AS authorName FROM report_handoffs h LEFT JOIN analyst_profiles p ON p.actor_id = h.actor_id
|
|
271
|
+
WHERE h.report_id = ?1 ORDER BY h.created_at DESC,h.id DESC LIMIT 100`).bind(reportId).all<Record<string,unknown>>(),
|
|
244
272
|
]);
|
|
245
273
|
const formFieldsValue = parsedJson(report.formFieldsJson, []);
|
|
246
274
|
const formFields = Array.isArray(formFieldsValue)
|
|
@@ -298,6 +326,11 @@ export async function loadReportDetail(
|
|
|
298
326
|
}
|
|
299
327
|
return {
|
|
300
328
|
...report,
|
|
329
|
+
relatedReports: related.results,
|
|
330
|
+
appeals: appeals.results,
|
|
331
|
+
playbookManaged: report.playbookManaged === 1,
|
|
332
|
+
customerFacts: options.includeCustomerContext ? parsedJson(report.customerFactsJson, {}) : undefined,
|
|
333
|
+
customerFactsJson: undefined,
|
|
301
334
|
reporterEmail,
|
|
302
335
|
reporterContactStatus,
|
|
303
336
|
reporterContactCiphertext: undefined,
|
|
@@ -315,7 +348,7 @@ export async function loadReportDetail(
|
|
|
315
348
|
messages: messages.results.map((row) => ({ ...row, automated: row.automated === 1 })),
|
|
316
349
|
decisions: decisions.results.map((row) => ({
|
|
317
350
|
...row,
|
|
318
|
-
evidenceReferences:
|
|
351
|
+
evidenceReferences: parsedJson(row.evidenceReferencesJson, []),
|
|
319
352
|
evidenceReferencesJson: undefined,
|
|
320
353
|
})),
|
|
321
354
|
actions: actions.results.map((row) => ({ ...row, terminal: row.terminal === 1 })),
|
|
@@ -323,7 +356,7 @@ export async function loadReportDetail(
|
|
|
323
356
|
aiRuns: aiRuns.results.map((row) => ({
|
|
324
357
|
...row,
|
|
325
358
|
uncertain: row.uncertain === 1,
|
|
326
|
-
validationErrors:
|
|
359
|
+
validationErrors: parsedJson(row.validationErrorsJson, []),
|
|
327
360
|
validationErrorsJson: undefined,
|
|
328
361
|
})),
|
|
329
362
|
history: history.results,
|
|
@@ -337,7 +370,12 @@ export async function loadReportDetail(
|
|
|
337
370
|
provenanceJson: undefined,
|
|
338
371
|
uncertaintyJson: undefined,
|
|
339
372
|
})),
|
|
340
|
-
claim: claim ? {
|
|
373
|
+
claim: claim && String(claim.expiresAt) > now() ? {
|
|
374
|
+
reviewerId: claim.reviewerId, claimedAt: claim.claimedAt, expiresAt: claim.expiresAt, version: claim.version,
|
|
375
|
+
analyst: { displayName: claim.analystDisplayName ?? claim.reviewerId, avatarUrl: claim.analystAvatarUrl ?? null, avatarColor: claim.analystAvatarColor ?? analystAvatarColor(String(claim.reviewerId)) },
|
|
376
|
+
} : null,
|
|
377
|
+
handoffs: handoffs.results,
|
|
378
|
+
assignment: claim ? {
|
|
341
379
|
reviewerId: claim.reviewerId,
|
|
342
380
|
claimedAt: claim.claimedAt,
|
|
343
381
|
expiresAt: claim.expiresAt,
|
|
@@ -390,7 +428,7 @@ export async function addReviewerMessage(
|
|
|
390
428
|
`).bind(reportId, input.idempotencyKey).first<{ id: string; deliveryState: string; deliveryChannel: ParticipantDelivery["channel"] }>();
|
|
391
429
|
if (existing) return { messageId: existing.id, jobs: [], deliveryState: existing.deliveryState, deliveryChannel: existing.deliveryChannel, idempotentReplay: true };
|
|
392
430
|
const createdAt = now();
|
|
393
|
-
await requireActiveClaim(db, reportId, session.actor.id, createdAt);
|
|
431
|
+
const claim = await requireActiveClaim(db, reportId, session.actor.id, createdAt);
|
|
394
432
|
const report = await workflowReport(db, reportId);
|
|
395
433
|
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
396
434
|
if (["closed", "resolved_by_human", "resolved_by_ai", "appealed"].includes(report.state)) {
|
|
@@ -461,7 +499,7 @@ export async function addReviewerMessage(
|
|
|
461
499
|
message: input.body,
|
|
462
500
|
}), createdAt));
|
|
463
501
|
}
|
|
464
|
-
await db
|
|
502
|
+
await commitReviewerMutation(db, report, claim, session, statements);
|
|
465
503
|
return { messageId, jobs, deliveryState: delivery.state, deliveryChannel: delivery.channel, idempotentReplay: false };
|
|
466
504
|
}
|
|
467
505
|
|
|
@@ -482,8 +520,13 @@ export async function recordHumanDecision(
|
|
|
482
520
|
SELECT id FROM decisions WHERE report_id = ?1 AND idempotency_key = ?2 LIMIT 1
|
|
483
521
|
`).bind(reportId, input.idempotencyKey).first<{ id: string }>();
|
|
484
522
|
if (existing) return { decisionId: existing.id, jobs: [], idempotentReplay: true };
|
|
523
|
+
if (await db.prepare(`SELECT 1 FROM workflow_runs wr JOIN workflow_versions wv ON wv.id = wr.workflow_version_id
|
|
524
|
+
WHERE wr.report_id = ?1 AND wr.run_kind = 'primary' AND json_type(wv.graph_json, '$.playbook') = 'object'
|
|
525
|
+
AND wr.state NOT IN ('succeeded','failed','terminated','superseded') LIMIT 1`).bind(reportId).first()) {
|
|
526
|
+
throw new ApiError(409, "playbook_review_required", "This case is following a published playbook. Complete its current review task to continue the case.");
|
|
527
|
+
}
|
|
485
528
|
const createdAt = now();
|
|
486
|
-
await requireActiveClaim(db, reportId, session.actor.id, createdAt);
|
|
529
|
+
const claim = await requireActiveClaim(db, reportId, session.actor.id, createdAt);
|
|
487
530
|
const report = await workflowReport(db, reportId);
|
|
488
531
|
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
489
532
|
if (!["human_review", "awaiting_reporter", "human_review_requested"].includes(report.state)) {
|
|
@@ -669,11 +712,57 @@ export async function recordHumanDecision(
|
|
|
669
712
|
jobs.push({ version: 1, jobId: `action:${actionId}`, type: "deliver_action", outboxId: actionId });
|
|
670
713
|
}
|
|
671
714
|
|
|
672
|
-
await db
|
|
715
|
+
await commitReviewerMutation(db, report, claim, session, statements, true);
|
|
673
716
|
return { decisionId, jobs, idempotentReplay: false };
|
|
674
717
|
}
|
|
675
718
|
|
|
676
719
|
|
|
720
|
+
export async function recordParticipantAppeal(
|
|
721
|
+
db: D1Database, reportId: string, session: OperatorSession,
|
|
722
|
+
input: { audience: ParticipantAudience; reason: string; idempotencyKey: string },
|
|
723
|
+
): Promise<{ appealId: string; idempotentReplay: boolean }> {
|
|
724
|
+
if (!session.permissions.reviewAppeals) throw new ApiError(403,"appeal_record_denied","Your role cannot record appeals.");
|
|
725
|
+
await assertOperatorReportAccess(db,session,reportId);
|
|
726
|
+
const existing = await db.prepare(`SELECT a.id, a.reason, a.appellant_audience AS audience, e.actor_id AS actorId FROM appeals a
|
|
727
|
+
LEFT JOIN audit_events e ON e.idempotency_key = 'appeal-recorded:' || a.id WHERE a.report_id = ?1 AND a.idempotency_key = ?2 LIMIT 1`)
|
|
728
|
+
.bind(reportId, input.idempotencyKey).first<{ id: string; reason: string; audience: string; actorId: string | null }>();
|
|
729
|
+
if (existing) {
|
|
730
|
+
if (existing.reason !== input.reason || existing.audience !== input.audience || existing.actorId !== session.actor.id) throw new ApiError(409,"idempotency_key_reused","This appeal request key belongs to another participant, reason or operator.");
|
|
731
|
+
return { appealId: existing.id, idempotentReplay: true };
|
|
732
|
+
}
|
|
733
|
+
const report = await db.prepare(`SELECT state FROM reports WHERE id = ?1 LIMIT 1`).bind(reportId).first<{ state: string }>();
|
|
734
|
+
if (!report || !["resolved_by_human", "resolved_by_ai", "appealed", "closed"].includes(report.state)) throw new ApiError(409, "appeal_requires_decision", "Only a resolved report can receive an appeal.");
|
|
735
|
+
const decision = await db.prepare(`SELECT id FROM decisions WHERE report_id = ?1 ORDER BY created_at DESC, id DESC LIMIT 1`).bind(reportId).first<{ id: string }>();
|
|
736
|
+
if (!decision) throw new ApiError(409, "appeal_requires_decision", "The report has no decision to appeal.");
|
|
737
|
+
const participant = await db.prepare(`SELECT id FROM report_participants WHERE report_id = ?1 AND audience = ?2 AND reference_redacted_at IS NULL LIMIT 1`).bind(reportId, input.audience).first();
|
|
738
|
+
if (!participant) throw new ApiError(400, "appeal_participant_missing", "This report has no participant with the selected audience.");
|
|
739
|
+
const queue = await db.prepare(`SELECT id FROM queue_definitions WHERE queue_kind = 'appeal' AND enabled = 1 AND status = 'active' ORDER BY position, id LIMIT 1`).first<{ id: string }>();
|
|
740
|
+
if (!queue) throw new ApiError(409, "appeal_queue_unavailable", "Enable an appeal queue before recording an appeal.");
|
|
741
|
+
const appealId = crypto.randomUUID();
|
|
742
|
+
const createdAt = now();
|
|
743
|
+
await db.batch([
|
|
744
|
+
db.prepare(`INSERT OR IGNORE INTO appeals (id, report_id, decision_id, appellant_audience, reason, state, queue_id, submitted_at, idempotency_key)
|
|
745
|
+
SELECT ?1, ?2, ?3, ?4, ?5, 'submitted', ?6, ?7, ?8 FROM reports r WHERE r.id = ?2 AND r.state = ?9
|
|
746
|
+
AND (?10 = 1 OR r.queue_id IN (SELECT value FROM json_each(?11)))
|
|
747
|
+
AND EXISTS (SELECT 1 FROM report_participants WHERE report_id = ?2 AND audience = ?4 AND reference_redacted_at IS NULL)
|
|
748
|
+
AND EXISTS (SELECT 1 FROM decisions WHERE id = ?3 AND sensitive_redacted_at IS NULL)
|
|
749
|
+
AND ?3 = (SELECT id FROM decisions WHERE report_id = ?2 ORDER BY created_at DESC, id DESC LIMIT 1)
|
|
750
|
+
AND EXISTS (SELECT 1 FROM queue_definitions WHERE id = ?6 AND queue_kind = 'appeal' AND enabled = 1 AND status = 'active')`)
|
|
751
|
+
.bind(appealId, reportId, decision.id, input.audience, input.reason, queue.id, createdAt, input.idempotencyKey, report.state,session.queueScope==null?1:0,canonicalJson(session.queueScope??[])),
|
|
752
|
+
db.prepare(`UPDATE reports SET state = 'appealed', updated_at = ?2 WHERE id = ?1 AND EXISTS (SELECT 1 FROM appeals WHERE id = ?3)`).bind(reportId, createdAt, appealId),
|
|
753
|
+
db.prepare(`INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at) SELECT ?1, ?2, ?3, 'appealed', 'participant_appeal_recorded', ?4, ?5, ?6 WHERE EXISTS (SELECT 1 FROM appeals WHERE id = ?7)`)
|
|
754
|
+
.bind(crypto.randomUUID(), reportId, report.state, session.actor.type, session.actor.id, createdAt, appealId),
|
|
755
|
+
db.prepare(`INSERT INTO audit_events (id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at) SELECT ?1, ?2, 'appeal.recorded', ?3, ?4, 'appeal', ?5, ?6, ?7 WHERE EXISTS (SELECT 1 FROM appeals WHERE id = ?5)`)
|
|
756
|
+
.bind(crypto.randomUUID(), `appeal-recorded:${appealId}`, session.actor.type, session.actor.id, appealId, canonicalJson({ reportId, decisionId: decision.id, audience: input.audience }), createdAt),
|
|
757
|
+
]);
|
|
758
|
+
const saved = await db.prepare(`SELECT a.id, a.idempotency_key AS idempotencyKey, a.reason, e.actor_id AS actorId FROM appeals a
|
|
759
|
+
LEFT JOIN audit_events e ON e.idempotency_key = 'appeal-recorded:' || a.id WHERE a.decision_id = ?1 AND a.appellant_audience = ?2 LIMIT 1`)
|
|
760
|
+
.bind(decision.id, input.audience).first<{ id: string; idempotencyKey: string; reason: string; actorId: string | null }>();
|
|
761
|
+
if (!saved || saved.idempotencyKey !== input.idempotencyKey) throw new ApiError(409, "appeal_already_recorded", "An appeal for this participant and decision already exists.");
|
|
762
|
+
if (saved.reason !== input.reason || saved.actorId !== session.actor.id) throw new ApiError(409,"idempotency_key_reused","This appeal request key belongs to another operator or reason.");
|
|
763
|
+
return { appealId: saved.id, idempotentReplay: saved.id !== appealId };
|
|
764
|
+
}
|
|
765
|
+
|
|
677
766
|
export async function listAppeals(db: D1Database, limit: number, allowedQueueIds?: string[] | null): Promise<Array<Record<string, unknown>>> {
|
|
678
767
|
const result = await db.prepare(`
|
|
679
768
|
SELECT a.id, a.report_id AS reportId, r.public_reference AS reportReference,
|
|
@@ -711,9 +800,14 @@ export async function loadAppealDetail(db: D1Database, appealId: string): Promis
|
|
|
711
800
|
`).bind(appealId, now()).first<Record<string, unknown>>();
|
|
712
801
|
if (!appeal) return null;
|
|
713
802
|
const decisions = await db.prepare(`
|
|
714
|
-
SELECT id, outcome, rationale, reviewer_id AS reviewerId,
|
|
715
|
-
corrective_action_id AS correctiveActionId, created_at AS createdAt
|
|
716
|
-
|
|
803
|
+
SELECT ad.id, ad.outcome, ad.rationale, ad.reviewer_id AS reviewerId,
|
|
804
|
+
ad.corrective_action_id AS correctiveActionId, ad.created_at AS createdAt,
|
|
805
|
+
ad.completion_state AS completionState, ad.correction_required AS correctionRequired,
|
|
806
|
+
ad.correction_reference AS correctionReference, ad.correction_note AS correctionNote,
|
|
807
|
+
ad.correction_confirmed_by AS correctionConfirmedBy, ad.correction_confirmed_at AS correctionConfirmedAt,
|
|
808
|
+
n.delivery_state AS noticeState
|
|
809
|
+
FROM appeal_decisions ad LEFT JOIN notices n ON n.idempotency_key = 'appeal-decision:' || ad.id || ':notice'
|
|
810
|
+
WHERE ad.appeal_id = ?1 ORDER BY ad.created_at, ad.id
|
|
717
811
|
`).bind(appealId).all<Record<string, unknown>>();
|
|
718
812
|
return { ...appeal, decisions: decisions.results };
|
|
719
813
|
}
|
|
@@ -723,6 +817,10 @@ export async function releaseAppealClaim(
|
|
|
723
817
|
appealId: string,
|
|
724
818
|
session: OperatorSession,
|
|
725
819
|
): Promise<void> {
|
|
820
|
+
if (!session.permissions.reviewAppeals) throw new ApiError(403,"appeal_claim_denied","Your role cannot review appeals.");
|
|
821
|
+
const appeal = await db.prepare(`SELECT report_id AS reportId FROM appeals WHERE id = ?1`).bind(appealId).first<{reportId:string}>();
|
|
822
|
+
if (!appeal) throw new ApiError(404,"appeal_not_found","The appeal does not exist.");
|
|
823
|
+
await assertOperatorReportAccess(db,session,appeal.reportId);
|
|
726
824
|
const claim = await db.prepare(`SELECT reviewer_id AS reviewerId, version FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
|
|
727
825
|
.bind(appealId).first<{ reviewerId: string; version: number }>();
|
|
728
826
|
if (!claim) return;
|
|
@@ -730,20 +828,16 @@ export async function releaseAppealClaim(
|
|
|
730
828
|
throw new ApiError(409, "appeal_claim_owned_by_another_reviewer", "Only the current reviewer or an assignment manager can release this claim.");
|
|
731
829
|
}
|
|
732
830
|
const createdAt = now();
|
|
733
|
-
await db.batch([
|
|
734
|
-
db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
targetType: "appeal",
|
|
742
|
-
targetId: appealId,
|
|
743
|
-
details: { priorReviewerId: claim.reviewerId, version: claim.version },
|
|
744
|
-
createdAt,
|
|
745
|
-
}),
|
|
831
|
+
const result = await db.batch([
|
|
832
|
+
db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1 AND version = ?2 AND reviewer_id = ?3
|
|
833
|
+
AND EXISTS (SELECT 1 FROM appeals a JOIN reports r ON r.id = a.report_id WHERE a.id = ?1 AND a.state = 'human_review'
|
|
834
|
+
AND (?4 = 1 OR r.queue_id IN (SELECT value FROM json_each(?5))))`).bind(appealId,claim.version,claim.reviewerId,session.queueScope==null?1:0,canonicalJson(session.queueScope??[])),
|
|
835
|
+
db.prepare(`UPDATE appeals SET state = 'submitted' WHERE id = ?1 AND state = 'human_review' AND changes() = 1`).bind(appealId),
|
|
836
|
+
db.prepare(`INSERT INTO audit_events(id,idempotency_key,action,actor_type,actor_id,target_type,target_id,details_json,created_at)
|
|
837
|
+
SELECT ?1,?2,'appeal.claim_released',?3,?4,'appeal',?5,?6,?7 WHERE changes() = 1`)
|
|
838
|
+
.bind(crypto.randomUUID(),`appeal-claim-release:${appealId}:${claim.version}`,session.actor.type,session.actor.id,appealId,canonicalJson({priorReviewerId:claim.reviewerId,version:claim.version}),createdAt),
|
|
746
839
|
]);
|
|
840
|
+
if (result[0]?.meta.changes !== 1) throw new ApiError(409,"appeal_claim_changed","The appeal claim changed. Refresh before releasing it.");
|
|
747
841
|
}
|
|
748
842
|
|
|
749
843
|
export async function claimAppeal(
|
|
@@ -752,200 +846,40 @@ export async function claimAppeal(
|
|
|
752
846
|
session: OperatorSession,
|
|
753
847
|
claimTtlMinutes: number,
|
|
754
848
|
): Promise<{ reviewerId: string; claimedAt: string; expiresAt: string; version: number }> {
|
|
849
|
+
if (!session.permissions.reviewAppeals) throw new ApiError(403,"appeal_claim_denied","Your role cannot review appeals.");
|
|
755
850
|
const appeal = await db.prepare(`
|
|
756
|
-
SELECT a.id, a.state, d.maker_type, d.maker_id
|
|
851
|
+
SELECT a.id, a.report_id, a.state, d.maker_type, d.maker_id
|
|
757
852
|
FROM appeals a JOIN decisions d ON d.id = a.decision_id
|
|
758
853
|
WHERE a.id = ?1 LIMIT 1
|
|
759
|
-
`).bind(appealId).first<{ id: string; state: string; maker_type: string; maker_id: string }>();
|
|
854
|
+
`).bind(appealId).first<{ id: string; report_id: string; state: string; maker_type: string; maker_id: string }>();
|
|
760
855
|
if (!appeal) throw new ApiError(404, "appeal_not_found", "The appeal does not exist.");
|
|
856
|
+
await assertOperatorReportAccess(db,session,appeal.report_id);
|
|
761
857
|
if (!["submitted", "human_review"].includes(appeal.state)) throw new ApiError(409, "appeal_not_claimable", "The appeal is not awaiting human review.");
|
|
762
858
|
if (appeal.maker_type === "human" && appeal.maker_id === session.actor.id) {
|
|
763
859
|
throw new ApiError(409, "independent_appeal_reviewer_required", "The original decision maker cannot claim this appeal.");
|
|
764
860
|
}
|
|
765
861
|
const claimedAt = now();
|
|
766
862
|
const expiresAt = addMinutes(claimedAt, Math.max(1, Math.min(1_440, claimTtlMinutes)));
|
|
767
|
-
await db.batch([
|
|
863
|
+
const written = await db.batch([
|
|
768
864
|
db.prepare(`
|
|
769
865
|
INSERT INTO appeal_claims (appeal_id, reviewer_id, claimed_at, expires_at, version)
|
|
770
|
-
|
|
866
|
+
SELECT ?1, ?2, ?3, ?4, 1 FROM appeals a JOIN decisions d ON d.id = a.decision_id JOIN reports r ON r.id = a.report_id
|
|
867
|
+
WHERE a.id = ?1 AND a.state IN ('submitted','human_review') AND a.reason_redacted_at IS NULL AND r.state = 'appealed'
|
|
868
|
+
AND d.maker_id != ?2 AND (?5 = 1 OR r.queue_id IN (SELECT value FROM json_each(?6)))
|
|
771
869
|
ON CONFLICT(appeal_id) DO UPDATE SET reviewer_id = excluded.reviewer_id,
|
|
772
870
|
claimed_at = excluded.claimed_at, expires_at = excluded.expires_at,
|
|
773
871
|
version = appeal_claims.version + 1
|
|
774
872
|
WHERE appeal_claims.expires_at <= ?3 OR appeal_claims.reviewer_id = ?2
|
|
775
|
-
`).bind(appealId, session.actor.id, claimedAt, expiresAt),
|
|
776
|
-
db.prepare(`UPDATE appeals SET state = 'human_review' WHERE id = ?1 AND state = 'submitted'`).bind(appealId),
|
|
873
|
+
`).bind(appealId, session.actor.id, claimedAt, expiresAt,session.queueScope==null?1:0,canonicalJson(session.queueScope??[])),
|
|
874
|
+
db.prepare(`UPDATE appeals SET state = 'human_review' WHERE id = ?1 AND state = 'submitted' AND changes() = 1`).bind(appealId),
|
|
777
875
|
]);
|
|
876
|
+
if (written[0]?.meta.changes !== 1) throw new ApiError(409,"appeal_claim_changed","The appeal or claim changed. Refresh before claiming it.");
|
|
778
877
|
const claim = await db.prepare(`SELECT reviewer_id, claimed_at, expires_at, version FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
|
|
779
878
|
.bind(appealId).first<{ reviewer_id: string; claimed_at: string; expires_at: string; version: number }>();
|
|
780
879
|
if (!claim || claim.reviewer_id !== session.actor.id) throw new ApiError(409, "appeal_already_claimed", "Another appeal reviewer currently holds this appeal.");
|
|
781
880
|
return { reviewerId: claim.reviewer_id, claimedAt: claim.claimed_at, expiresAt: claim.expires_at, version: claim.version };
|
|
782
881
|
}
|
|
783
882
|
|
|
784
|
-
export async function decideAppeal(
|
|
785
|
-
db: D1Database,
|
|
786
|
-
appealId: string,
|
|
787
|
-
session: OperatorSession,
|
|
788
|
-
input: AppealDecisionInput,
|
|
789
|
-
options: DecisionOptions,
|
|
790
|
-
): Promise<{ appealDecisionId: string; jobs: DeliveryJob[]; idempotentReplay: boolean }> {
|
|
791
|
-
const existing = await db.prepare(`
|
|
792
|
-
SELECT id FROM appeal_decisions WHERE appeal_id = ?1 AND idempotency_key = ?2 LIMIT 1
|
|
793
|
-
`).bind(appealId, input.idempotencyKey).first<{ id: string }>();
|
|
794
|
-
if (existing) return { appealDecisionId: existing.id, jobs: [], idempotentReplay: true };
|
|
795
|
-
const createdAt = now();
|
|
796
|
-
const appeal = await db.prepare(`
|
|
797
|
-
SELECT a.id, a.report_id, a.decision_id, a.appellant_audience, a.state, a.submitted_at,
|
|
798
|
-
d.maker_id AS original_maker_id, r.public_reference, r.state AS report_state,
|
|
799
|
-
r.policy_version_id, t.target_reference,
|
|
800
|
-
p.reference AS participant_reference
|
|
801
|
-
FROM appeals a
|
|
802
|
-
JOIN decisions d ON d.id = a.decision_id
|
|
803
|
-
JOIN reports r ON r.id = a.report_id
|
|
804
|
-
JOIN report_targets t ON t.report_id = r.id
|
|
805
|
-
JOIN report_participants p ON p.report_id = r.id AND p.audience = a.appellant_audience
|
|
806
|
-
WHERE a.id = ?1
|
|
807
|
-
LIMIT 1
|
|
808
|
-
`).bind(appealId).first<{
|
|
809
|
-
id: string;
|
|
810
|
-
report_id: string;
|
|
811
|
-
decision_id: string;
|
|
812
|
-
appellant_audience: ParticipantAudience;
|
|
813
|
-
state: string;
|
|
814
|
-
submitted_at: string;
|
|
815
|
-
original_maker_id: string;
|
|
816
|
-
public_reference: string;
|
|
817
|
-
report_state: ReportState;
|
|
818
|
-
policy_version_id: string | null;
|
|
819
|
-
target_reference: string;
|
|
820
|
-
participant_reference: string | null;
|
|
821
|
-
}>();
|
|
822
|
-
if (!appeal) throw new ApiError(404, "appeal_not_found", "The appeal does not exist.");
|
|
823
|
-
if (appeal.state !== "human_review") throw new ApiError(409, "appeal_not_decidable", "The appeal is not awaiting a human decision.");
|
|
824
|
-
if (appeal.original_maker_id === session.actor.id) {
|
|
825
|
-
throw new ApiError(409, "independent_appeal_reviewer_required", "The original decision maker cannot decide this appeal.");
|
|
826
|
-
}
|
|
827
|
-
const claim = await db.prepare(`SELECT reviewer_id, expires_at FROM appeal_claims WHERE appeal_id = ?1 LIMIT 1`)
|
|
828
|
-
.bind(appealId).first<{ reviewer_id: string; expires_at: string }>();
|
|
829
|
-
if (!claim || claim.reviewer_id !== session.actor.id || claim.expires_at <= createdAt) {
|
|
830
|
-
throw new ApiError(409, "active_appeal_claim_required", "Claim this appeal before deciding it.");
|
|
831
|
-
}
|
|
832
|
-
if (input.correctiveActionCode && (!options.actionEnabled || !options.allowedActionCodes.has(input.correctiveActionCode))) {
|
|
833
|
-
throw new ApiError(400, "action_not_allowed", "The corrective application action is not allowlisted for this deployment.");
|
|
834
|
-
}
|
|
835
|
-
const appealDecisionId = crypto.randomUUID();
|
|
836
|
-
const messageId = crypto.randomUUID();
|
|
837
|
-
const noticeId = crypto.randomUUID();
|
|
838
|
-
const notificationId = crypto.randomUUID();
|
|
839
|
-
const actionId = input.correctiveActionCode ? crypto.randomUUID() : null;
|
|
840
|
-
const templateId = appeal.appellant_audience === "affected_user" ? "appeal-outcome-v1" : "reporter-outcome-v1";
|
|
841
|
-
const jobs: DeliveryJob[] = [];
|
|
842
|
-
const statements: D1PreparedStatement[] = [
|
|
843
|
-
db.prepare(`
|
|
844
|
-
INSERT INTO appeal_decisions (
|
|
845
|
-
id, appeal_id, outcome, rationale, reviewer_id, corrective_action_id, created_at, idempotency_key
|
|
846
|
-
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
|
847
|
-
`).bind(appealDecisionId, appealId, input.outcome, input.rationale, session.actor.id, actionId, createdAt, input.idempotencyKey),
|
|
848
|
-
db.prepare(`UPDATE appeals SET state = ?2, resolved_at = ?3 WHERE id = ?1`).bind(appealId, input.outcome, createdAt),
|
|
849
|
-
db.prepare(`UPDATE reports SET state = 'closed', queue_id = NULL, updated_at = ?2 WHERE id = ?1`)
|
|
850
|
-
.bind(appeal.report_id, createdAt),
|
|
851
|
-
db.prepare(`
|
|
852
|
-
INSERT INTO report_state_history (id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at)
|
|
853
|
-
VALUES (?1, ?2, ?3, 'closed', ?4, ?5, ?6, ?7)
|
|
854
|
-
`).bind(crypto.randomUUID(), appeal.report_id, appeal.report_state, `appeal_${input.outcome}`, session.actor.type, session.actor.id, createdAt),
|
|
855
|
-
db.prepare(`
|
|
856
|
-
INSERT INTO case_messages (
|
|
857
|
-
id, report_id, audience, direction, sender_type, sender_id, body, template_version_id,
|
|
858
|
-
automated, delivery_state, created_at, idempotency_key
|
|
859
|
-
) VALUES (?1, ?2, ?3, 'outbound', 'human', ?4, ?5, ?6, 0, ?7, ?8, ?9)
|
|
860
|
-
`).bind(
|
|
861
|
-
messageId, appeal.report_id, appeal.appellant_audience, session.actor.id, input.participantNotice,
|
|
862
|
-
templateId, options.notificationEnabled ? "queued" : "suppressed", createdAt,
|
|
863
|
-
`appeal-decision:${appealDecisionId}:message`,
|
|
864
|
-
),
|
|
865
|
-
db.prepare(`
|
|
866
|
-
INSERT INTO notices (
|
|
867
|
-
id, report_id, decision_id, audience, template_version_id, rendered_body,
|
|
868
|
-
delivery_state, created_at, updated_at, idempotency_key, message_id
|
|
869
|
-
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8, ?9, ?10)
|
|
870
|
-
`).bind(
|
|
871
|
-
noticeId, appeal.report_id, appeal.decision_id, appeal.appellant_audience, templateId,
|
|
872
|
-
input.participantNotice, options.notificationEnabled ? "queued" : "suppressed", createdAt,
|
|
873
|
-
`appeal-decision:${appealDecisionId}:notice`, messageId,
|
|
874
|
-
),
|
|
875
|
-
db.prepare(`DELETE FROM appeal_claims WHERE appeal_id = ?1`).bind(appealId),
|
|
876
|
-
auditEvent(db, {
|
|
877
|
-
idempotencyKey: `appeal-decision:${appealId}:${input.idempotencyKey}`,
|
|
878
|
-
action: "appeal.human_decision_recorded",
|
|
879
|
-
actorType: session.actor.type,
|
|
880
|
-
actorId: session.actor.id,
|
|
881
|
-
targetType: "appeal_decision",
|
|
882
|
-
targetId: appealDecisionId,
|
|
883
|
-
details: {
|
|
884
|
-
appealId,
|
|
885
|
-
reportId: appeal.report_id,
|
|
886
|
-
outcome: input.outcome,
|
|
887
|
-
correctiveActionCode: input.correctiveActionCode ?? null,
|
|
888
|
-
},
|
|
889
|
-
createdAt,
|
|
890
|
-
}),
|
|
891
|
-
];
|
|
892
|
-
if (input.outcome === "reversed") {
|
|
893
|
-
statements.push(db.prepare(`
|
|
894
|
-
INSERT INTO ai_usage_daily (usage_date, config_version_id, appeal_reversals, updated_at)
|
|
895
|
-
SELECT substr(?2, 1, 10), ar.config_version_id, 1, ?2
|
|
896
|
-
FROM decisions d JOIN ai_runs ar ON ar.id = d.ai_run_id
|
|
897
|
-
WHERE d.id = ?1
|
|
898
|
-
ON CONFLICT(usage_date, config_version_id) DO UPDATE SET
|
|
899
|
-
appeal_reversals = appeal_reversals + 1, updated_at = excluded.updated_at
|
|
900
|
-
`).bind(appeal.decision_id, createdAt));
|
|
901
|
-
}
|
|
902
|
-
if (options.notificationEnabled) {
|
|
903
|
-
statements.push(db.prepare(`
|
|
904
|
-
INSERT INTO notification_outbox (
|
|
905
|
-
id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
|
|
906
|
-
) VALUES (?1, ?2, ?3, 'customer_webhook', ?4, 'pending', ?5, ?5, ?5)
|
|
907
|
-
`).bind(notificationId, noticeId, `notice:${noticeId}`, canonicalJson({
|
|
908
|
-
version: 1,
|
|
909
|
-
event: "appeal.outcome.available",
|
|
910
|
-
notification_id: notificationId,
|
|
911
|
-
report_reference: appeal.public_reference,
|
|
912
|
-
...(appeal.participant_reference ? { recipient_reference: appeal.participant_reference } : {}),
|
|
913
|
-
audience: appeal.appellant_audience,
|
|
914
|
-
message: input.participantNotice,
|
|
915
|
-
}), createdAt));
|
|
916
|
-
jobs.push({ version: 1, jobId: `notification:${notificationId}`, type: "deliver_notification", outboxId: notificationId });
|
|
917
|
-
}
|
|
918
|
-
if (input.correctiveActionCode && actionId) {
|
|
919
|
-
statements.push(db.prepare(`
|
|
920
|
-
INSERT INTO decision_action_outbox (
|
|
921
|
-
id, report_id, decision_id, idempotency_key, action_code, target_reference,
|
|
922
|
-
payload_json, status, next_attempt_at, created_at, updated_at
|
|
923
|
-
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8, ?8, ?8)
|
|
924
|
-
`).bind(
|
|
925
|
-
actionId, appeal.report_id, appeal.decision_id,
|
|
926
|
-
`appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,
|
|
927
|
-
input.correctiveActionCode, appeal.target_reference,
|
|
928
|
-
canonicalJson({
|
|
929
|
-
version: 1,
|
|
930
|
-
action_id: actionId,
|
|
931
|
-
idempotency_key: `appeal-action:${appealDecisionId}:${input.correctiveActionCode}`,
|
|
932
|
-
report_id: appeal.report_id,
|
|
933
|
-
report_reference: appeal.public_reference,
|
|
934
|
-
original_decision_id: appeal.decision_id,
|
|
935
|
-
appeal_id: appealId,
|
|
936
|
-
appeal_decision_id: appealDecisionId,
|
|
937
|
-
action_code: input.correctiveActionCode,
|
|
938
|
-
target_reference: appeal.target_reference,
|
|
939
|
-
policy_version_id: appeal.policy_version_id,
|
|
940
|
-
}),
|
|
941
|
-
createdAt,
|
|
942
|
-
));
|
|
943
|
-
jobs.push({ version: 1, jobId: `action:${actionId}`, type: "deliver_action", outboxId: actionId });
|
|
944
|
-
}
|
|
945
|
-
await db.batch(statements);
|
|
946
|
-
return { appealDecisionId, jobs, idempotentReplay: false };
|
|
947
|
-
}
|
|
948
|
-
|
|
949
883
|
export async function chainPendingAuditEvents(db: D1Database, limit = 50): Promise<void> {
|
|
950
884
|
const pending = await db.prepare(`
|
|
951
885
|
SELECT id, action, actor_type, actor_id, target_type, target_id, details_json, created_at
|