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:
parent
afdcf1412d
commit
0ce51a0167
1 changed files with 87 additions and 0 deletions
87
lib/services/pax8-company-match-resolver.ts
Normal file
87
lib/services/pax8-company-match-resolver.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue