diff --git a/drizzle/0023_subject_portal.sql b/drizzle/0023_subject_portal.sql new file mode 100644 index 00000000..a3f7c948 --- /dev/null +++ b/drizzle/0023_subject_portal.sql @@ -0,0 +1,42 @@ +-- Subject portal (WP3): consumer self-check + subject-facing portal. +-- Access tokens are stored ONLY as SHA-256 hashes (same scheme as +-- api_tokens."tokenHash"); dispute statements are stored encrypted +-- (Vault Transit envelope) with a SHA-256 integrity digest. No plaintext PII. + +BEGIN; + +-- consent_purpose gains the self-check purpose used by the subject portal. +ALTER TYPE consent_purpose ADD VALUE IF NOT EXISTS 'consumer_self_check'; + +CREATE TYPE subject_access_token_purpose AS ENUM ('self_check', 'status', 'dispute'); +CREATE TYPE subject_dispute_status AS ENUM ('received', 'under_review', 'resolved'); + +CREATE TABLE IF NOT EXISTS subject_access_tokens ( + id UUID PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + candidate_id INTEGER NOT NULL REFERENCES candidate_profiles(id) ON DELETE RESTRICT, + token_hash TEXT NOT NULL UNIQUE, + purpose subject_access_token_purpose NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS sat_candidate_idx ON subject_access_tokens (tenant_id, candidate_id); +CREATE INDEX IF NOT EXISTS sat_expiry_idx ON subject_access_tokens (expires_at); + +CREATE TABLE IF NOT EXISTS subject_disputes ( + id UUID PRIMARY KEY, + tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + candidate_id INTEGER NOT NULL REFERENCES candidate_profiles(id) ON DELETE RESTRICT, + case_id UUID REFERENCES informal_verification_cases(id) ON DELETE RESTRICT, + statement_sha256 CHAR(64) NOT NULL CHECK (statement_sha256 ~ '^[0-9a-f]{64}$'), + statement_enc TEXT, + status subject_dispute_status NOT NULL DEFAULT 'received', + resolution TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS sd_candidate_idx ON subject_disputes (tenant_id, candidate_id); +CREATE INDEX IF NOT EXISTS sd_status_idx ON subject_disputes (tenant_id, status); + +COMMIT; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5fb34f56..40326288 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1788631200000, "tag": "0022_payment_reconciliation_cases", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1788634800000, + "tag": "0023_subject_portal", + "breakpoints": true } ] } diff --git a/server/dataCompleteness.ts b/server/dataCompleteness.ts new file mode 100644 index 00000000..7a8340d6 --- /dev/null +++ b/server/dataCompleteness.ts @@ -0,0 +1,84 @@ +/** + * server/dataCompleteness.ts + * + * Thin-file / data-completeness scoring, extracted from server/routers.ts + * (getDataCompleteness) so both the operator-facing procedure and the + * subject-facing portal (server/subjectPortal.ts) compute the SAME score. + * Do not fork this logic — extend it here. + */ +import { TRPCError } from "@trpc/server"; +import { and, eq, inArray } from "drizzle-orm"; +import { + fieldVisitReports, + investigations, + kycRecords, + screeningOrders, + screeningResults, +} from "../drizzle/schema"; + +// Minimal structural type for the Drizzle handle so this module does not +// depend on server/db.ts (keeps unit-test mocking trivial). +export type DbHandle = { + select: (...args: any[]) => any; +}; + +export function getFallbackSuggestion(source: string): string { + const map: Record = { + nin_trace: 'Request NIN slip or NIMC self-service printout from subject', + bvn_fraud_check: 'Request recent bank statement (last 3 months) as alternative', + npf_criminal: 'Request sworn affidavit of good character from magistrate court', + efcc_watchlist: 'Cross-check against INTERPOL Red Notice list manually', + pep_check: 'Search public records: INEC portal, FIRS TCC, NASS website', + adverse_media_ng: 'Run manual Google News search with subject name + "fraud" / "court"', + cac_full_profile: 'Request certified true copy of Certificate of Incorporation', + firs_tax_clearance: 'Request TCC (Tax Clearance Certificate) from entity directly', + beneficial_owner: 'Request CAC Form CO2 (Return of Allotment) from entity', + corporate_sanctions: 'Cross-check OFAC SDN list and UN consolidated sanctions list', + }; + return map[source] ?? 'Request supporting documentation from subject directly'; +} + +export type DataCompletenessReport = { + score: number; + sourcesChecked: number; + sourcesTotal: number; + thinFile: boolean; + coverage: { source: string; label: string; hasData: boolean; fallback: string }[]; + missingCritical: string[]; +}; + +export async function computeDataCompleteness(db: DbHandle, investigationRef: string): Promise { + const [inv] = await db.select().from(investigations).where(eq(investigations.ref, investigationRef)).limit(1); + if (!inv) throw new TRPCError({ code: 'NOT_FOUND' }); + const isCorperate = inv.subjectType === 'corporate'; + const expectedSources = isCorperate + ? ['cac_full_profile', 'firs_tax_clearance', 'beneficial_owner', 'corporate_sanctions'] + : ['nin_trace', 'bvn_fraud_check', 'npf_criminal', 'efcc_watchlist', 'pep_check', 'adverse_media_ng']; + // screeningResults links via screeningOrders.investigationRef + const orderRows = await db.select({ id: screeningOrders.id, types: screeningOrders.screeningTypes }) + .from(screeningOrders).where(eq(screeningOrders.investigationRef, investigationRef)); + const orderIds = orderRows.map((o: any) => o.id); + const screeningRows = orderIds.length > 0 + ? await db.select().from(screeningResults).where(and(inArray(screeningResults.orderId, orderIds), eq(screeningResults.status, 'completed'))) + : []; + const completedTypes = new Set(screeningRows.map((r: any) => r.screeningType)); + const kycRows = await db.select().from(kycRecords).where(eq(kycRecords.investigationId, inv.id)).limit(1); + const hasKyc = kycRows.length > 0 && kycRows[0].status !== 'pending'; + const visitRows = await db.select().from(fieldVisitReports).where(eq(fieldVisitReports.investigationId, inv.id)).limit(1); + const hasFieldVisit = visitRows.length > 0 && visitRows[0].submittedAt != null; + const coverage = expectedSources.map(src => ({ + source: src, + label: src.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase()), + hasData: completedTypes.has(src), + fallback: getFallbackSuggestion(src), + })); + const bonusSources = [ + { source: 'kyc_identity', label: 'KYC Identity Verification', hasData: hasKyc, fallback: 'Request government-issued ID document upload' }, + { source: 'field_visit', label: 'Field Visit / Physical Verification', hasData: hasFieldVisit, fallback: 'Dispatch field agent for address verification' }, + ]; + const allCoverage = [...coverage, ...bonusSources]; + const sourcesWithData = allCoverage.filter(c => c.hasData).length; + const score = Math.round((sourcesWithData / allCoverage.length) * 100); + const thinFile = score < 40; + return { score, sourcesChecked: sourcesWithData, sourcesTotal: allCoverage.length, thinFile, coverage: allCoverage, missingCritical: coverage.filter(c => !c.hasData).map(c => c.source) }; +} diff --git a/server/subject-portal.test.ts b/server/subject-portal.test.ts new file mode 100644 index 00000000..11280b6c --- /dev/null +++ b/server/subject-portal.test.ts @@ -0,0 +1,432 @@ +/** + * server/subject-portal.test.ts + * + * WP3 subject portal tests. Uses the repo's established pool-mock precedent + * (see consumerDisputeDeadlineEscalation.test.ts): a stateful in-memory SQL + * dispatch so token lifecycle, consent gating, dispute wiring, tenant + * isolation, and minimal disclosure are exercised against real router logic. + */ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.fn(); +const release = vi.fn(); +const connect = vi.fn(async () => ({ query, release })); + +// Drizzle handle fake: computeDataCompleteness selects by table identity. +const drizzleRows = new Map[]>(); +const drizzleInsert = vi.fn(async () => []); +const fakeDrizzle = { + select: (_fields?: unknown) => ({ + from: (table: unknown) => { + const result = drizzleRows.get(table) ?? []; + const chain: any = { + where: () => chain, + orderBy: () => chain, + limit: () => chain, + then: (res: (v: unknown) => unknown, rej: (e: unknown) => unknown) => + Promise.resolve(result).then(res, rej), + }; + return chain; + }, + }), + insert: (_table: unknown) => ({ values: (_vals: unknown) => drizzleInsert() }), +}; + +vi.mock("./db", () => ({ + getPgPool: vi.fn(async () => ({ connect })), + getDb: vi.fn(async () => fakeDrizzle), +})); +vi.mock("./permify", () => ({ permifyCheck: vi.fn(async () => true) })); +vi.mock("./_core/env", () => ({ + ENV: { + isProduction: false, + auditHmacSecret: "test-audit-hmac-key", + eventProcessorUrl: "http://localhost:8083", + bisGatewayKey: "test-gateway-key", + }, +})); +vi.mock("./piiKeyRegistry", () => ({ + activeTenantEncryptionRegistry: vi.fn(async () => ({ + id: 1, tenantId: 7, keyVersion: "kv1", externalKeyRef: "transit/keys/bis-t7", + provider: "vault_transit", providerKeyName: "bis-t7", providerKeyVersion: 3, status: "active", + })), +})); +vi.mock("./piiEnvelopeCrypto", () => ({ + piiAad: vi.fn(() => "bis-pii-envelope:v2|7|candidate_profile|41|subject-dispute:test"), + encryptPiiEnvelope: vi.fn(async () => ({ + ciphertext: Buffer.from("vault:v3:dGVzdC1jaXBoZXJ0ZXh0", "utf8"), + nonce: null, + keyVersion: "kv1", + providerKeyVersion: 3, + plaintextSha256: "0".repeat(64), + cryptoProvider: "vault_transit", + })), +})); + +const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({}) })); +vi.stubGlobal("fetch", fetchMock); + +import { subjectPortalRouter } from "./subjectPortal"; +import { fieldVisitReports, investigations, kycRecords, screeningOrders, screeningResults } from "../drizzle/schema"; + +// ─── Stateful in-memory "PostgreSQL" ───────────────────────────────────────── + +const TENANT = 7; +const OTHER_TENANT = 8; + +type Store = { + tokens: any[]; + disputes: any[]; + cases: any[]; + caseEvents: any[]; + candidates: any[]; + consents: any[]; + investigations: any[]; + references: any[]; + nextCandidateId: number; +}; +let store: Store; + +function resetStore() { + store = { + tokens: [], disputes: [], cases: [], caseEvents: [], + candidates: [], consents: [], investigations: [], references: [], + nextCandidateId: 41, + }; +} + +function installSqlBehavior() { + query.mockImplementation(async (stmt: string, params: any[] = []) => { + if (stmt === "BEGIN" || stmt === "COMMIT" || stmt === "ROLLBACK") return { rows: [], rowCount: 0 }; + + if (stmt.includes("FROM tenants")) { + return params[0] === TENANT || params[0] === OTHER_TENANT + ? { rows: [{ id: params[0] }], rowCount: 1 } + : { rows: [], rowCount: 0 }; + } + if (stmt.includes("SELECT id FROM candidate_profiles")) { + const found = store.candidates.find(c => c.tenantId === params[0] && (c.nin === params[1] || c.bvn === params[1])); + return { rows: found ? [{ id: found.id }] : [], rowCount: found ? 1 : 0 }; + } + if (stmt.includes("INSERT INTO candidate_profiles")) { + const row = { id: store.nextCandidateId++, tenantId: params[1], nin: params[6], bvn: params[7] }; + store.candidates.push(row); + return { rows: [{ id: row.id }], rowCount: 1 }; + } + if (stmt.includes("INSERT INTO candidate_consents")) { + store.consents.push({ consentRef: params[0], candidateId: params[1], purpose: "consumer_self_check", consentText: params[2], signedAt: new Date(), revokedAt: null }); + return { rows: [], rowCount: 1 }; + } + if (stmt.includes("INSERT INTO investigations")) { + store.investigations.push({ ref: params[0], subjectName: params[1], phone: params[2], tenantId: params[3], candidateProfileId: params[5], status: "pending", createdAt: new Date() }); + return { rows: [], rowCount: 1 }; + } + if (stmt.includes("INSERT INTO subject_access_tokens")) { + store.tokens.push({ id: params[0], tenant_id: params[1], candidate_id: params[2], token_hash: params[3], purpose: "self_check", expires_at: params[4], revoked_at: null }); + return { rows: [], rowCount: 1 }; + } + if (stmt.includes("FROM subject_access_tokens")) { + const row = store.tokens.find(t => t.token_hash === params[0]); + return { rows: row ? [row] : [], rowCount: row ? 1 : 0 }; + } + if (stmt.includes("FROM investigations")) { + const rows = store.investigations + .filter(i => i.candidateProfileId === params[0] && i.tenantId === params[1]) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + return { rows: rows.slice(0, 1), rowCount: rows.length > 0 ? 1 : 0 }; + } + if (stmt.includes("FROM informal_references")) { + const rows = store.references.filter(r => r.candidate_id === params[0] && r.tenant_id === params[1] && !r.withdrawn_at); + return { rows, rowCount: rows.length }; + } + if (stmt.includes("INSERT INTO subject_disputes")) { + store.disputes.push({ id: params[0], tenant_id: params[1], candidate_id: params[2], case_id: null, statement_sha256: params[3], statement_enc: params[4], status: "received" }); + return { rows: [], rowCount: 1 }; + } + if (stmt.includes("UPDATE subject_disputes SET case_id")) { + const d = store.disputes.find(x => x.id === params[1]); + if (d) d.case_id = params[0]; + return { rows: [], rowCount: d ? 1 : 0 }; + } + if (stmt.includes("SELECT id FROM informal_verification_cases")) { + const allowed = ["collecting", "under_review", "completed"]; + const rows = store.cases.filter(c => + c.tenant_id === params[0] && c.candidate_id === params[1] && allowed.includes(c.status) + && (params.length < 3 || c.id === params[2])); + return { rows: rows.slice(0, 1).map(c => ({ id: c.id })), rowCount: rows.length > 0 ? 1 : 0 }; + } + if (stmt.includes("UPDATE informal_verification_cases")) { + const c = store.cases.find(x => x.id === params[0] && x.tenant_id === params[1] + && ["collecting", "under_review", "completed"].includes(x.status)); + if (c) c.status = "disputed"; + return { rows: [], rowCount: c ? 1 : 0 }; + } + if (stmt.includes("INSERT INTO informal_reference_events")) { + store.caseEvents.push({ id: params[0], case_id: params[1], tenant_id: params[2], event_type: "subject_correction_submitted", event_sha256: params[3], metadata: params[4] }); + return { rows: [], rowCount: 1 }; + } + if (stmt.includes("UPDATE subject_disputes")) { // resolveDispute + const d = store.disputes.find(x => x.id === params[1] && x.tenant_id === params[2] && x.status !== "resolved"); + if (d) { d.status = "resolved"; d.resolution = params[0]; } + return { rows: d ? [{ id: d.id }] : [], rowCount: d ? 1 : 0 }; + } + throw new Error(`Unexpected SQL: ${stmt}`); + }); +} + +function ctxFor(ip: string) { + return { req: { ip, headers: {} }, user: null, tenantId: null, isDemo: false } as any; +} +const OPERATOR_CTX = { req: { ip: "10.9.9.1", headers: {} }, user: { id: 99, role: "supervisor", email: "sup@bis.test" }, tenantId: TENANT, isDemo: false } as any; + +const VALID_INTAKE = { + tenantId: TENANT, + fullName: "Adaeze Okonkwo", + ninOrBvn: "12345678901", + idType: "nin" as const, + phone: "08031234567", + consentText: "I, Adaeze Okonkwo, consent to BIS verifying my identity and informal references for a consumer self-check.", +}; + +async function issueToken(ip = "10.0.0.1") { + const caller = subjectPortalRouter.createCaller(ctxFor(ip)); + return caller.requestSelfCheck(VALID_INTAKE); +} + +beforeEach(() => { + resetStore(); + query.mockReset(); + installSqlBehavior(); + connect.mockClear(); + drizzleInsert.mockClear(); + drizzleRows.clear(); + drizzleRows.set(investigations, [{ id: 555, ref: "", subjectType: "individual" }]); + drizzleRows.set(screeningOrders, []); + drizzleRows.set(screeningResults, []); + drizzleRows.set(kycRecords, []); + drizzleRows.set(fieldVisitReports, []); +}); + +describe("subjectPortal.requestSelfCheck", () => { + it("issues a bis_sp_ token, persists ONLY its SHA-256 hash, and creates consent + investigation atomically", async () => { + const result = await issueToken(); + expect(result.token).toMatch(/^bis_sp_[A-Za-z0-9_-]{32}$/); + expect(result.investigationRef).toMatch(/^BIS-\d{4}-[0-9A-F]{6}$/); + expect(new Date(result.expiresAt).getTime()).toBeGreaterThan(Date.now() + 71 * 60 * 60 * 1000); + + const expectedHash = createHash("sha256").update(result.token).digest("hex"); + expect(store.tokens).toHaveLength(1); + expect(store.tokens[0].token_hash).toBe(expectedHash); + expect(JSON.stringify(store.tokens)).not.toContain(result.token); + + expect(store.consents).toHaveLength(1); + expect(store.consents[0].consentText).toBe(VALID_INTAKE.consentText); + expect(store.consents[0].signedAt).toBeTruthy(); + expect(store.consents[0].revokedAt).toBeNull(); + expect(store.investigations).toHaveLength(1); + expect(store.investigations[0].status).toBe("pending"); + expect(store.tokens[0].candidate_id).toBe(store.investigations[0].candidateProfileId); + }); + + it("rejects when consent text is absent/too short — before touching PostgreSQL (no consent → no case)", async () => { + const caller = subjectPortalRouter.createCaller(ctxFor("10.0.1.1")); + await expect(caller.requestSelfCheck({ ...VALID_INTAKE, consentText: "ok" })).rejects.toThrow(); + expect(connect).not.toHaveBeenCalled(); + expect(store.cases).toHaveLength(0); + expect(store.tokens).toHaveLength(0); + }); + + it("rejects malformed NIN/BVN before touching PostgreSQL", async () => { + const caller = subjectPortalRouter.createCaller(ctxFor("10.0.1.2")); + await expect(caller.requestSelfCheck({ ...VALID_INTAKE, ninOrBvn: "12345" })).rejects.toThrow(); + expect(connect).not.toHaveBeenCalled(); + }); + + it("rejects an unknown tenant", async () => { + const caller = subjectPortalRouter.createCaller(ctxFor("10.0.1.3")); + await expect(caller.requestSelfCheck({ ...VALID_INTAKE, tenantId: 999 })) + .rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("upserts an existing candidate matched by NIN instead of duplicating", async () => { + store.candidates.push({ id: 77, tenantId: TENANT, nin: VALID_INTAKE.ninOrBvn, bvn: null }); + await issueToken("10.0.1.4"); + expect(store.candidates).toHaveLength(1); + expect(store.tokens[0].candidate_id).toBe(77); + }); + + it("rate-limits bursts from a single IP", async () => { + const caller = subjectPortalRouter.createCaller(ctxFor("10.0.2.1")); + let lastError: any; + for (let i = 0; i < 12; i++) { + try { await caller.requestSelfCheck(VALID_INTAKE); } catch (e) { lastError = e; } + } + expect(lastError).toMatchObject({ code: "TOO_MANY_REQUESTS" }); + }); +}); + +describe("subjectPortal.getMyStatus — token lifecycle + minimal disclosure", () => { + async function seedStatusWorld() { + const { token, investigationRef } = await issueToken("10.1.0.1"); + const candidateId = store.tokens[0].candidate_id; + const caseId = "11111111-2222-4333-8444-555555555555"; + store.cases.push({ id: caseId, tenant_id: TENANT, candidate_id: candidateId, status: "collecting" }); + store.references.push( + { candidate_id: candidateId, tenant_id: TENANT, provenance_status: "claimed", withdrawn_at: null, source_display_name: "Musa the landlord", contact: "08099998888" }, + { candidate_id: candidateId, tenant_id: TENANT, provenance_status: "independently_confirmed", withdrawn_at: null, source_display_name: "Cooperative chair", contact: "coop@x.ng" }, + { candidate_id: candidateId, tenant_id: TENANT, provenance_status: "contradicted", withdrawn_at: new Date(), source_display_name: "Withdrawn ref" }, + ); + drizzleRows.set(investigations, [{ id: 555, ref: investigationRef, subjectType: "individual" }]); + return { token, investigationRef, candidateId, caseId }; + } + + it("returns status + completeness + provenance labels for a valid token", async () => { + const { token, investigationRef } = await seedStatusWorld(); + const caller = subjectPortalRouter.createCaller(ctxFor("10.1.0.2")); + const status = await caller.getMyStatus({ token }); + expect(status.investigationRef).toBe(investigationRef); + expect(status.investigationStatus).toBe("pending"); + expect(status.thinFile).toBe(true); // no screening/KYC/field data yet + expect(status.dataCompleteness.score).toBe(0); + expect(status.referenceProvenance).toEqual(["claimed", "independently_confirmed"]); // withdrawn excluded + }); + + it("never discloses PII or referee identity/contact (minimal-disclosure assertion)", async () => { + const { token } = await seedStatusWorld(); + const caller = subjectPortalRouter.createCaller(ctxFor("10.1.0.3")); + const serialized = JSON.stringify(await caller.getMyStatus({ token })); + const forbidden = [ + "source_display_name", "sourceDisplayName", "contact", "Musa", "Cooperative chair", + "nin", "bvn", "phone", "email", "fullName", "subjectName", "statement", + "08099998888", "08031234567", "12345678901", "Adaeze", + ]; + for (const key of forbidden) expect(serialized).not.toContain(key); + }); + + it("rejects an expired token (fail closed)", async () => { + const { token } = await seedStatusWorld(); + store.tokens[0].expires_at = new Date(Date.now() - 1000); + const caller = subjectPortalRouter.createCaller(ctxFor("10.1.0.4")); + await expect(caller.getMyStatus({ token })).rejects.toMatchObject({ code: "UNAUTHORIZED", message: expect.stringContaining("expired") }); + }); + + it("rejects a revoked token (fail closed)", async () => { + const { token } = await seedStatusWorld(); + store.tokens[0].revoked_at = new Date(); + const caller = subjectPortalRouter.createCaller(ctxFor("10.1.0.5")); + await expect(caller.getMyStatus({ token })).rejects.toMatchObject({ code: "UNAUTHORIZED", message: expect.stringContaining("revoked") }); + }); + + it("rejects unknown and malformed tokens (fail closed)", async () => { + await seedStatusWorld(); + const caller = subjectPortalRouter.createCaller(ctxFor("10.1.0.6")); + await expect(caller.getMyStatus({ token: "bis_sp_forged" })).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + await expect(caller.getMyStatus({ token: "bearer-whatever" })).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); +}); + +describe("subjectPortal.submitDispute — informal-verification wiring", () => { + it("encrypts the statement, stores only the envelope, and transitions the candidate's case to 'disputed'", async () => { + const { token } = await issueToken("10.2.0.1"); + const candidateId = store.tokens[0].candidate_id; + const caseId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + store.cases.push({ id: caseId, tenant_id: TENANT, candidate_id: candidateId, status: "under_review" }); + + const caller = subjectPortalRouter.createCaller(ctxFor("10.2.0.2")); + const statement = "The landlord reference overstates my tenancy period by two years."; + const result = await caller.submitDispute({ token, statement }); + expect(result).toEqual({ disputeId: expect.any(String), status: "received", caseDisputed: true }); + + const dispute = store.disputes[0]; + expect(dispute.case_id).toBe(caseId); + expect(dispute.statement_sha256).toBe(createHash("sha256").update(statement).digest("hex")); + expect(dispute.statement_enc).toContain("vault:v3:"); + expect(JSON.stringify(store.disputes)).not.toContain(statement); + + expect(store.cases[0].status).toBe("disputed"); + expect(store.caseEvents).toHaveLength(1); + expect(store.caseEvents[0].event_type).toBe("subject_correction_submitted"); + expect(store.caseEvents[0].event_sha256).toMatch(/^[0-9a-f]{64}$/); + + expect(drizzleInsert).toHaveBeenCalled(); // audit log write + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/v1/events"), + expect.objectContaining({ body: expect.stringContaining("SUBJECT_DISPUTE_SUBMITTED") }), + ); + }); + + it("still records the dispute when no informal case exists (no spurious case creation)", async () => { + const { token } = await issueToken("10.2.1.1"); + const caller = subjectPortalRouter.createCaller(ctxFor("10.2.1.2")); + const result = await caller.submitDispute({ token, statement: "My NIN trace shows an address I never lived at." }); + expect(result.caseDisputed).toBe(false); + expect(store.disputes).toHaveLength(1); + expect(store.cases).toHaveLength(0); + }); + + it("never transitions a case belonging to another tenant (wrong-tenant rejection)", async () => { + const { token } = await issueToken("10.2.2.1"); + const otherCase = "cccccccc-dddd-4eee-8fff-000000000000"; + store.cases.push({ id: otherCase, tenant_id: OTHER_TENANT, candidate_id: store.tokens[0].candidate_id, status: "collecting" }); + const caller = subjectPortalRouter.createCaller(ctxFor("10.2.2.2")); + const result = await caller.submitDispute({ token, caseId: otherCase, statement: "This report confuses me with another person entirely." }); + expect(result.caseDisputed).toBe(false); + expect(store.cases[0].status).toBe("collecting"); // untouched + expect(store.disputes[0].case_id).toBeNull(); + }); + + it("rejects a purpose-mismatched token usage pattern via expiry/revocation of the same hash store", async () => { + const { token } = await issueToken("10.2.3.1"); + store.tokens[0].purpose = "status"; // a status-only token may not dispute + const caller = subjectPortalRouter.createCaller(ctxFor("10.2.3.2")); + await expect(caller.submitDispute({ token, statement: "Attempting dispute with a status-only token." })) + .rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(store.disputes).toHaveLength(0); + }); +}); + +describe("subjectPortal.resolveDispute — operator resolution", () => { + async function seedDispute() { + const { token } = await issueToken("10.3.0.1"); + const caller = subjectPortalRouter.createCaller(ctxFor("10.3.0.2")); + const { disputeId } = await caller.submitDispute({ token, statement: "The adverse-media hit is a namesake, not me." }); + return disputeId; + } + + it("resolves a dispute under the operator tenant with audit + event", async () => { + const disputeId = await seedDispute(); + const caller = subjectPortalRouter.createCaller(OPERATOR_CTX); + await expect(caller.resolveDispute({ disputeId, resolution: "Reinvestigated with source; adverse item removed." })) + .resolves.toEqual({ disputeId, status: "resolved" }); + expect(store.disputes[0].status).toBe("resolved"); + expect(store.disputes[0].resolution).toContain("Reinvestigated"); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/v1/events"), + expect.objectContaining({ body: expect.stringContaining("SUBJECT_DISPUTE_RESOLVED") }), + ); + }); + + it("rejects operators from another tenant (tenant-scoped, fail closed)", async () => { + const disputeId = await seedDispute(); + const caller = subjectPortalRouter.createCaller({ ...OPERATOR_CTX, tenantId: OTHER_TENANT }); + await expect(caller.resolveDispute({ disputeId, resolution: "Attempting cross-tenant resolution here." })) + .rejects.toMatchObject({ code: "CONFLICT" }); + expect(store.disputes[0].status).toBe("received"); + }); + + it("denies analyst accounts the resolution control", async () => { + const disputeId = await seedDispute(); + const caller = subjectPortalRouter.createCaller({ ...OPERATOR_CTX, user: { id: 41, role: "analyst" } }); + await expect(caller.resolveDispute({ disputeId, resolution: "Analysts must not resolve subject disputes." })) + .rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("is idempotent against double resolution", async () => { + const disputeId = await seedDispute(); + const caller = subjectPortalRouter.createCaller(OPERATOR_CTX); + await caller.resolveDispute({ disputeId, resolution: "First resolution, properly investigated." }); + await expect(caller.resolveDispute({ disputeId, resolution: "Second resolution attempt should fail." })) + .rejects.toMatchObject({ code: "CONFLICT" }); + }); +}); diff --git a/server/subjectPortal.ts b/server/subjectPortal.ts new file mode 100644 index 00000000..19a0d9c8 --- /dev/null +++ b/server/subjectPortal.ts @@ -0,0 +1,495 @@ +/** + * server/subjectPortal.ts + * + * WP3 — subject-facing portal (Checkr candidate-portal / Intelius self-check analog): + * - requestSelfCheck : consumer self-check intake (public, consent-gated, rate-limited) + * - getMyStatus : token-authed, minimal-disclosure status view + * - submitDispute : token-authed dispute intake, wired into informalVerification + * - resolveDispute : operator (admin/supervisor) resolution, tenant-scoped + * + * Security model: + * - Portal tokens are `bis_sp_` and are stored ONLY as SHA-256 hex hashes + * (subject_access_tokens.token_hash) — the same hash-lookup scheme PR #151 + * established for apiTokens.tokenHash in server/openclawEndpoints.ts. + * - Tenant identity ALWAYS comes from the token row, never from client input. + * - Disclosure is minimal: provenance LABELS only, never referee names/contacts. + * - Everything fails closed: DB down, token unknown/expired/revoked, or PII + * encryption unavailable all reject rather than degrade. + */ +import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { protectedProcedure, publicProcedure, router } from "./_core/trpc"; +import { getDb, getPgPool } from "./db"; +import { auditLog } from "../drizzle/schema"; +import { ENV } from "./_core/env"; +import { computeDataCompleteness } from "./dataCompleteness"; +import { encryptPiiEnvelope, piiAad } from "./piiEnvelopeCrypto"; +import { activeTenantEncryptionRegistry } from "./piiKeyRegistry"; + +const TOKEN_PREFIX = "bis_sp_"; +const TOKEN_TTL_MS = 72 * 60 * 60 * 1000; // 72h self-check window + +/** System actor recorded as investigations.createdBy for subject-initiated self-checks + * (no operator session exists at intake; investigations.createdBy has no FK). */ +const SYSTEM_ACTOR_ID = 0; + +// ─── Rate limiting ──────────────────────────────────────────────────────────── +// The Express layer already applies a global express-rate-limit (server/_core/index.ts), +// but this router issues credentials, so it gets a stricter dedicated per-IP +// fixed-window limiter. This store is per-process; if the BFF is scaled +// horizontally, back it with the shared Redis client (server/redis.ts) instead. +const RATE_WINDOW_MS = 15 * 60 * 1000; +const RATE_MAX_REQUESTS = 10; +const rateBuckets = new Map(); + +function enforceRateLimit(ip: string): void { + const now = Date.now(); + const bucket = rateBuckets.get(ip); + if (!bucket || bucket.resetAt <= now) { + rateBuckets.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS }); + return; + } + bucket.count += 1; + if (bucket.count > RATE_MAX_REQUESTS) { + throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many subject-portal requests from this network. Try again later." }); + } +} + +function clientIp(ctx: { req?: { ip?: string; headers?: Record } }): string { + const fwd = ctx.req?.headers?.["x-forwarded-for"]; + const first = Array.isArray(fwd) ? fwd[0] : typeof fwd === "string" ? fwd.split(",")[0] : undefined; + return (first ?? ctx.req?.ip ?? "unknown").trim() || "unknown"; +} + +// ─── Token helpers (hash-only storage — see openclawEndpoints.ts precedent) ─── + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +type SubjectTokenRow = { + id: string; + tenant_id: number; + candidate_id: number; + purpose: string; + expires_at: Date | string; + revoked_at: Date | string | null; +}; + +/** + * Resolve a presented portal token via SHA-256 hash lookup. Fails CLOSED: + * unknown prefix, unknown hash, revocation, expiry, or wrong purpose all reject. + */ +async function resolveSubjectToken( + client: { query: Function }, + rawToken: string, + allowedPurposes: ReadonlyArray, +): Promise { + if (!rawToken.startsWith(TOKEN_PREFIX)) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid subject access token" }); + } + const result = await client.query( + `SELECT id, tenant_id, candidate_id, purpose, expires_at, revoked_at + FROM subject_access_tokens WHERE token_hash = $1`, + [hashToken(rawToken)], + ); + if (result.rowCount !== 1) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid subject access token" }); + } + const row = result.rows[0] as SubjectTokenRow; + if (row.revoked_at) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Subject access token has been revoked" }); + } + if (new Date(row.expires_at) <= new Date()) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Subject access token has expired" }); + } + if (!allowedPurposes.includes(row.purpose)) { + throw new TRPCError({ code: "FORBIDDEN", message: "Subject access token is not valid for this operation" }); + } + return row; +} + +// ─── Audit + event helpers ──────────────────────────────────────────────────── +// These mirror the local writeAuditLog / publishEvent helpers in server/routers.ts +// (identical HMAC integrity format and event envelope). They are duplicated here +// rather than imported to avoid a circular module dependency with routers.ts; +// keep the formats in sync if routers.ts changes. + +async function writeAuditLog(entry: { + userId?: number; + userEmail?: string; + tenantId?: number; + category: "investigation" | "kyc" | "alert" | "report" | "user" | "system" | "api"; + action: string; + targetRef?: string; + result?: "success" | "warning" | "failure"; + ipAddress?: string; + detail?: unknown; +}) { + try { + const db = await getDb(); + if (!db) return; + const result = entry.result ?? "success"; + const createdAt = new Date(); + const integrityHash = createHmac("sha256", ENV.auditHmacSecret) + .update([String(entry.userId ?? ""), entry.category, entry.action, entry.targetRef ?? "", result, createdAt.toISOString()].join("|")) + .digest("hex") + .slice(0, 64); + await db.insert(auditLog).values({ + userId: entry.userId, userEmail: entry.userEmail, tenantId: entry.tenantId, category: entry.category, + action: entry.action, targetRef: entry.targetRef, result, + ipAddress: entry.ipAddress, detail: entry.detail as any, integrityHash, createdAt, + }); + } catch (e) { + console.warn("[AuditLog] Failed to write:", e); + } +} + +async function publishEvent(eventType: string, subjectRef: string, severity: string, payload: unknown, source = "bis-bff") { + try { + await fetch(`${ENV.eventProcessorUrl}/v1/events`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-BIS-Key": ENV.bisGatewayKey }, + body: JSON.stringify({ event_type: eventType, subject_id: subjectRef, subject_ref: subjectRef, severity, payload, source_service: source }), + }); + } catch (e) { + console.warn("[EventProcessor] Failed to publish event:", e); + } +} + +// ─── Misc ───────────────────────────────────────────────────────────────────── + +function generateRef(prefix: string): string { + const year = new Date().getFullYear(); + const rand = randomBytes(3).toString("hex").toUpperCase(); + return `${prefix}-${year}-${rand}`; +} + +async function poolOrFail() { + const pool = await getPgPool(); + if (!pool) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Subject portal storage is unavailable" }); + return pool; +} + +/** Mirrors informalVerification.ts digest() so case events stay cross-correlatable. */ +function eventDigest(caseId: string, type: string, detail: Record): string { + return createHash("sha256").update(JSON.stringify({ caseId, type, detail })).digest("hex"); +} + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export const subjectPortalRouter = router({ + + /** + * Consumer self-check intake. PUBLIC (no operator session) but consent-gated: + * a signed consent text is mandatory and persisted (unrevoked) before any + * investigation or token is created. All writes happen in ONE transaction. + */ + requestSelfCheck: publicProcedure + .input(z.object({ + tenantId: z.number().int().positive(), + fullName: z.string().min(2).max(255), + ninOrBvn: z.string().regex(/^\d{11}$/, "NIN or BVN must be exactly 11 digits"), + idType: z.enum(["nin", "bvn"]).default("nin"), + phone: z.string().min(7).max(20), + consentText: z.string().min(40).max(4000), + })) + .mutation(async ({ input, ctx }) => { + const ip = clientIp(ctx); + enforceRateLimit(ip); + const pool = await poolOrFail(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const tenant = await client.query(`SELECT id FROM tenants WHERE id = $1`, [input.tenantId]); + if (tenant.rowCount !== 1) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Unknown tenant for self-check intake" }); + } + + // Upsert candidate profile: match on NIN/BVN within the tenant. + const existing = await client.query( + `SELECT id FROM candidate_profiles + WHERE "tenantId" = $1 AND (nin = $2 OR bvn = $2) + ORDER BY id LIMIT 1 FOR UPDATE`, + [input.tenantId, input.ninOrBvn], + ); + let candidateId: number; + if (existing.rowCount === 1) { + candidateId = existing.rows[0].id; + } else { + const candidateRef = generateRef("CAND"); + const parts = input.fullName.trim().split(/\s+/); + const firstName = parts[0] ?? input.fullName.trim(); + const lastName = parts.slice(1).join(" ") || firstName; + // Placeholder mailbox (frscQuickCheck precedent): the subject portal + // does not collect email at intake. + const inserted = await client.query( + `INSERT INTO candidate_profiles + ("candidateRef", "tenantId", "firstName", "lastName", email, phone, nin, bvn, + "consentStatus", "ndprConsentAt", "ndprConsentIp") + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'submitted', now(), $9) + RETURNING id`, + [ + candidateRef, input.tenantId, firstName, lastName, + `${candidateRef.toLowerCase()}@self-check.bis.internal`, input.phone, + input.idType === "nin" ? input.ninOrBvn : null, + input.idType === "bvn" ? input.ninOrBvn : null, + ip, + ], + ); + candidateId = inserted.rows[0].id; + } + + // Mandatory signed consent — the portal never proceeds without it. + const consentRef = generateRef("CON"); + await client.query( + `INSERT INTO candidate_consents + ("consentRef", "candidateId", purpose, "consentText", "signedAt", "signerIp") + VALUES ($1, $2, 'consumer_self_check', $3, now(), $4)`, + [consentRef, candidateId, input.consentText, ip], + ); + + // Self-check investigation (consumer_self_check purpose). + const investigationRef = generateRef("BIS"); + await client.query( + `INSERT INTO investigations + (ref, "subjectType", "subjectName", country, tier, priority, status, + phone, purpose, "tenantId", "createdBy", "candidateProfileId") + VALUES ($1, 'individual', $2, 'NG', 'basic', 'medium', 'pending', + $3, 'consumer_self_check', $4, $5, $6)`, + [investigationRef, input.fullName.trim(), input.phone, input.tenantId, SYSTEM_ACTOR_ID, candidateId], + ); + + // Issue the portal token; ONLY its SHA-256 hash is persisted. + const token = `${TOKEN_PREFIX}${randomBytes(24).toString("base64url")}`; + const expiresAt = new Date(Date.now() + TOKEN_TTL_MS); + await client.query( + `INSERT INTO subject_access_tokens + (id, tenant_id, candidate_id, token_hash, purpose, expires_at) + VALUES ($1, $2, $3, $4, 'self_check', $5)`, + [randomUUID(), input.tenantId, candidateId, hashToken(token), expiresAt], + ); + + await client.query("COMMIT"); + await publishEvent("SUBJECT_SELF_CHECK_REQUESTED", investigationRef, "info", { + tenantId: input.tenantId, consentRef, + }).catch(() => {}); + return { token, investigationRef, expiresAt }; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + }), + + /** + * Minimal-disclosure status view for the token holder. Tenant and candidate + * identity come from the TOKEN ROW ONLY. Returns provenance LABELS — never + * referee names, contacts, or claim text. + */ + getMyStatus: publicProcedure + .input(z.object({ token: z.string().min(1) })) + .query(async ({ input, ctx }) => { + enforceRateLimit(clientIp(ctx)); + const pool = await poolOrFail(); + const client = await pool.connect(); + try { + const tokenRow = await resolveSubjectToken(client, input.token, ["self_check", "status"]); + + const inv = await client.query( + `SELECT ref, status FROM investigations + WHERE "candidateProfileId" = $1 AND "tenantId" = $2 + ORDER BY "createdAt" DESC LIMIT 1`, + [tokenRow.candidate_id, tokenRow.tenant_id], + ); + if (inv.rowCount !== 1) { + throw new TRPCError({ code: "NOT_FOUND", message: "No self-check investigation found for this token" }); + } + const investigationRef = inv.rows[0].ref as string; + + // Same scoring as investigations.getDataCompleteness (shared module). + const db = await getDb(); + const completeness = db + ? await computeDataCompleteness(db, investigationRef) + : { score: 0, sourcesChecked: 0, sourcesTotal: 0, thinFile: true, coverage: [], missingCritical: [] }; + + const refs = await client.query( + `SELECT r.provenance_status + FROM informal_references r + JOIN informal_verification_cases c ON c.id = r.case_id + WHERE c.candidate_id = $1 AND c.tenant_id = $2 AND r.withdrawn_at IS NULL`, + [tokenRow.candidate_id, tokenRow.tenant_id], + ); + + // Minimal-disclosure shape: no subject PII, no referee identity/contact, + // no source-claim text — only status, score, thin-file flag, and labels. + return { + investigationRef, + investigationStatus: inv.rows[0].status as string, + dataCompleteness: { + score: completeness.score, + sourcesChecked: completeness.sourcesChecked, + sourcesTotal: completeness.sourcesTotal, + thinFile: completeness.thinFile, + }, + thinFile: completeness.thinFile, + referenceProvenance: refs.rows.map((r: { provenance_status: string }) => r.provenance_status), + }; + } finally { + client.release(); + } + }), + + /** + * Subject dispute intake. Statement PII is encrypted with the tenant's active + * Vault Transit key (piiEnvelopeCrypto); if encryption is unavailable the + * dispute is REJECTED (fail closed) rather than stored in plaintext. + */ + submitDispute: publicProcedure + .input(z.object({ + token: z.string().min(1), + statement: z.string().min(10).max(4096), + caseId: z.string().uuid().optional(), + })) + .mutation(async ({ input, ctx }) => { + enforceRateLimit(clientIp(ctx)); + const pool = await poolOrFail(); + const client = await pool.connect(); + try { + const tokenRow = await resolveSubjectToken(client, input.token, ["self_check", "dispute"]); + const disputeId = randomUUID(); + const statementSha256 = createHash("sha256").update(input.statement).digest("hex"); + + // Encrypt the statement under the tenant's active Transit key. Fail + // closed: without an active key the statement is never persisted. + let statementEnc: string; + try { + const key = await activeTenantEncryptionRegistry(client, tokenRow.tenant_id); + const envelope = await encryptPiiEnvelope( + key, + piiAad(tokenRow.tenant_id, "candidate_profile", tokenRow.candidate_id, `subject-dispute:${disputeId}`), + { statement: input.statement }, + ); + statementEnc = JSON.stringify({ + ciphertext: envelope.ciphertext.toString("utf8"), + keyVersion: envelope.keyVersion, + providerKeyVersion: envelope.providerKeyVersion, + cryptoProvider: envelope.cryptoProvider, + }); + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Dispute statement encryption is unavailable; dispute was not recorded" }); + } + + await client.query("BEGIN"); + await client.query( + `INSERT INTO subject_disputes + (id, tenant_id, candidate_id, case_id, statement_sha256, statement_enc, status) + VALUES ($1, $2, $3, NULL, $4, $5, 'received')`, + [disputeId, tokenRow.tenant_id, tokenRow.candidate_id, statementSha256, statementEnc], + ); + + // Wire into the informal-verification flow: transition the candidate's + // current case to 'disputed' using the SAME semantics as + // informalVerification.submitCorrection (status guard + immutable case + // event with statement digest). Mirrored here because the subject is + // token-authenticated, not an operator session that procedure requires. + const caseResult = await client.query( + `SELECT id FROM informal_verification_cases + WHERE tenant_id = $1 AND candidate_id = $2 + AND status IN ('collecting', 'under_review', 'completed') + ${input.caseId ? "AND id = $3" : ""} + ORDER BY created_at DESC LIMIT 1 FOR UPDATE`, + input.caseId ? [tokenRow.tenant_id, tokenRow.candidate_id, input.caseId] : [tokenRow.tenant_id, tokenRow.candidate_id], + ); + let caseDisputed = false; + if (caseResult.rowCount === 1) { + const caseId = caseResult.rows[0].id as string; + const transitioned = await client.query( + `UPDATE informal_verification_cases SET status = 'disputed', updated_at = now() + WHERE id = $1 AND tenant_id = $2 AND status IN ('collecting', 'under_review', 'completed')`, + [caseId, tokenRow.tenant_id], + ); + if (transitioned.rowCount === 1) { + const detail = { statementSha256, disputeId }; + await client.query( + `INSERT INTO informal_reference_events (id, case_id, reference_id, tenant_id, event_type, actor_user_id, event_sha256, metadata) + VALUES ($1, $2, NULL, $3, 'subject_correction_submitted', NULL, $4, $5::jsonb)`, + [randomUUID(), caseId, tokenRow.tenant_id, eventDigest(caseId, "subject_correction_submitted", detail), JSON.stringify(detail)], + ); + await client.query(`UPDATE subject_disputes SET case_id = $1 WHERE id = $2`, [caseId, disputeId]); + caseDisputed = true; + } + } + await client.query("COMMIT"); + + await writeAuditLog({ + tenantId: tokenRow.tenant_id, category: "investigation", + action: "Subject dispute submitted", targetRef: disputeId, + ipAddress: clientIp(ctx), + detail: { candidateId: tokenRow.candidate_id, caseDisputed, statementSha256 }, + }); + await publishEvent("SUBJECT_DISPUTE_SUBMITTED", disputeId, "warning", { + tenantId: tokenRow.tenant_id, candidateId: tokenRow.candidate_id, caseDisputed, + }).catch(() => {}); + return { disputeId, status: "received" as const, caseDisputed }; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + }), + + /** + * Operator resolution of a subject dispute. Admin/supervisor only, strictly + * scoped to the operator's tenant context. + */ + resolveDispute: protectedProcedure + .input(z.object({ + disputeId: z.string().uuid(), + resolution: z.string().min(10).max(2048), + })) + .mutation(async ({ input, ctx }) => { + if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: "An authenticated operator is required" }); + if (ctx.user.role !== "admin" && ctx.user.role !== "supervisor") { + throw new TRPCError({ code: "FORBIDDEN", message: "A designated reviewer is required to resolve subject disputes" }); + } + if (!ctx.tenantId || ctx.tenantId <= 0) { + throw new TRPCError({ code: "FORBIDDEN", message: "An explicit tenant context is required" }); + } + const tenantId = ctx.tenantId; + const pool = await poolOrFail(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const result = await client.query( + `UPDATE subject_disputes + SET status = 'resolved', resolution = $1, updated_at = now() + WHERE id = $2 AND tenant_id = $3 AND status <> 'resolved' + RETURNING id`, + [input.resolution.trim(), input.disputeId, tenantId], + ); + if (result.rowCount !== 1) { + throw new TRPCError({ code: "CONFLICT", message: "Subject dispute is unavailable or already resolved in this tenant" }); + } + await client.query("COMMIT"); + await writeAuditLog({ + userId: ctx.user.id, userEmail: ctx.user.email ?? undefined, tenantId, + category: "investigation", action: "Subject dispute resolved", targetRef: input.disputeId, + }); + await publishEvent("SUBJECT_DISPUTE_RESOLVED", input.disputeId, "info", { + tenantId, resolvedBy: ctx.user.id, + }).catch(() => {}); + return { disputeId: input.disputeId, status: "resolved" as const }; + } catch (error) { + await client.query("ROLLBACK").catch(() => undefined); + throw error; + } finally { + client.release(); + } + }), +});