Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions drizzle/0023_subject_portal.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
84 changes: 84 additions & 0 deletions server/dataCompleteness.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<DataCompletenessReport> {
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) };
}
Loading
Loading