diff --git a/app/api/pax8/company-matches/[id]/resolve/route.ts b/app/api/pax8/company-matches/[id]/resolve/route.ts new file mode 100644 index 0000000..9a342ed --- /dev/null +++ b/app/api/pax8/company-matches/[id]/resolve/route.ts @@ -0,0 +1,79 @@ +/** + * POST /api/pax8/company-matches/[id]/resolve + * Body: { companyId: number, note?: string } + * + * Resolves a flagged PAX8 -> Autotask company match by linking the PAX8 + * company to the given Autotask company id. Unlike the read-side GET + * /api/pax8/company-matches (D-07, requireAuth only), this mutation is + * admin-gated (D-08) — mutating a match crosses a privilege boundary. + * + * companyId is NOT required to be one of the review's candidate_company_ids + * (D-05 manual-search fallback / D-09 zero-candidate case) — the resolver + * validates existence + active state instead of candidate membership. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { resolvePax8CompanyMatch } from '@/lib/services/pax8-company-match-resolver'; + +const ResolveBody = z.object({ + companyId: z.number().int().positive(), + note: z.string().max(500).optional(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requirePermission('admin', 'access'); + if (error) return error; + + const { id } = await params; + if (!/^[0-9a-f-]{36}$/i.test(id)) { + return NextResponse.json({ error: 'Invalid review id' }, { status: 400 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + const parsed = ResolveBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid payload', details: parsed.error.flatten() }, + { status: 400 } + ); + } + + try { + const result = await postgresClient.transaction((tx) => + resolvePax8CompanyMatch(tx, { + reviewId: id, + companyId: parsed.data.companyId, + note: parsed.data.note ?? null, + userId: session?.user?.id ?? null, + }) + ); + + if (result.ok) { + return NextResponse.json({ ok: true, resolvedToCompanyId: result.resolvedToCompanyId }); + } + + const statusByCode: Record = { + not_found: 404, + already_resolved: 409, + company_not_found: 400, + }; + return NextResponse.json({ error: result.message }, { status: statusByCode[result.code] }); + } catch (err) { + console.error('Failed to resolve PAX8 company match:', err); + return NextResponse.json( + { error: 'Failed to resolve PAX8 company match', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +}