feat(14-02): implement resolvePax8CompanyMatch resolver

- Two-table transactional write: pax8_companies.match_method='manual' AND
  pax8_company_match_review.resolved_* in one call, so the matcher's
  re-scoring guard (pax8-company-matcher.ts ~216-231) never re-flags a
  manually resolved company
- Guards not_found / already_resolved via FOR UPDATE select
- Validates target company existence + active state (substitute for
  candidate-membership check — D-05/D-09 allow non-candidate ids)
- All five vitest behavior cases green; tsc clean
This commit is contained in:
lorentz 2026-07-11 14:28:48 -04:00
parent afdcf1412d
commit 0ce51a0167

View file

@ -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: <T = unknown>(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<ResolveResult> {
const { reviewId, companyId, note, userId } = params;
const reviewRes = await tx.query<ReviewRow>(
`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 };
}