diff --git a/.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md b/.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md new file mode 100644 index 0000000..91e677a --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md @@ -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. diff --git a/app/api/pax8/company-matches/[id]/resolve/route.ts b/app/api/pax8/company-matches/[id]/resolve/route.ts new file mode 100644 index 0000000..9a342ed --- /dev/null +++ b/app/api/pax8/company-matches/[id]/resolve/route.ts @@ -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 = { + 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 } + ); + } +} 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 } + ); + } +} diff --git a/lib/services/pax8-company-match-resolver.test.ts b/lib/services/pax8-company-match-resolver.test.ts new file mode 100644 index 0000000..47fd07b --- /dev/null +++ b/lib/services/pax8-company-match-resolver.test.ts @@ -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 Promise<{ rows: unknown[]; rowCount: number }>>>; + +function makeTx(queryMock: QueryMock) { + return { + query: (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 }); + }); +}); diff --git a/lib/services/pax8-company-match-resolver.ts b/lib/services/pax8-company-match-resolver.ts new file mode 100644 index 0000000..934a1a9 --- /dev/null +++ b/lib/services/pax8-company-match-resolver.ts @@ -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: (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 }; +}