import { NextResponse } from 'next/server'; import { and, eq, inArray } from 'drizzle-orm'; import type { AuthContext } from '@/lib/api/helpers'; import { db } from '@/lib/db'; import { clients, clientContacts } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; import { errorResponse } from '@/lib/errors'; import { findClientMatches, type MatchCandidate } from '@/lib/dedup/find-matches'; import { normalizeEmail, normalizeName, normalizePhone } from '@/lib/dedup/normalize'; import type { CountryCode } from '@/lib/i18n/countries'; /** * GET /api/v1/clients/match-candidates * * Query parameters (any combination): * email Free-text email; gets normalized server-side. * phone Free-text phone; gets normalized to E.164 server-side. * name Free-text full name; used for surname-token blocking. * country Optional ISO country hint (default: AI for Port Nimara). * * Returns the top candidates that scored above the soft-warn threshold, * each with a small client summary the form's suggestion card can * render. Confidence tiers and rules are applied server-side from the * port's `system_settings` (when wired) or sensible defaults otherwise. * * Used by `useDedupSuggestion` in the new-client form. Debounced on * the client; this endpoint must be cheap (single port pool fetch + * an in-memory dedup pass). */ export async function getMatchCandidatesHandler( req: Request, ctx: AuthContext, ): Promise { try { const url = new URL(req.url); const rawEmail = url.searchParams.get('email'); const rawPhone = url.searchParams.get('phone'); const rawName = url.searchParams.get('name'); const country = (url.searchParams.get('country') ?? 'AI') as CountryCode; const email = rawEmail ? normalizeEmail(rawEmail) : null; const phoneResult = rawPhone ? normalizePhone(rawPhone, country) : null; const nameResult = rawName ? normalizeName(rawName) : null; // If the caller didn't give us anything useful to match on, return empty // — short-circuit rather than scan every client for nothing. if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) { return NextResponse.json({ data: [] }); } // Build the input candidate. const input: MatchCandidate = { id: '__incoming__', fullName: nameResult?.display ?? null, surnameToken: nameResult?.surnameToken ?? null, emails: email ? [email] : [], phonesE164: phoneResult?.e164 ? [phoneResult.e164] : [], countryIso: country, }; // Fetch the live pool for this port. We keep this O(N) over clients // since the dedup library does its own blocking; for ports with // thousands of clients we can later restrict by surname-token / // contact lookups, but for current scale the simple full-pool fetch // is fine. const liveClients = await db .select({ id: clients.id, fullName: clients.fullName, nationalityIso: clients.nationalityIso, }) .from(clients) .where(and(eq(clients.portId, ctx.portId))); if (liveClients.length === 0) { return NextResponse.json({ data: [] }); } const clientIds = liveClients.map((c) => c.id); const contactRows = await db .select({ clientId: clientContacts.clientId, channel: clientContacts.channel, value: clientContacts.value, valueE164: clientContacts.valueE164, }) .from(clientContacts) .where(inArray(clientContacts.clientId, clientIds)); // Group contacts by client for the candidate map. const emailsByClient = new Map(); const phonesByClient = new Map(); for (const c of contactRows) { if (c.channel === 'email') { const arr = emailsByClient.get(c.clientId) ?? []; arr.push(c.value.toLowerCase()); emailsByClient.set(c.clientId, arr); } else if (c.channel === 'phone' || c.channel === 'whatsapp') { if (c.valueE164) { const arr = phonesByClient.get(c.clientId) ?? []; arr.push(c.valueE164); phonesByClient.set(c.clientId, arr); } } } const pool: MatchCandidate[] = liveClients.map((c) => { const named = normalizeName(c.fullName); return { id: c.id, fullName: c.fullName, surnameToken: named.surnameToken ?? null, emails: emailsByClient.get(c.id) ?? [], phonesE164: phonesByClient.get(c.id) ?? [], countryIso: (c.nationalityIso as CountryCode | null) ?? null, }; }); const matches = findClientMatches(input, pool, { highScore: 90, mediumScore: 50, }); // Only return medium+ — low-confidence noise isn't useful at the // create-form layer (background scoring queue picks those up). const useful = matches.filter((m) => m.confidence !== 'low'); if (useful.length === 0) { return NextResponse.json({ data: [] }); } // Pull a quick summary for each surfaced candidate so the suggestion // card has enough to render ("Marcus Laurent · 2 interests · last // contact 9d ago"). const summarizedIds = useful.map((m) => m.candidate.id); const interestCounts = await db .select({ clientId: interests.clientId }) .from(interests) .where(inArray(interests.clientId, summarizedIds)); const interestsByClient = new Map(); for (const r of interestCounts) { interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1); } const data = useful.map((m) => ({ clientId: m.candidate.id, fullName: m.candidate.fullName, score: m.score, confidence: m.confidence, reasons: m.reasons, interestCount: interestsByClient.get(m.candidate.id) ?? 0, emails: m.candidate.emails, phonesE164: m.candidate.phonesE164, })); return NextResponse.json({ data }); } catch (error) { return errorResponse(error); } }