From da8da05d2c997559ff617de9a490af3bc6abfdd0 Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:08:07 -0400 Subject: [PATCH 1/3] feat: unified entity search + related-persons graph (router + tests) --- server/entity-search.test.ts | 440 ++++++++++++++++++++++++++++ server/entitySearch.ts | 547 +++++++++++++++++++++++++++++++++++ 2 files changed, 987 insertions(+) create mode 100644 server/entity-search.test.ts create mode 100644 server/entitySearch.ts diff --git a/server/entity-search.test.ts b/server/entity-search.test.ts new file mode 100644 index 0000000..5cf2d4c --- /dev/null +++ b/server/entity-search.test.ts @@ -0,0 +1,440 @@ +// entity-search.test.ts +// Tests for WP1 unified one-box entity search + related-persons graph: +// - query-type detection matrix +// - per-source failure isolation (one failing source must not sink the rest) +// - tenant isolation (every query scoped to ctx.tenantId; no tenant → fail closed) +// - confidence classification (direct_documented=high / declared=medium / shared_attribute=low) + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./db", () => ({ getPgPool: vi.fn() })); + +import { getPgPool } from "./db"; +import { + entitySearchRouter, + detectQueryType, + normalizeRcNumber, + phoneVariants, + confidenceForEvidenceKind, + evidenceKindForReferenceType, + extractBeneficialOwners, +} from "./entitySearch"; +import type { TrpcContext } from "./_core/context"; + +// ─── Test fixtures ──────────────────────────────────────────────────────────── + +const TENANT = 42; + +function tenantContext(tenantId: number | null = TENANT): TrpcContext { + return { + user: { + id: 21, openId: "entity-search-user", email: "analyst@example.test", name: "Search Analyst", + loginMethod: "keycloak", role: "analyst", tenantId, pushToken: null, + createdAt: new Date(), updatedAt: new Date(), lastSignedIn: new Date(), + }, + tenantId, isDemo: false, authMethod: "keycloak", + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"], + }; +} + +function anonymousContext(): TrpcContext { + return { + user: null, tenantId: null, isDemo: false, authMethod: "keycloak", + req: { protocol: "https", headers: {} } as TrpcContext["req"], + res: { clearCookie: vi.fn() } as unknown as TrpcContext["res"], + }; +} + +type QueryHandler = (text: string, params: unknown[]) => { rows: unknown[]; rowCount?: number }; + +interface CapturedQuery { text: string; params: unknown[] } + +function makePool(handler: QueryHandler) { + const queries: CapturedQuery[] = []; + const query = vi.fn(async (text: string, params: unknown[] = []) => { + queries.push({ text, params }); + return handler(text, params); + }); + return { pool: { query }, queries }; +} + +const emptyHandler: QueryHandler = () => ({ rows: [] }); + +function mockPool(pool: unknown) { + vi.mocked(getPgPool).mockResolvedValue(pool as never); +} + +// ─── Query-type detection matrix ───────────────────────────────────────────── + +describe("Entity Search: detectQueryType", () => { + it.each([ + ["12345678901", "nin_bvn"], // 11 digits → dual NIN/BVN + ["98765432109", "nin_bvn"], + [" 12345678901 ", "nin_bvn"], // whitespace tolerated + ["08031234567", "phone"], // 11 digits starting 0 → NG local mobile + ["+2348031234567", "phone"], // international format + ["2348031234567", "phone"], + ["0803 123 4567", "phone"], // separators stripped + ["RC123456", "cac"], // RC prefix → CAC + ["rc7654321", "cac"], // case-insensitive + ["RC-123456", "cac"], + ["RC 123456", "cac"], + ["John Doe", "name"], + ["adewale ogunleye", "name"], + ["O'Connor", "name"], + ])("classifies %j as %s", (input, expected) => { + expect(detectQueryType(input)).toBe(expected); + }); +}); + +describe("Entity Search: input normalization", () => { + it("normalizes RC variants to a canonical RC number", () => { + expect(normalizeRcNumber("rc 123456")).toBe("RC123456"); + expect(normalizeRcNumber("RC-7654321")).toBe("RC7654321"); + }); + + it("generates Nigerian phone variants so 0…/234…/+234… all match", () => { + expect(phoneVariants("08031234567")).toEqual(expect.arrayContaining(["08031234567", "+2348031234567", "2348031234567"])); + expect(phoneVariants("+2348031234567")).toEqual(expect.arrayContaining(["+2348031234567", "08031234567"])); + }); +}); + +// ─── Confidence classification ─────────────────────────────────────────────── + +describe("Entity Search: confidence classification", () => { + it("maps evidence kinds to confidence levels", () => { + expect(confidenceForEvidenceKind("direct_documented")).toBe("high"); + expect(confidenceForEvidenceKind("declared")).toBe("medium"); + expect(confidenceForEvidenceKind("shared_attribute")).toBe("low"); + }); + + it("treats a signed guarantor as direct_documented (high)", () => { + expect(confidenceForEvidenceKind(evidenceKindForReferenceType("guarantor"))).toBe("high"); + }); + + it("treats self-nominated referees and other references as declared (medium)", () => { + for (const t of ["self_nominated_referee", "landlord", "trade_association", "cooperative", "neighbour", "field_observation"]) { + expect(confidenceForEvidenceKind(evidenceKindForReferenceType(t))).toBe("medium"); + } + }); +}); + +describe("Entity Search: extractBeneficialOwners", () => { + it("extracts owners/directors/shareholders from CAC payloads", () => { + const owners = extractBeneficialOwners({ + data: { + beneficial_owners: [{ name: "Aisha Bello", shareholding: "60%" }], + directors: [{ fullName: "Tunde Adeyemi" }, "Aisha Bello"], + }, + }); + expect(owners).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "Aisha Bello", role: "beneficial_owner", shareholding: "60%" }), + expect.objectContaining({ name: "Tunde Adeyemi", role: "director" }), + expect.objectContaining({ name: "Aisha Bello", role: "director" }), + ])); + }); + + it("returns an empty list for empty or malformed payloads", () => { + expect(extractBeneficialOwners(null)).toEqual([]); + expect(extractBeneficialOwners({ error: "HTTP 502" })).toEqual([]); + expect(extractBeneficialOwners({ directors: [{}] })).toEqual([]); + }); +}); + +// ─── Router-level: search fan-out, failure isolation, tenant scope ──────────── + +describe("Entity Search: search procedure", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("fans a name query across investigations, kyc_records and candidate_profiles with per-source status", async () => { + const { pool, queries } = makePool((text) => { + if (text.includes("FROM investigations")) return { rows: [{ id: 1, ref: "INV-1", subjectName: "John Doe" }] }; + if (text.includes("FROM kyc_records")) return { rows: [{ id: 7, subjectName: "John Doe", subjectRef: "KYC-7" }] }; + return { rows: [] }; + }); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).search({ query: "John Doe" }); + + expect(result.queryType).toBe("name"); + expect(result.investigations).toHaveLength(1); + expect(result.kyc).toHaveLength(1); + expect(result.sources.map((s) => s.source).sort()).toEqual(["candidate_profiles", "investigations", "kyc_records"]); + expect(result.sources.every((s) => s.status === "ok" && s.latencyMs >= 0)).toBe(true); + + // Tenant scope: every data query filtered by the ctx tenant, never client input + const dataQueries = queries.filter((q) => !q.text.includes("INSERT INTO audit_log")); + expect(dataQueries.length).toBeGreaterThan(0); + for (const q of dataQueries) { + expect(q.text).toContain('"tenantId" = $1'); + expect(q.params[0]).toBe(TENANT); + } + + // Every search is audit-logged (who searched what), tenant-scoped + const audit = queries.find((q) => q.text.includes("INSERT INTO audit_log")); + expect(audit).toBeDefined(); + expect(audit!.params[0]).toBe(TENANT); + expect(audit!.params[1]).toBe(21); + expect(audit!.params[3]).toBe("Entity search performed"); + expect(JSON.parse(audit!.params[6] as string)).toMatchObject({ query: "John Doe", queryType: "name" }); + }); + + it("runs a dual NIN+BVN gateway lookup for 11-digit queries and isolates a gateway outage", async () => { + const fetchMock = vi.fn(async () => { throw new Error("gateway unreachable"); }); + vi.stubGlobal("fetch", fetchMock); + const { pool } = makePool((text) => { + if (text.includes("FROM kyc_records")) return { rows: [{ id: 3, nin: "12345678901", subjectName: "Ada Lovelace" }] }; + return { rows: [] }; + }); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).search({ query: "12345678901" }); + + expect(result.queryType).toBe("nin_bvn"); + // Gateway failed for BOTH lookups — but DB sources still returned + expect(fetchMock).toHaveBeenCalledTimes(2); + const bySource = Object.fromEntries(result.sources.map((s) => [s.source, s])); + expect(bySource.gateway_nin.status).toBe("error"); + expect(bySource.gateway_bvn.status).toBe("error"); + expect(bySource.gateway_nin.error).toContain("gateway unreachable"); + expect(bySource.investigations.status).toBe("ok"); + expect(bySource.kyc_records.status).toBe("ok"); + expect(bySource.candidate_profiles.status).toBe("ok"); + // The failing sources did not sink the search + expect(result.kyc).toHaveLength(1); + }); + + it("records successful gateway identities when the gateway responds", async () => { + vi.stubGlobal("fetch", vi.fn(async () => ({ + ok: true, + json: async () => ({ found: true, firstName: "Ada" }), + }))); + const { pool } = makePool(emptyHandler); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).search({ query: "12345678901" }); + + expect(result.sources.every((s) => s.status === "ok")).toBe(true); + expect(result.identities).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "nin", reference: "12345678901" }), + expect.objectContaining({ type: "bvn", reference: "12345678901" }), + ])); + }); + + it("routes RC queries to the CAC gateway and corporate profiles", async () => { + const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ companyName: "ACME LTD" }) })); + vi.stubGlobal("fetch", fetchMock); + const { pool, queries } = makePool((text) => { + if (text.includes("FROM corporate_screening_profiles")) { + return { rows: [{ id: 9, profileRef: "CSP-9", companyName: "ACME LTD", rcNumber: "RC123456" }] }; + } + return { rows: [] }; + }); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).search({ query: "rc 123456" }); + + expect(result.queryType).toBe("cac"); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/v1/cac/RC123456"), expect.anything()); + expect(result.identities).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "cac", reference: "RC123456" }), + expect.objectContaining({ type: "corporate_profile", reference: "CSP-9" }), + ])); + const profileQuery = queries.find((q) => q.text.includes("corporate_screening_profiles")); + expect(profileQuery!.params).toEqual([TENANT, "RC123456"]); + }); + + it("matches phone queries against all Nigerian variants", async () => { + const { pool, queries } = makePool(emptyHandler); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).search({ query: "08031234567" }); + + expect(result.queryType).toBe("phone"); + const phoneQuery = queries.find((q) => q.text.includes("FROM kyc_records")); + expect(phoneQuery!.params[0]).toBe(TENANT); + expect(phoneQuery!.params[1]).toEqual(expect.arrayContaining(["08031234567", "+2348031234567", "2348031234567"])); + }); + + it("fails closed when the tenant context is missing", async () => { + const { pool } = makePool(emptyHandler); + mockPool(pool); + await expect(entitySearchRouter.createCaller(tenantContext(null)).search({ query: "John Doe" })) + .rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("rejects anonymous callers", async () => { + const { pool } = makePool(emptyHandler); + mockPool(pool); + await expect(entitySearchRouter.createCaller(anonymousContext()).search({ query: "John Doe" })) + .rejects.toMatchObject({ code: "UNAUTHORIZED" }); + }); +}); + +// ─── Router-level: related-persons graph ───────────────────────────────────── + +describe("Entity Search: getAssociates", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + const investigationRow = { + id: 100, ref: "INV-2026-ABC123", subjectName: "John Doe", phone: "08031234567", + address: "12 Marina Road, Lagos", nin: "12345678901", bvn: null, candidateProfileId: 55, + }; + const candidateRow = { + id: 55, candidateRef: "CAND-55", firstName: "John", lastName: "Doe", + phone: "08031234567", currentAddress: "12 Marina Road, Lagos", nin: "12345678901", bvn: null, + }; + + function associatesPool() { + return makePool((text, params) => { + if (text.includes("FROM investigations WHERE ref =")) return { rows: [investigationRow] }; + if (text.includes("FROM candidate_profiles WHERE id =")) return { rows: [candidateRow] }; + if (text.includes("FROM corporate_screening_profiles")) return { rows: [{ + profileRef: "CSP-1", companyName: "DOE VENTURES LTD", + directorsResult: { data: { beneficial_owners: [{ name: "Jane Owner", shareholding: "70%" }] } }, + cacResult: { directors: ["James Director"] }, + }] }; + if (text.includes("FROM informal_references")) return { rows: [ + { id: "ref-guarantor-1", source_type: "guarantor", source_display_name: "Gambi Musa", relationship_to_subject: "Signed guarantor", provenance_status: "independently_confirmed" }, + { id: "ref-referee-2", source_type: "self_nominated_referee", source_display_name: "Ngozi Eze", relationship_to_subject: "Former colleague", provenance_status: "claimed" }, + ] }; + if (text.includes("FROM kyc_records")) return { rows: [ + { id: 88, subjectName: "Shared Phone Person", subjectRef: "KYC-88", phone: "+2348031234567" }, + ] }; + if (text.includes("FROM investigations")) return { rows: [ + { id: 101, ref: "INV-2026-XYZ999", subjectName: "Same Address Subject", phone: null, address: "12 Marina Road, Lagos" }, + ] }; + return { rows: [] }; + }); + } + + it("assembles a provenance-labelled graph with high/medium/low confidence edges", async () => { + const { pool } = associatesPool(); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).getAssociates({ investigationRef: "INV-2026-ABC123" }); + + // Subject node present + const subject = result.nodes.find((n) => n.type === "subject"); + expect(subject).toBeDefined(); + expect(subject!.name).toBe("John Doe"); + + const edgeByTarget = (name: string) => { + const node = result.nodes.find((n) => n.name === name); + expect(node, `node ${name}`).toBeDefined(); + return result.edges.find((e) => e.from === node!.id); + }; + + // direct_documented → high: beneficial owner, CAC director, signed guarantor + expect(edgeByTarget("Jane Owner")).toMatchObject({ confidence: "high", relationship: "beneficial_owner_of_subject_entity", evidenceRef: "CSP-1" }); + expect(edgeByTarget("James Director")).toMatchObject({ confidence: "high", relationship: "director_of_subject_entity" }); + expect(edgeByTarget("Gambi Musa")).toMatchObject({ confidence: "high", evidenceRef: "ref-guarantor-1" }); + // declared → medium: self-nominated referee + expect(edgeByTarget("Ngozi Eze")).toMatchObject({ confidence: "medium", evidenceRef: "ref-referee-2" }); + // shared_attribute → low: same phone, same address + expect(edgeByTarget("Shared Phone Person")).toMatchObject({ confidence: "low", relationship: "shared_phone", evidenceRef: "KYC-88" }); + expect(edgeByTarget("Same Address Subject")).toMatchObject({ confidence: "low", relationship: "shared_address", evidenceRef: "INV-2026-XYZ999" }); + }); + + it("scopes every association query to the caller's tenant", async () => { + const { pool, queries } = associatesPool(); + mockPool(pool); + + await entitySearchRouter.createCaller(tenantContext()).getAssociates({ investigationRef: "INV-2026-ABC123" }); + + const dataQueries = queries.filter((q) => !q.text.includes("INSERT INTO audit_log")); + expect(dataQueries.length).toBeGreaterThan(0); + for (const q of dataQueries) { + expect(q.params[0] === TENANT || q.params[1] === TENANT).toBe(true); + } + for (const q of dataQueries) { + expect(q.text.toLowerCase()).toContain("tenant"); + } + }); + + it("resolves the subject from a candidateId alone", async () => { + const { pool } = makePool((text) => { + if (text.includes("FROM candidate_profiles WHERE id =")) return { rows: [candidateRow] }; + if (text.includes("FROM informal_references")) return { rows: [ + { id: "ref-9", source_type: "landlord", source_display_name: "Chief Landlord", relationship_to_subject: "Landlord (3 yrs)", provenance_status: "attested" }, + ] }; + return { rows: [] }; + }); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).getAssociates({ candidateId: 55 }); + + const landlord = result.nodes.find((n) => n.name === "Chief Landlord"); + expect(landlord).toBeDefined(); + expect(result.edges.find((e) => e.from === landlord!.id)).toMatchObject({ confidence: "medium" }); + }); + + it("returns NOT_FOUND for another tenant's investigation", async () => { + const { pool } = makePool(emptyHandler); // tenant filter applied in SQL → no rows + mockPool(pool); + await expect(entitySearchRouter.createCaller(tenantContext()).getAssociates({ investigationRef: "INV-OTHER-TENANT" })) + .rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("fails closed without a tenant context", async () => { + const { pool } = makePool(emptyHandler); + mockPool(pool); + await expect(entitySearchRouter.createCaller(tenantContext(null)).getAssociates({ candidateId: 55 })) + .rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("requires an investigationRef or candidateId", async () => { + const { pool } = makePool(emptyHandler); + mockPool(pool); + await expect(entitySearchRouter.createCaller(tenantContext()).getAssociates({})) + .rejects.toThrow(); + }); +}); + +// ─── Router-level: search history ──────────────────────────────────────────── + +describe("Entity Search: searchHistory", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns the tenant's audit-logged searches with a next cursor when the page is full", async () => { + const rows = [ + { id: 102, userId: 21, action: "Entity search performed", detail: { query: "John Doe" }, createdAt: new Date() }, + { id: 101, userId: 21, action: "Entity search performed", detail: { query: "RC123456" }, createdAt: new Date() }, + ]; + const { pool, queries } = makePool((text) => text.includes("FROM audit_log") ? { rows } : { rows: [] }); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).searchHistory({ limit: 2 }); + + expect(result.items).toHaveLength(2); + expect(result.nextCursor).toBe(101); + const historyQuery = queries.find((q) => q.text.includes("FROM audit_log")); + expect(historyQuery!.params[0]).toBe(TENANT); // tenant-scoped + expect(historyQuery!.text).toContain("category = 'api'"); + }); + + it("returns null cursor on the last page and honours the cursor filter", async () => { + const { pool, queries } = makePool((text) => text.includes("FROM audit_log") ? { rows: [{ id: 50 }] } : { rows: [] }); + mockPool(pool); + + const result = await entitySearchRouter.createCaller(tenantContext()).searchHistory({ limit: 20, cursor: 101 }); + + expect(result.nextCursor).toBeNull(); + const historyQuery = queries.find((q) => q.text.includes("FROM audit_log")); + expect(historyQuery!.params[1]).toBe(101); + }); + + it("fails closed without a tenant context", async () => { + const { pool } = makePool(emptyHandler); + mockPool(pool); + await expect(entitySearchRouter.createCaller(tenantContext(null)).searchHistory({ limit: 20 })) + .rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); diff --git a/server/entitySearch.ts b/server/entitySearch.ts new file mode 100644 index 0000000..69e448d --- /dev/null +++ b/server/entitySearch.ts @@ -0,0 +1,547 @@ +/** + * entitySearch.ts — Unified one-box entity search + related-persons graph (WP1). + * + * Closes the Intelius-parity gap: a single query box that auto-detects whether the + * operator typed a NIN/BVN (11 digits), a CAC RC number, a phone number, or a name, + * then fans out across every relevant tenant-scoped source in parallel with + * per-source failure isolation (one failing source never sinks the rest). + * + * Also assembles a related-persons graph (beneficial owners, informal-sector + * references, shared phone/address) with provenance-labelled confidence: + * direct_documented (beneficial owner, signed guarantor) → high + * declared (self-nominated referee / other reference) → medium + * shared_attribute (same phone/address) → low + * + * SECURITY: every row is tenant-scoped from ctx.tenantId (never client-supplied), + * every search and graph view is written to the HMAC-integrity audit log, and the + * gateway call fails closed when the gateway URL is not configured. + */ +import { createHmac } from "node:crypto"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { protectedProcedure, router } from "./_core/trpc"; +import { getPgPool } from "./db"; +import { ENV } from "./_core/env"; + +// ─── Query-type detection (pure, unit-tested) ──────────────────────────────── + +export type QueryType = "nin_bvn" | "cac" | "phone" | "name"; + +/** + * Classify a one-box query string. + * - 11 digits not starting with 0 → dual NIN + BVN lookup + * - 11 digits starting with 0 (Nigerian local mobile) → phone + * - RC prefix followed by digits → CAC corporate lookup + * - any other dialable digit pattern (optionally + international) → phone + * - otherwise → fuzzy name search + */ +export function detectQueryType(raw: string): QueryType { + const q = raw.trim(); + if (/^\d{11}$/.test(q)) return q.startsWith("0") ? "phone" : "nin_bvn"; + if (/^RC[\s-]?\d{3,}$/i.test(q)) return "cac"; + const dialable = q.replace(/[\s\-()]/g, ""); + if (/^\+?\d{7,15}$/.test(dialable)) return "phone"; + return "name"; +} + +/** Normalize an RC query (e.g. "rc 123456" / "RC-123456") to "RC123456". */ +export function normalizeRcNumber(raw: string): string { + const compact = raw.trim().toUpperCase().replace(/[\s-]+/g, ""); + return compact.startsWith("RC") ? compact : `RC${compact.replace(/^RC/, "")}`; +} + +/** Equivalent Nigerian phone variants so "0803…", "234803…" and "+234803…" all match. */ +export function phoneVariants(raw: string): string[] { + const q = raw.trim().replace(/[\s\-()]/g, ""); + const variants = new Set([q]); + if (q.startsWith("+234")) { + variants.add(`0${q.slice(4)}`); + variants.add(q.slice(1)); + } else if (q.startsWith("234")) { + variants.add(`0${q.slice(3)}`); + variants.add(`+${q}`); + } else if (q.startsWith("0")) { + variants.add(`+234${q.slice(1)}`); + variants.add(`234${q.slice(1)}`); + } + return Array.from(variants); +} + +// ─── Confidence classification (pure, unit-tested) ─────────────────────────── + +export type EvidenceKind = "direct_documented" | "declared" | "shared_attribute"; +export type Confidence = "high" | "medium" | "low"; + +export function confidenceForEvidenceKind(kind: EvidenceKind): Confidence { + switch (kind) { + case "direct_documented": return "high"; + case "declared": return "medium"; + case "shared_attribute": return "low"; + } +} + +/** + * Map an informal_references.source_type to an evidence kind. + * A guarantor signs a legally-binding guarantee → direct_documented. + * Every other reference (self-nominated referee, landlord, association, …) is a + * declared relationship until independently corroborated. + */ +export function evidenceKindForReferenceType(sourceType: string): EvidenceKind { + return sourceType === "guarantor" ? "direct_documented" : "declared"; +} + +export interface BeneficialOwner { + name: string; + role: "beneficial_owner" | "director" | "shareholder"; + shareholding?: string; +} + +/** Extract beneficial owners / directors from a CAC gateway JSON payload of unknown-but-known shapes. */ +export function extractBeneficialOwners(payload: unknown): BeneficialOwner[] { + if (!payload || typeof payload !== "object") return []; + const out: BeneficialOwner[] = []; + const seen = new Set(); + const push = (entry: unknown, role: BeneficialOwner["role"]) => { + if (typeof entry === "string") { + const name = entry.trim(); + if (name.length >= 2 && !seen.has(`${role}:${name.toLowerCase()}`)) { + seen.add(`${role}:${name.toLowerCase()}`); + out.push({ name, role }); + } + return; + } + if (!entry || typeof entry !== "object") return; + const rec = entry as Record; + const name = String(rec.name ?? rec.fullName ?? rec.full_name ?? rec.director ?? "").trim(); + if (name.length < 2) return; + const key = `${role}:${name.toLowerCase()}`; + if (seen.has(key)) return; + seen.add(key); + const shareholding = rec.shareholding ?? rec.shares ?? rec.percentage; + out.push({ name, role, ...(shareholding != null ? { shareholding: String(shareholding) } : {}) }); + }; + const container = payload as Record; + const data = (container.data && typeof container.data === "object" ? container.data : {}) as Record; + for (const source of [container, data]) { + for (const entry of asArray(source.beneficial_owners) ?? asArray(source.beneficialOwners) ?? []) push(entry, "beneficial_owner"); + for (const entry of asArray(source.directors) ?? []) push(entry, "director"); + for (const entry of asArray(source.shareholders) ?? []) push(entry, "shareholder"); + } + if (Array.isArray(payload)) for (const entry of payload) push(entry, "director"); + return out; +} +function asArray(v: unknown): unknown[] | null { return Array.isArray(v) ? v : null; } + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +interface Actor { tenantId: number; userId: number; userEmail?: string } + +function requireTenant(ctx: { tenantId: number | null; user: { id: number; email?: string | null } | null }): Actor { + if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: "An authenticated operator is required" }); + if (!ctx.tenantId || ctx.tenantId <= 0) throw new TRPCError({ code: "FORBIDDEN", message: "An explicit tenant context is required" }); + return { tenantId: ctx.tenantId, userId: ctx.user.id, userEmail: ctx.user.email ?? undefined }; +} + +async function poolOrFail() { + const pool = await getPgPool(); + if (!pool) throw new TRPCError({ code: "SERVICE_UNAVAILABLE", message: "Entity search storage is unavailable" }); + return pool; +} + +/** Gateway identity lookup — fails closed when the gateway is not configured. */ +async function gatewayGet(path: string): Promise { + if (!ENV.bisGatewayUrl) throw new Error("BIS_GATEWAY_URL is not configured"); + const res = await fetch(`${ENV.bisGatewayUrl}${path}`, { headers: { "X-BIS-Key": ENV.bisGatewayKey } }); + if (!res.ok) throw new Error(`Gateway error ${res.status}: ${await res.text()}`); + return res.json(); +} + +export interface SourceStatus { + source: string; + status: "ok" | "error"; + latencyMs: number; + error?: string; +} + +interface SourcedResult { source: string; data: T } + +async function timed(source: string, run: () => Promise): Promise> { + const data = await run(); + return { source, data }; +} + +/** + * Fan out across sources with Promise.allSettled semantics: each source records + * its own status + latency, and a rejected source is captured as + * { status: "error" } instead of sinking the whole search. + */ +async function runSources(tasks: Array<{ source: string; run: () => Promise }>): Promise<{ results: Map; sources: SourceStatus[] }> { + const started = tasks.map(() => Date.now()); + const settled = await Promise.allSettled(tasks.map((t) => timed(t.source, t.run))); + const results = new Map(); + const sources: SourceStatus[] = []; + settled.forEach((outcome, i) => { + const latencyMs = Date.now() - started[i]; + if (outcome.status === "fulfilled") { + results.set(outcome.value.source, outcome.value.data); + sources.push({ source: outcome.value.source, status: "ok", latencyMs }); + } else { + sources.push({ + source: tasks[i].source, + status: "error", + latencyMs, + error: outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason), + }); + } + }); + return { results, sources }; +} + +type Pool = NonNullable>>; + +/** + * HMAC-integrity audit write, mirroring writeAuditLog in routers.ts + * (category "api"). Attempted on every search/graph view; a write failure is + * logged but does not block the operator, consistent with existing behavior. + */ +async function writeSearchAudit(pool: Pool, actor: Actor, entry: { + action: string; + targetRef?: string; + result?: "success" | "warning" | "failure"; + detail?: unknown; +}) { + try { + const result = entry.result ?? "success"; + const createdAt = new Date(); + const payload = [String(actor.userId), "api", entry.action, entry.targetRef ?? "", result, createdAt.toISOString()].join("|"); + const integrityHash = createHmac("sha256", ENV.auditHmacSecret).update(payload).digest("hex").slice(0, 64); + await pool.query( + `INSERT INTO audit_log ("tenantId", "userId", "userEmail", category, action, "targetRef", result, detail, "integrityHash", "createdAt") + VALUES ($1, $2, $3, 'api', $4, $5, $6, $7::jsonb, $8, $9)`, + [actor.tenantId, actor.userId, actor.userEmail ?? null, entry.action, entry.targetRef ?? null, result, + entry.detail != null ? JSON.stringify(entry.detail) : null, integrityHash, createdAt], + ); + } catch (e) { + console.warn("[EntitySearch] Failed to write audit log:", e); + } +} + +// ─── Row shapes ─────────────────────────────────────────────────────────────── + +const INVESTIGATION_COLS = `id, ref, "subjectName", "subjectType", status, "riskScore", "riskTier", nin, bvn, phone, email, "createdAt"`; +const KYC_COLS = `id, "subjectName", "subjectRef", status, "riskScore", nin, bvn, phone, "createdAt"`; +const CANDIDATE_COLS = `id, "candidateRef", "firstName", "lastName", email, phone, nin, bvn, "consentStatus"`; + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export const entitySearchRouter = router({ + /** + * Unified one-box search. Detects the query type, fans out across the gateway + * (NIN/BVN/CAC) and tenant-scoped internal tables in parallel, records per-source + * status + latency, and audit-logs who searched what. + */ + search: protectedProcedure + .input(z.object({ query: z.string().min(2).max(256) })) + .query(async ({ input, ctx }) => { + const actor = requireTenant(ctx); + const pool = await poolOrFail(); + const queryType = detectQueryType(input.query); + const q = input.query.trim(); + + const tasks: Array<{ source: string; run: () => Promise }> = []; + + if (queryType === "nin_bvn") { + tasks.push( + { source: "gateway_nin", run: () => gatewayGet(`/v1/nin/${q}`) }, + { source: "gateway_bvn", run: () => gatewayGet(`/v1/bvn/${q}`) }, + { source: "investigations", run: async () => (await pool.query( + `SELECT ${INVESTIGATION_COLS} FROM investigations + WHERE "tenantId" = $1 AND "deletedAt" IS NULL AND (nin = $2 OR bvn = $2) + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, q])).rows }, + { source: "kyc_records", run: async () => (await pool.query( + `SELECT ${KYC_COLS} FROM kyc_records + WHERE "tenantId" = $1 AND "deletedAt" IS NULL AND (nin = $2 OR bvn = $2) + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, q])).rows }, + { source: "candidate_profiles", run: async () => (await pool.query( + `SELECT ${CANDIDATE_COLS} FROM candidate_profiles + WHERE "tenantId" = $1 AND (nin = $2 OR bvn = $2) + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, q])).rows }, + ); + } else if (queryType === "cac") { + const rc = normalizeRcNumber(q); + tasks.push( + { source: "gateway_cac", run: () => gatewayGet(`/v1/cac/${encodeURIComponent(rc)}`) }, + { source: "corporate_screening_profiles", run: async () => (await pool.query( + `SELECT id, "profileRef", "companyName", "rcNumber", status, "overallOutcome", "riskScore", "investigationRef" + FROM corporate_screening_profiles + WHERE "tenantId" = $1 AND "rcNumber" = $2 + ORDER BY "createdAt" DESC LIMIT 10`, [actor.tenantId, rc])).rows }, + ); + } else if (queryType === "phone") { + const variants = phoneVariants(q); + tasks.push( + { source: "investigations", run: async () => (await pool.query( + `SELECT ${INVESTIGATION_COLS} FROM investigations + WHERE "tenantId" = $1 AND "deletedAt" IS NULL AND phone = ANY($2::text[]) + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, variants])).rows }, + { source: "kyc_records", run: async () => (await pool.query( + `SELECT ${KYC_COLS} FROM kyc_records + WHERE "tenantId" = $1 AND "deletedAt" IS NULL AND phone = ANY($2::text[]) + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, variants])).rows }, + { source: "candidate_profiles", run: async () => (await pool.query( + `SELECT ${CANDIDATE_COLS} FROM candidate_profiles + WHERE "tenantId" = $1 AND phone = ANY($2::text[]) + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, variants])).rows }, + ); + } else { + const pattern = `%${q.replace(/[%_]/g, (m) => `\\${m}`)}%`; + tasks.push( + { source: "investigations", run: async () => (await pool.query( + `SELECT ${INVESTIGATION_COLS} FROM investigations + WHERE "tenantId" = $1 AND "deletedAt" IS NULL AND "subjectName" ILIKE $2 + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, pattern])).rows }, + { source: "kyc_records", run: async () => (await pool.query( + `SELECT ${KYC_COLS} FROM kyc_records + WHERE "tenantId" = $1 AND "deletedAt" IS NULL AND "subjectName" ILIKE $2 + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, pattern])).rows }, + { source: "candidate_profiles", run: async () => (await pool.query( + `SELECT ${CANDIDATE_COLS} FROM candidate_profiles + WHERE "tenantId" = $1 AND ("firstName" || ' ' || "lastName") ILIKE $2 + ORDER BY "createdAt" DESC LIMIT 25`, [actor.tenantId, pattern])).rows }, + ); + } + + const { results, sources } = await runSources(tasks); + + const identities: Array> = []; + const ninIdentity = results.get("gateway_nin"); + if (ninIdentity != null) identities.push({ type: "nin", reference: q, data: ninIdentity }); + const bvnIdentity = results.get("gateway_bvn"); + if (bvnIdentity != null) identities.push({ type: "bvn", reference: q, data: bvnIdentity }); + const cacIdentity = results.get("gateway_cac"); + if (cacIdentity != null) identities.push({ type: "cac", reference: normalizeRcNumber(q), data: cacIdentity }); + for (const row of (results.get("candidate_profiles") as Array> | undefined) ?? []) { + identities.push({ type: "candidate", reference: row.candidateRef, data: row }); + } + for (const row of (results.get("corporate_screening_profiles") as Array> | undefined) ?? []) { + identities.push({ type: "corporate_profile", reference: row.profileRef, data: row }); + } + + const investigations = (results.get("investigations") as unknown[] | undefined) ?? []; + const kyc = (results.get("kyc_records") as unknown[] | undefined) ?? []; + + // Every search is sensitive: record who searched what, and how each source fared. + await writeSearchAudit(pool, actor, { + action: "Entity search performed", + targetRef: queryType === "name" ? q.slice(0, 64) : undefined, + result: sources.some((s) => s.status === "error") ? "warning" : "success", + detail: { + query: q, + queryType, + resultCounts: { identities: identities.length, investigations: investigations.length, kyc: kyc.length }, + sources: sources.map(({ source, status, latencyMs }) => ({ source, status, latencyMs })), + }, + }); + + return { queryType, identities, investigations, kyc, sources }; + }), + + /** + * Related-persons graph for a subject (by investigationRef and/or candidateId). + * Assembles beneficial owners (documented), informal-sector references + * (guarantor = documented, other refs = declared) and shared phone/address + * matches (shared_attribute) — all provenance-labelled and tenant-scoped. + */ + getAssociates: protectedProcedure + .input(z.object({ + investigationRef: z.string().min(4).max(32).optional(), + candidateId: z.number().int().positive().optional(), + }).refine((v) => v.investigationRef != null || v.candidateId != null, { + message: "An investigationRef or candidateId is required", + })) + .query(async ({ input, ctx }) => { + const actor = requireTenant(ctx); + const pool = await poolOrFail(); + + // ── Resolve the subject (tenant-scoped) ────────────────────────────── + let investigation: Record | null = null; + if (input.investigationRef) { + const res = await pool.query( + `SELECT id, ref, "subjectName", phone, address, nin, bvn, "candidateProfileId" + FROM investigations WHERE ref = $1 AND "tenantId" = $2 AND "deletedAt" IS NULL LIMIT 1`, + [input.investigationRef, actor.tenantId], + ); + investigation = res.rows[0] ?? null; + if (!investigation && input.candidateId == null) { + throw new TRPCError({ code: "NOT_FOUND", message: "Investigation not found in this tenant" }); + } + } + const candidateId = input.candidateId ?? (investigation?.candidateProfileId as number | null) ?? null; + let candidate: Record | null = null; + if (candidateId != null) { + const res = await pool.query( + `SELECT id, "candidateRef", "firstName", "lastName", phone, "currentAddress", nin, bvn + FROM candidate_profiles WHERE id = $1 AND "tenantId" = $2 LIMIT 1`, + [candidateId, actor.tenantId], + ); + candidate = res.rows[0] ?? null; + } + if (!investigation && !candidate) { + throw new TRPCError({ code: "NOT_FOUND", message: "Subject not found in this tenant" }); + } + + const subjectName = candidate + ? `${candidate.firstName} ${candidate.lastName}` + : String(investigation!.subjectName); + const subjectId = investigation ? `inv:${investigation.ref}` : `candidate:${candidate!.id}`; + const subjectPhones = [investigation?.phone, candidate?.phone].filter((p): p is string => typeof p === "string" && p.length > 0); + const subjectPhoneVariants = Array.from(new Set(subjectPhones.flatMap(phoneVariants))); + const subjectAddresses = [investigation?.address, candidate?.currentAddress] + .filter((a): a is string => typeof a === "string" && a.trim().length >= 8) + .map((a) => a.trim()); + + // ── Fan out across association sources (failure-isolated) ──────────── + const tasks: Array<{ source: string; run: () => Promise }> = []; + if (input.investigationRef) { + tasks.push({ source: "corporate_screening_profiles", run: async () => (await pool.query( + `SELECT "profileRef", "companyName", "directorsResult", "cacResult" + FROM corporate_screening_profiles WHERE "investigationRef" = $1 AND "tenantId" = $2`, + [input.investigationRef, actor.tenantId])).rows }); + } + tasks.push({ source: "informal_references", run: async () => (await pool.query( + `SELECT r.id, r.source_type, r.source_display_name, r.relationship_to_subject, r.provenance_status + FROM informal_references r + JOIN informal_verification_cases c ON c.id = r.case_id + WHERE r.tenant_id = $1 AND r.withdrawn_at IS NULL + AND (($2::int IS NOT NULL AND c.candidate_id = $2) OR ($3::int IS NOT NULL AND c.investigation_id = $3))`, + [actor.tenantId, candidateId, investigation?.id ?? null])).rows }); + if (subjectPhoneVariants.length > 0) { + tasks.push({ source: "shared_phone_kyc", run: async () => (await pool.query( + `SELECT id, "subjectName", "subjectRef", phone FROM kyc_records + WHERE "tenantId" = $1 AND "deletedAt" IS NULL AND phone = ANY($2::text[]) LIMIT 25`, + [actor.tenantId, subjectPhoneVariants])).rows }); + } + if (subjectAddresses.length > 0 || subjectPhoneVariants.length > 0) { + tasks.push({ source: "shared_attribute_investigations", run: async () => { + const clauses: string[] = []; + const params: unknown[] = [actor.tenantId]; + if (subjectPhoneVariants.length > 0) { params.push(subjectPhoneVariants); clauses.push(`phone = ANY($${params.length}::text[])`); } + for (const addr of subjectAddresses.slice(0, 3)) { params.push(`%${addr.replace(/[%_]/g, (m) => `\\${m}`)}%`); clauses.push(`address ILIKE $${params.length}`); } + const excludeRef = investigation?.ref ?? ""; + params.push(excludeRef); + return (await pool.query( + `SELECT id, ref, "subjectName", phone, address FROM investigations + WHERE "tenantId" = $1 AND "deletedAt" IS NULL AND ref <> $${params.length} AND (${clauses.join(" OR ")}) LIMIT 25`, + params, + )).rows; + } }); + } + const { results, sources } = await runSources(tasks); + + // ── Assemble the provenance-labelled graph ─────────────────────────── + interface GraphNode { id: string; name: string; type: string; sources: string[] } + interface GraphEdge { from: string; to: string; relationship: string; confidence: Confidence; evidenceRef: string } + const nodes = new Map(); + const edges: GraphEdge[] = []; + const edgeKeys = new Set(); + const addNode = (node: GraphNode) => { + const existing = nodes.get(node.id); + if (existing) { for (const s of node.sources) if (!existing.sources.includes(s)) existing.sources.push(s); } + else nodes.set(node.id, node); + }; + const addEdge = (edge: GraphEdge) => { + const key = `${edge.from}|${edge.to}|${edge.relationship}`; + if (edgeKeys.has(key)) return; + edgeKeys.add(key); + edges.push(edge); + }; + addNode({ id: subjectId, name: subjectName, type: "subject", sources: ["subject"] }); + + // Beneficial owners / directors — direct_documented → high + for (const profile of (results.get("corporate_screening_profiles") as Array> | undefined) ?? []) { + const owners = [...extractBeneficialOwners(profile.directorsResult), ...extractBeneficialOwners(profile.cacResult)]; + for (const owner of owners) { + const nodeId = `bo:${profile.profileRef}:${owner.name.toLowerCase().replace(/\s+/g, "_")}`; + addNode({ id: nodeId, name: owner.name, type: "beneficial_owner", sources: ["corporate_screening_profiles"] }); + addEdge({ + from: nodeId, to: subjectId, + relationship: owner.role === "director" ? "director_of_subject_entity" : "beneficial_owner_of_subject_entity", + confidence: confidenceForEvidenceKind("direct_documented"), + evidenceRef: String(profile.profileRef), + }); + } + } + + // Informal references — guarantor = direct_documented (high), others declared (medium) + for (const ref of (results.get("informal_references") as Array> | undefined) ?? []) { + const nodeId = `ref:${ref.id}`; + const kind = evidenceKindForReferenceType(String(ref.source_type)); + addNode({ id: nodeId, name: String(ref.source_display_name), type: String(ref.source_type), sources: ["informal_references"] }); + addEdge({ + from: nodeId, to: subjectId, + relationship: String(ref.relationship_to_subject), + confidence: confidenceForEvidenceKind(kind), + evidenceRef: String(ref.id), + }); + } + + // Shared phone in KYC — shared_attribute → low + for (const row of (results.get("shared_phone_kyc") as Array> | undefined) ?? []) { + const nodeId = `kyc:${row.id}`; + addNode({ id: nodeId, name: String(row.subjectName), type: "shared_contact", sources: ["kyc_records"] }); + addEdge({ + from: nodeId, to: subjectId, relationship: "shared_phone", + confidence: confidenceForEvidenceKind("shared_attribute"), + evidenceRef: String(row.subjectRef ?? `kyc:${row.id}`), + }); + } + + // Shared phone/address across investigations — shared_attribute → low + for (const row of (results.get("shared_attribute_investigations") as Array> | undefined) ?? []) { + const nodeId = `inv:${row.ref}`; + const sharedPhone = typeof row.phone === "string" && subjectPhoneVariants.includes(row.phone); + addNode({ id: nodeId, name: String(row.subjectName), type: "shared_contact", sources: ["investigations"] }); + addEdge({ + from: nodeId, to: subjectId, + relationship: sharedPhone ? "shared_phone" : "shared_address", + confidence: confidenceForEvidenceKind("shared_attribute"), + evidenceRef: String(row.ref), + }); + } + + await writeSearchAudit(pool, actor, { + action: "Related-persons graph viewed", + targetRef: input.investigationRef ?? (candidate ? String(candidate.candidateRef) : undefined), + detail: { investigationRef: input.investigationRef ?? null, candidateId, nodeCount: nodes.size, edgeCount: edges.length }, + }); + + return { nodes: Array.from(nodes.values()), edges, sources }; + }), + + /** + * Current tenant's audit-logged entity searches, keyset-paginated by audit id. + */ + searchHistory: protectedProcedure + .input(z.object({ + limit: z.number().int().min(1).max(100).default(20), + cursor: z.number().int().positive().optional(), + })) + .query(async ({ input, ctx }) => { + const actor = requireTenant(ctx); + const pool = await poolOrFail(); + const res = await pool.query( + `SELECT id, "userId", "userEmail", action, "targetRef", result, detail, "createdAt" + FROM audit_log + WHERE "tenantId" = $1 AND category = 'api' + AND (action LIKE 'Entity search%' OR action LIKE 'Related-persons%') + AND ($2::int IS NULL OR id < $2) + ORDER BY id DESC LIMIT $3`, + [actor.tenantId, input.cursor ?? null, input.limit], + ); + const items = res.rows as Array<{ id: number }>; + return { + items, + nextCursor: items.length === input.limit ? items[items.length - 1].id : null, + }; + }), +}); From f401127cd7df90331f2750bd844f11f94ffc75a2 Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:18:17 -0400 Subject: [PATCH 2/3] feat: register entitySearch router via mergeRouters; export mergeRouters from trpc core --- server/_core/index.ts | 7 ++++++- server/_core/trpc.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/server/_core/index.ts b/server/_core/index.ts index 61df6c8..127503f 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -15,7 +15,12 @@ import compression from "compression"; import { register as promRegister, collectDefaultMetrics, Counter, Histogram, Gauge } from "prom-client"; import { registerOAuthRoutes } from "./oauth"; import { registerStorageProxy } from "./storageProxy"; -import { appRouter } from "../routers"; +import { appRouter as baseAppRouter } from "../routers"; +import { entitySearchRouter } from "../entitySearch"; +import { mergeRouters, router } from "./trpc"; +// WP1: entitySearch registered via mergeRouters to keep server/routers.ts untouched; +// endpoints remain namespaced as entitySearch.search / getAssociates / searchHistory. +const appRouter = mergeRouters(baseAppRouter, router({ entitySearch: entitySearchRouter })); import { createContext, createContextFromRequest } from "./context"; import { serveStatic, setupVite } from "./vite"; import { notifyOwner } from "./notification"; diff --git a/server/_core/trpc.ts b/server/_core/trpc.ts index 0d528ec..7c99b26 100644 --- a/server/_core/trpc.ts +++ b/server/_core/trpc.ts @@ -10,6 +10,7 @@ const t = initTRPC.context().create({ }); export const router = t.router; +export const mergeRouters = t.mergeRouters; export const publicProcedure = t.procedure; const requireUser = t.middleware(async opts => { From 78646b5e6a40d4825ec3c28550594df154119014 Mon Sep 17 00:00:00 2001 From: munisp <155237317+munisp@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:28:18 -0400 Subject: [PATCH 3/3] revert: restore _core/index.ts and _core/trpc.ts to main (routers.ts registration applied via integration patch per recon) --- server/_core/index.ts | 7 +------ server/_core/trpc.ts | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/server/_core/index.ts b/server/_core/index.ts index 127503f..61df6c8 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -15,12 +15,7 @@ import compression from "compression"; import { register as promRegister, collectDefaultMetrics, Counter, Histogram, Gauge } from "prom-client"; import { registerOAuthRoutes } from "./oauth"; import { registerStorageProxy } from "./storageProxy"; -import { appRouter as baseAppRouter } from "../routers"; -import { entitySearchRouter } from "../entitySearch"; -import { mergeRouters, router } from "./trpc"; -// WP1: entitySearch registered via mergeRouters to keep server/routers.ts untouched; -// endpoints remain namespaced as entitySearch.search / getAssociates / searchHistory. -const appRouter = mergeRouters(baseAppRouter, router({ entitySearch: entitySearchRouter })); +import { appRouter } from "../routers"; import { createContext, createContextFromRequest } from "./context"; import { serveStatic, setupVite } from "./vite"; import { notifyOwner } from "./notification"; diff --git a/server/_core/trpc.ts b/server/_core/trpc.ts index 7c99b26..0d528ec 100644 --- a/server/_core/trpc.ts +++ b/server/_core/trpc.ts @@ -10,7 +10,6 @@ const t = initTRPC.context().create({ }); export const router = t.router; -export const mergeRouters = t.mergeRouters; export const publicProcedure = t.procedure; const requireUser = t.middleware(async opts => {