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, clientMergeCandidates } from '@/lib/db/schema/clients'; import { errorResponse, NotFoundError } from '@/lib/errors'; import { listPendingMergeCandidates, mergeClients, type MergeFieldChoices, } from '@/lib/services/client-merge.service'; /** * GET /api/v1/admin/duplicates * * Pending merge candidates for the current port, sorted by score. * Each row hydrates its two client summaries so the review-queue UI * can render side-by-side cards without an N+1 fetch. */ export async function listHandler(_req: Request, ctx: AuthContext): Promise { try { const pairs = await listPendingMergeCandidates(ctx.portId); if (pairs.length === 0) return NextResponse.json({ data: [] }); const ids = Array.from(new Set(pairs.flatMap((p) => [p.clientAId, p.clientBId]))); const clientRows = await db .select({ id: clients.id, fullName: clients.fullName, archivedAt: clients.archivedAt, mergedIntoClientId: clients.mergedIntoClientId, createdAt: clients.createdAt, }) .from(clients) .where(inArray(clients.id, ids)); const clientById = new Map(clientRows.map((c) => [c.id, c])); const data = pairs .map((p) => { const a = clientById.get(p.clientAId); const b = clientById.get(p.clientBId); if (!a || !b) return null; // FK orphan — shouldn't happen, but be defensive // Skip pairs where one side has already been merged or archived. if (a.mergedIntoClientId || b.mergedIntoClientId) return null; return { id: p.id, score: p.score, reasons: p.reasons, createdAt: p.createdAt, clientA: { id: a.id, fullName: a.fullName, createdAt: a.createdAt }, clientB: { id: b.id, fullName: b.fullName, createdAt: b.createdAt }, }; }) .filter((row): row is NonNullable => row !== null); return NextResponse.json({ data }); } catch (error) { return errorResponse(error); } } /** * POST /api/v1/admin/duplicates/[id]/merge * * Body: { winnerId: string, fieldChoices?: MergeFieldChoices } * * Confirms a merge candidate. The winner is the one the user picked * to keep; the other side becomes the loser. Calls into the merge * service which is the only path that touches client_merge_log. */ export async function confirmMergeHandler( req: Request, ctx: AuthContext, params: { id?: string }, ): Promise { try { const id = params.id ?? ''; const body = (await req.json().catch(() => ({}))) as { winnerId?: string; fieldChoices?: MergeFieldChoices; }; if (!body.winnerId) { return NextResponse.json({ error: 'winnerId required' }, { status: 400 }); } const [candidate] = await db .select() .from(clientMergeCandidates) .where( and( eq(clientMergeCandidates.id, id), eq(clientMergeCandidates.portId, ctx.portId), eq(clientMergeCandidates.status, 'pending'), ), ); if (!candidate) throw new NotFoundError('Merge candidate'); const loserId = body.winnerId === candidate.clientAId ? candidate.clientBId : body.winnerId === candidate.clientBId ? candidate.clientAId : null; if (!loserId) { return NextResponse.json( { error: 'winnerId must match one of the candidate clients' }, { status: 400 }, ); } const result = await mergeClients({ winnerId: body.winnerId, loserId, mergedBy: ctx.userId, fieldChoices: body.fieldChoices, }); return NextResponse.json({ data: result }); } catch (error) { return errorResponse(error); } } /** * POST /api/v1/admin/duplicates/[id]/dismiss * * Mark a merge candidate as dismissed. The background scoring job * skips dismissed pairs on subsequent runs (a future score increase * can re-create them). */ export async function dismissHandler( _req: Request, ctx: AuthContext, params: { id?: string }, ): Promise { try { const id = params.id ?? ''; const result = await db .update(clientMergeCandidates) .set({ status: 'dismissed', resolvedAt: new Date(), resolvedBy: ctx.userId, }) .where( and( eq(clientMergeCandidates.id, id), eq(clientMergeCandidates.portId, ctx.portId), eq(clientMergeCandidates.status, 'pending'), ), ) .returning({ id: clientMergeCandidates.id }); if (result.length === 0) throw new NotFoundError('Merge candidate'); return NextResponse.json({ data: { id: result[0]!.id, status: 'dismissed' } }); } catch (error) { return errorResponse(error); } }