From a08664baaeab666e22ad4fd7e38cbaf3d17ffefe Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 14:27:30 -0400 Subject: [PATCH] feat(14-02): add GET /api/pax8/company-matches review queue - requireAuth-gated (D-07) list of unresolved pax8_company_match_review rows - Bulk-fetches candidate Autotask company names in one ANY($1::bigint[]) query - Returns items with pax8 company + zipped candidates (id/name/confidence) --- app/api/pax8/company-matches/route.ts | 94 +++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 app/api/pax8/company-matches/route.ts diff --git a/app/api/pax8/company-matches/route.ts b/app/api/pax8/company-matches/route.ts new file mode 100644 index 0000000..278e8a7 --- /dev/null +++ b/app/api/pax8/company-matches/route.ts @@ -0,0 +1,94 @@ +/** + * GET /api/pax8/company-matches + * Returns unresolved PAX8 company match reviews with bulk-fetched candidate + * Autotask company names, paged. + * Query params: + * limit (default 50, max 200) + * offset (default 0) + * + * D-07: gated by requireAuth() only — viewing the flagged-match queue is + * manager-visible; only the resolve mutation (POST .../[id]/resolve) is + * admin-gated. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +interface ReviewRow { + id: string; + detected_at: string; + candidate_company_ids: number[]; + match_confidences: string[]; + pax8_company_id: string; + pax8_company_name: string; +} + +interface CompanyRow { + id: string; + company_name: string; +} + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + const url = request.nextUrl; + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200); + const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0); + + try { + const reviews = await postgresClient.query( + `SELECT r.id::text, r.detected_at::text, r.candidate_company_ids, r.match_confidences, + pc.id AS pax8_company_id, pc.name AS pax8_company_name + FROM pax8_company_match_review r + JOIN pax8_companies pc ON pc.id = r.pax8_company_id + WHERE r.resolved_at IS NULL + ORDER BY r.detected_at DESC + LIMIT $1 OFFSET $2`, + [limit, offset] + ); + + // Bulk-fetch all candidate company names in one query. + const allCandidateIds = new Set(); + for (const r of reviews.rows) { + for (const id of r.candidate_company_ids ?? []) allCandidateIds.add(Number(id)); + } + const nameMap = new Map(); + if (allCandidateIds.size > 0) { + const companyRes = await postgresClient.query( + `SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`, + [Array.from(allCandidateIds)] + ); + for (const c of companyRes.rows) nameMap.set(Number(c.id), c.company_name); + } + + const totalRes = await postgresClient.query<{ count: string }>( + `SELECT COUNT(*)::text AS count FROM pax8_company_match_review WHERE resolved_at IS NULL` + ); + const total = parseInt(totalRes.rows[0]?.count ?? '0', 10); + + const items = reviews.rows.map((r) => ({ + id: r.id, + detectedAt: r.detected_at, + pax8CompanyId: r.pax8_company_id, + pax8CompanyName: r.pax8_company_name, + candidates: (r.candidate_company_ids ?? []).map((companyId, i) => { + const numId = Number(companyId); + return { + companyId: numId, + companyName: nameMap.get(numId) ?? null, + confidence: r.match_confidences?.[i] ?? null, + }; + }), + })); + + return NextResponse.json({ items, total, limit, offset }); + } catch (error) { + console.error('Failed to fetch PAX8 company match review queue:', error); + return NextResponse.json( + { error: 'Failed to fetch PAX8 company match review queue', message: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +}