diff --git a/lib/services/pax8-company-match-resolver.ts b/lib/services/pax8-company-match-resolver.ts new file mode 100644 index 0000000..67f9379 --- /dev/null +++ b/lib/services/pax8-company-match-resolver.ts @@ -0,0 +1,87 @@ +/** + * resolvePax8CompanyMatch — the two-table transactional write behind + * POST /api/pax8/company-matches/[id]/resolve. + * + * Both `pax8_companies.match_method='manual'` and + * `pax8_company_match_review.resolved_at` must be set in the same + * transaction, or pax8-company-matcher.ts's re-scoring eligibility guard + * (lines ~216-231: `c.match_method IS DISTINCT FROM 'manual' AND NOT EXISTS + * (... resolved_at IS NOT NULL)`) will re-flag the company on the next sync. + * + * Deliberately does NOT check `candidate_company_ids` membership — D-05 + * (manual-search fallback) and D-09 (zero-candidate case) require accepting + * a companyId that was never one of the stored candidates, as long as it + * exists and is active. + */ + +export type ResolveResult = + | { ok: true; resolvedToCompanyId: number } + | { ok: false; code: 'not_found' | 'already_resolved' | 'company_not_found'; message: string }; + +interface TxLike { + query: (sql: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number }>; +} + +interface ResolveParams { + reviewId: string; + companyId: number; + note: string | null; + userId: string | null; +} + +interface ReviewRow { + pax8_company_id: string; + resolved_at: string | null; +} + +export async function resolvePax8CompanyMatch( + tx: TxLike, + params: ResolveParams +): Promise { + const { reviewId, companyId, note, userId } = params; + + const reviewRes = await tx.query( + `SELECT pax8_company_id, resolved_at + FROM pax8_company_match_review + WHERE id = $1 + FOR UPDATE`, + [reviewId] + ); + if (reviewRes.rowCount === 0) { + return { ok: false, code: 'not_found', message: 'Review not found' }; + } + const review = reviewRes.rows[0]; + if (review.resolved_at) { + return { ok: false, code: 'already_resolved', message: 'Already resolved' }; + } + + const companyRes = await tx.query( + `SELECT 1 FROM companies WHERE id = $1 AND is_active = true AND is_deleted = false`, + [companyId] + ); + if (companyRes.rowCount === 0) { + return { ok: false, code: 'company_not_found', message: 'Target company not found or inactive' }; + } + + await tx.query( + `UPDATE pax8_companies + SET autotask_company_id = $2, + match_confidence = NULL, + match_method = 'manual', + matched_at = NOW() + WHERE id = $1`, + [review.pax8_company_id, companyId] + ); + + await tx.query( + `UPDATE pax8_company_match_review + SET resolved_at = NOW(), + resolved_by_user_id = $2, + resolved_to_company_id = $3, + resolution_note = $4 + WHERE id = $1`, + [reviewId, userId, companyId, note] + ); + + return { ok: true, resolvedToCompanyId: companyId }; +}