chore: merge executor worktree (worktree-agent-a11c767c2a8721d5b)
This commit is contained in:
commit
580e7ac508
5 changed files with 528 additions and 0 deletions
107
.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md
Normal file
107
.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
---
|
||||
phase: 14-pax8-ui-surface
|
||||
plan: 02
|
||||
subsystem: api
|
||||
tags: [pax8, postgres, transaction, vitest, requireAuth, requirePermission]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 14-pax8-ui-surface
|
||||
provides: migration 091/093 schema (pax8_companies, pax8_company_match_review), pax8-company-matcher.ts re-scoring guard
|
||||
provides:
|
||||
- "GET /api/pax8/company-matches — requireAuth-gated unresolved review queue with bulk-fetched candidate names"
|
||||
- "resolvePax8CompanyMatch(tx, params) — unit-tested two-table transactional resolver service"
|
||||
- "POST /api/pax8/company-matches/[id]/resolve — requirePermission('admin','access')-gated resolve mutation"
|
||||
affects: [14-pax8-ui-surface plan 06 (manual verification), any future pax8 admin UI plan consuming these routes]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Extracted transactional write logic into lib/services/ as a plain-tx-parameter function for unit testability, mirroring device-link-conflicts' resolve route shape but diverging on the validation substitute (existence/active check instead of candidate-membership check)"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- app/api/pax8/company-matches/route.ts
|
||||
- lib/services/pax8-company-match-resolver.ts
|
||||
- lib/services/pax8-company-match-resolver.test.ts
|
||||
- app/api/pax8/company-matches/[id]/resolve/route.ts
|
||||
modified: []
|
||||
|
||||
key-decisions:
|
||||
- "D-07: GET /api/pax8/company-matches uses requireAuth() only (not requirePermission) — the review queue is manager-visible; only the mutation is admin-gated"
|
||||
- "D-08: POST .../resolve uses requirePermission('admin','access') — the deliberate asymmetry vs the GET route"
|
||||
- "D-05/D-09: resolver does not enforce candidate_company_ids membership — validates target company existence + active state instead, so the manual-search fallback and zero-candidate case can resolve to any valid Autotask company"
|
||||
|
||||
patterns-established:
|
||||
- "Resolver-as-tx-parameter pattern: lib/services/pax8-company-match-resolver.ts takes a `{ query }` tx handle as its first argument rather than importing postgresClient directly, making the transactional write path testable with a hand-rolled mock instead of vi.mock('@/lib/services/postgres-client')"
|
||||
|
||||
requirements-completed: [PAX8-12, PAX8-14]
|
||||
|
||||
# Metrics
|
||||
duration: 12min
|
||||
completed: 2026-07-11
|
||||
---
|
||||
|
||||
# Phase 14 Plan 02: PAX8 Company Match Review + Resolve API Summary
|
||||
|
||||
**GET review-queue route (requireAuth) + admin-gated POST resolve route (requirePermission) backed by a unit-tested `resolvePax8CompanyMatch` service that writes both `pax8_companies.match_method='manual'` and the review row atomically in one transaction.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~12 min
|
||||
- **Started:** 2026-07-11T18:18:00Z (approx, prior to first Read)
|
||||
- **Completed:** 2026-07-11T18:29:47Z
|
||||
- **Tasks:** 3 completed (Task 2 followed TDD RED→GREEN)
|
||||
- **Files modified:** 4 created, 0 modified
|
||||
|
||||
## Accomplishments
|
||||
- `GET /api/pax8/company-matches` returns the unresolved review queue (paginated, `limit`/`offset`) with each flagged PAX8 company's top-3 candidate Autotask companies resolved to names via a single bulk `= ANY($1::bigint[])` query — no N+1 lookups.
|
||||
- `resolvePax8CompanyMatch()` extracted into `lib/services/` as the load-bearing, test-covered write path: it locks the review row (`FOR UPDATE`), guards `not_found`/`already_resolved`, validates the target company's existence + active state, then issues both required UPDATEs (`pax8_companies.match_method='manual'` and `pax8_company_match_review.resolved_*`) so the matcher's re-scoring eligibility guard never re-flags a resolved company on the next sync.
|
||||
- `POST /api/pax8/company-matches/[id]/resolve` is admin-only (`requirePermission('admin','access')`), zod-validates the body (`companyId` positive int, `note` optional ≤500 chars), and delegates to the resolver inside `postgresClient.transaction(...)`, mapping result codes to HTTP status (`ok`→200, `not_found`→404, `already_resolved`→409, `company_not_found`→400).
|
||||
- Five vitest behavior cases green, covering the full resolver contract including the D-05/D-09 non-candidate-id acceptance case.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: GET /api/pax8/company-matches** - `a08664b` (feat)
|
||||
2. **Task 2: resolvePax8CompanyMatch service + unit test (RED→GREEN)** - `afdcf14` (test, RED) → `0ce51a0` (feat, GREEN) → `4d7a58b` (docs, minor wording fix for grep acceptance check)
|
||||
3. **Task 3: POST /api/pax8/company-matches/[id]/resolve** - `a81e358` (feat)
|
||||
|
||||
_TDD Gate Compliance: `test(...)` commit `afdcf14` precedes `feat(...)` commit `0ce51a0` — RED then GREEN confirmed by running vitest before and after implementation._
|
||||
|
||||
## Files Created/Modified
|
||||
- `app/api/pax8/company-matches/route.ts` - GET unresolved review queue, requireAuth-gated, bulk candidate-name fetch
|
||||
- `lib/services/pax8-company-match-resolver.ts` - `resolvePax8CompanyMatch(tx, params)` two-table transactional resolver
|
||||
- `lib/services/pax8-company-match-resolver.test.ts` - vitest coverage: success (both writes), not_found, already_resolved, company_not_found, non-candidate companyId still resolves
|
||||
- `app/api/pax8/company-matches/[id]/resolve/route.ts` - POST resolve, requirePermission('admin','access')-gated, zod body validation, wraps resolver in a transaction
|
||||
|
||||
## Decisions Made
|
||||
- Followed interfaces block exactly for the `ResolveResult` discriminated union and `resolvePax8CompanyMatch` signature — no divergence from the plan's contract.
|
||||
- Task 2's docstring originally referenced the literal string `candidate_company_ids` to explain what is deliberately *not* checked; reworded to satisfy the plan's grep-based acceptance criterion ("no reference to candidate_company_ids in the resolver source") without changing any logic — tracked as commit `4d7a58b`, not a deviation rule (documentation-only, no behavior change).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. The one follow-up commit (`4d7a58b`) was a same-task documentation wording fix to satisfy a literal grep-based acceptance criterion, not a functional deviation.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
None.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None - no external service configuration required.
|
||||
|
||||
## Next Phase Readiness
|
||||
- Read and write routes for PAX8 company match resolution are complete, type-checked, and unit-tested.
|
||||
- Ready for Plan 06's manual verification step (resolve a real flagged company as admin, confirm both tables update, confirm re-sync leaves it untouched, confirm non-admin gets 403).
|
||||
- No blockers for dependent UI plans consuming `GET /api/pax8/company-matches` / `POST .../resolve`.
|
||||
|
||||
---
|
||||
*Phase: 14-pax8-ui-surface*
|
||||
*Completed: 2026-07-11*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
All created files verified present on disk; all task/summary commit hashes verified in git log.
|
||||
79
app/api/pax8/company-matches/[id]/resolve/route.ts
Normal file
79
app/api/pax8/company-matches/[id]/resolve/route.ts
Normal file
|
|
@ -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<typeof result.code, number> = {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
94
app/api/pax8/company-matches/route.ts
Normal file
94
app/api/pax8/company-matches/route.ts
Normal file
|
|
@ -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<ReviewRow>(
|
||||
`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<number>();
|
||||
for (const r of reviews.rows) {
|
||||
for (const id of r.candidate_company_ids ?? []) allCandidateIds.add(Number(id));
|
||||
}
|
||||
const nameMap = new Map<number, string>();
|
||||
if (allCandidateIds.size > 0) {
|
||||
const companyRes = await postgresClient.query<CompanyRow>(
|
||||
`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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
162
lib/services/pax8-company-match-resolver.test.ts
Normal file
162
lib/services/pax8-company-match-resolver.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* pax8-company-match-resolver.ts unit tests.
|
||||
*
|
||||
* The resolver takes `tx` as a parameter (it runs inside
|
||||
* postgresClient.transaction() at the call site), so no module mock is
|
||||
* needed here — a hand-rolled mock tx `{ query: vi.fn() }` is scripted per
|
||||
* test to return the sequenced results, and assertions check the SQL
|
||||
* strings + bound params of each `query` call plus the returned
|
||||
* ResolveResult, matching the mocking discipline of
|
||||
* pax8-company-matcher.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { resolvePax8CompanyMatch } from './pax8-company-match-resolver';
|
||||
|
||||
interface MockCall {
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type QueryMock = ReturnType<typeof vi.fn<(...args: any[]) => Promise<{ rows: unknown[]; rowCount: number }>>>;
|
||||
|
||||
function makeTx(queryMock: QueryMock) {
|
||||
return {
|
||||
query: <T = unknown>(sql: string, params?: unknown[]) =>
|
||||
queryMock(sql, params) as Promise<{ rows: T[]; rowCount: number }>,
|
||||
};
|
||||
}
|
||||
|
||||
function calls(queryMock: QueryMock): MockCall[] {
|
||||
return queryMock.mock.calls.map(([sql, params]) => ({
|
||||
sql: String(sql),
|
||||
params: (params as unknown[]) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
describe('resolvePax8CompanyMatch', () => {
|
||||
it('writes both pax8_companies and pax8_company_match_review on success', async () => {
|
||||
const queryMock = vi.fn();
|
||||
// 1. review SELECT ... FOR UPDATE
|
||||
queryMock.mockResolvedValueOnce({
|
||||
rows: [{ pax8_company_id: 'pax8-uuid-1', resolved_at: null }],
|
||||
rowCount: 1,
|
||||
});
|
||||
// 2. company existence/active check
|
||||
queryMock.mockResolvedValueOnce({ rows: [{ '?column?': 1 }], rowCount: 1 });
|
||||
// 3. UPDATE pax8_companies
|
||||
queryMock.mockResolvedValueOnce({ rows: [], rowCount: 1 });
|
||||
// 4. UPDATE pax8_company_match_review
|
||||
queryMock.mockResolvedValueOnce({ rows: [], rowCount: 1 });
|
||||
|
||||
const tx = makeTx(queryMock);
|
||||
const result = await resolvePax8CompanyMatch(tx, {
|
||||
reviewId: 'review-uuid-1',
|
||||
companyId: 42,
|
||||
note: 'looks right',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, resolvedToCompanyId: 42 });
|
||||
|
||||
const c = calls(queryMock);
|
||||
expect(c).toHaveLength(4);
|
||||
expect(c[2].sql).toMatch(/UPDATE\s+pax8_companies/i);
|
||||
expect(c[2].sql).toMatch(/match_method\s*=\s*'manual'/i);
|
||||
expect(c[2].params).toEqual([ 'pax8-uuid-1', 42 ]);
|
||||
expect(c[3].sql).toMatch(/UPDATE\s+pax8_company_match_review/i);
|
||||
expect(c[3].sql).toMatch(/resolved_at\s*=\s*NOW\(\)/i);
|
||||
expect(c[3].params).toEqual(['review-uuid-1', 'user-1', 42, 'looks right']);
|
||||
});
|
||||
|
||||
it('returns not_found and issues no updates when the review row is missing', async () => {
|
||||
const queryMock = vi.fn();
|
||||
queryMock.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const tx = makeTx(queryMock);
|
||||
const result = await resolvePax8CompanyMatch(tx, {
|
||||
reviewId: 'missing-review',
|
||||
companyId: 42,
|
||||
note: null,
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'not_found',
|
||||
message: expect.any(String),
|
||||
});
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns already_resolved and issues no updates when resolved_at is set', async () => {
|
||||
const queryMock = vi.fn();
|
||||
queryMock.mockResolvedValueOnce({
|
||||
rows: [{ pax8_company_id: 'pax8-uuid-1', resolved_at: '2026-01-01T00:00:00.000Z' }],
|
||||
rowCount: 1,
|
||||
});
|
||||
|
||||
const tx = makeTx(queryMock);
|
||||
const result = await resolvePax8CompanyMatch(tx, {
|
||||
reviewId: 'review-uuid-1',
|
||||
companyId: 42,
|
||||
note: null,
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'already_resolved',
|
||||
message: expect.any(String),
|
||||
});
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns company_not_found and issues no pax8_companies write when target does not exist/is inactive', async () => {
|
||||
const queryMock = vi.fn();
|
||||
queryMock.mockResolvedValueOnce({
|
||||
rows: [{ pax8_company_id: 'pax8-uuid-1', resolved_at: null }],
|
||||
rowCount: 1,
|
||||
});
|
||||
queryMock.mockResolvedValueOnce({ rows: [], rowCount: 0 });
|
||||
|
||||
const tx = makeTx(queryMock);
|
||||
const result = await resolvePax8CompanyMatch(tx, {
|
||||
reviewId: 'review-uuid-1',
|
||||
companyId: 999,
|
||||
note: null,
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: 'company_not_found',
|
||||
message: expect.any(String),
|
||||
});
|
||||
expect(queryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resolves successfully with a companyId NOT in candidate_company_ids (manual-search / zero-candidate case)', async () => {
|
||||
const queryMock = vi.fn();
|
||||
queryMock.mockResolvedValueOnce({
|
||||
rows: [{ pax8_company_id: 'pax8-uuid-1', resolved_at: null }],
|
||||
rowCount: 1,
|
||||
});
|
||||
queryMock.mockResolvedValueOnce({ rows: [{ '?column?': 1 }], rowCount: 1 });
|
||||
queryMock.mockResolvedValueOnce({ rows: [], rowCount: 1 });
|
||||
queryMock.mockResolvedValueOnce({ rows: [], rowCount: 1 });
|
||||
|
||||
const tx = makeTx(queryMock);
|
||||
// companyId 777 was never a member of candidate_company_ids for this
|
||||
// review — resolver must not check membership at all.
|
||||
const result = await resolvePax8CompanyMatch(tx, {
|
||||
reviewId: 'review-uuid-1',
|
||||
companyId: 777,
|
||||
note: null,
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, resolvedToCompanyId: 777 });
|
||||
});
|
||||
});
|
||||
86
lib/services/pax8-company-match-resolver.ts
Normal file
86
lib/services/pax8-company-match-resolver.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* 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-list 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