docs(14): research phase domain
This commit is contained in:
parent
ccc34110c8
commit
64b30a623b
1 changed files with 485 additions and 0 deletions
485
.planning/phases/14-pax8-ui-surface/14-RESEARCH.md
Normal file
485
.planning/phases/14-pax8-ui-surface/14-RESEARCH.md
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
# Phase 14: /pax8 UI Surface - Research
|
||||
|
||||
**Researched:** 2026-07-11
|
||||
**Domain:** Next.js App Router page + API routes over existing Postgres schema (no new external integration, no new library) — read-heavy list/detail UI plus a small admin-gated resolve mutation.
|
||||
**Confidence:** HIGH (all claims below verified directly against the live codebase and a live Postgres query against this project's actual `pax8_*` tables — no external library research was needed for this phase)
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 14 is a pure internal-consistency phase: every table, service, and UI primitive it needs already exists in this repo. There is no new library to evaluate and no external API to integrate — the work is (1) two or three new `app/api/pax8/*` route handlers doing parameterized `postgresClient` queries, (2) a new `app/pax8/page.tsx` composed from `DataTable` + `DetailModal` + shadcn `Tabs`, and (3) one new nav entry. `device-link-conflicts` (page + its two API routes) is a near-exact structural precedent for the review/resolve half of this phase and should be mirrored closely.
|
||||
|
||||
The one real risk in this phase is **not** schema or auth — it's `DetailModal.tsx`. Its "Formatted" tab is not a generic renderer: it hardcodes two field-group tables (`TICKET_GROUPS`, `COMPANY_GROUPS`) selected by sniffing specific keys in the data object (`'ticket_number' in data`, `'company_name' in data`), and every field type it knows how to render is a single scalar (date, bool, badge, phone, url, id). It has **no mechanism to render an array of subscription/cost-breakdown rows** — the exact content D-02/D-03 need front and center. CONTEXT.md's D-02 says "used as-is," but as verified below, rendering the per-company cost breakdown will require an additive extension to `DetailModal.tsx`, not just passing it data. This is flagged as Pitfall 1 and should be budgeted as a task, not discovered mid-implementation.
|
||||
|
||||
Live data was queried directly against the project's Postgres container to ground every join and count below (118 `pax8_companies`, 80 auto-matched, 38 open review rows [16 no-candidate / 22 ambiguous], 445 `pax8_subscriptions`, ~28.8K `pax8_order_items` with a company set). That data surfaced a second load-bearing pitfall: PAX8's "New Commerce Experience" subscriptions bill on **per-subscription anniversary cycles**, not a shared calendar month — so "the latest order_items period" is not one date, it's one date *per subscription*. Any cost-breakdown query that takes a single global `MAX(start_period)` will silently drop every subscription whose anniversary isn't the most recent one. See Pitfall 2.
|
||||
|
||||
**Primary recommendation:** Build `/pax8` as a client page with two `Tabs` panels ("Companies", "Needs Review") per D-04; back the Companies tab with `DataTable` querying `pax8_companies LEFT JOIN companies ON companies.id = pax8_companies.autotask_company_id`; back the drill-down with an extended `DetailModal` (new field-group branch + a new array-rendering path) fed by a per-company query that joins `pax8_subscriptions` (current state: qty/billing-term/status) to each subscription's *own* latest `pax8_order_items` row (windowed per `subscription_id`, not a single global cutoff) for the actual billed amount; back the Needs Review tab with a list+resolve pair that mirrors `device-link-conflicts` exactly, except the resolve transaction must write to **both** `pax8_companies` (autotask_company_id/match_confidence/match_method='manual'/matched_at) **and** `pax8_company_match_review` (resolved_at/resolved_by_user_id/resolved_to_company_id/resolution_note) — confirmed necessary by reading `pax8-company-matcher.ts`'s own re-scoring-eligibility guard (see Architecture Patterns).
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Company list + subscription summary | API / Backend (`app/api/pax8/companies`) | Frontend Server (page shell) | Aggregation/joins belong server-side; page just renders `DataTable` rows from the API response |
|
||||
| Per-company cost breakdown | API / Backend (`app/api/pax8/companies/[id]/cost-breakdown` or embedded in company row detail fetch) | Browser (DetailModal render) | The windowed "latest period per subscription" join (Pitfall 2) is non-trivial SQL — must not be reimplemented client-side |
|
||||
| Needs Review queue (list) | API / Backend (`app/api/pax8/company-matches`) | — | Mirrors `device-link-conflicts` route exactly: parameterized query, admin-gated |
|
||||
| Resolve action (mutation) | API / Backend (`app/api/pax8/company-matches/[id]/resolve`) | Database (transaction) | Must be a single transaction touching two tables (`pax8_companies` + `pax8_company_match_review`) — see Architecture Patterns |
|
||||
| Manual company search fallback (D-05) | Browser (client-side filter) | API / Backend (`/api/data/companies-list`, already exists) | Only ~242 active companies — fetch once, filter client-side; no new search endpoint needed |
|
||||
| Nav entry | Browser (client component) | — | `components/navigation/app-navigation.tsx`'s `navigationItems` array; both desktop dropdown-menu and mobile Sheet consume the same array, so one edit covers both |
|
||||
| View/resolve permission split | API / Backend (route-level guard) | — | `requireAuth()` (view) vs `requirePermission('admin','access')` (resolve) per D-07/D-08 — enforced in the route handler, not middleware |
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- No ORM — use `postgresClient.query()` with parameterized SQL, manual snake_case → camelCase transform in the route handler.
|
||||
- No server actions — API routes called via `fetch()` from a `'use client'` page.
|
||||
- No SWR/react-query/new state libraries — `useState`/`useEffect`/`fetch`, matching every other Pulse page.
|
||||
- No Zod in API routes unless it "matters" — the resolve mutation is a good candidate for a small Zod schema (mirrors `device-link-conflicts/[id]/resolve/route.ts`'s existing `ResolveBody = z.object({...})` precedent exactly); the read-only list routes don't need it.
|
||||
- Files kebab-case, components PascalCase-exported from kebab-case files, icons from `lucide-react`, toasts from `sonner`.
|
||||
- New SQL is a new numbered migration, `IF NOT EXISTS` guarded — **this phase should need zero new migrations**; all tables/columns it reads already exist (091, 092, 093, 094, 095, 096). Confirm during planning that no new column is actually required before adding one.
|
||||
- Every new API route must have an explicit `requireAuth()`/`requirePermission()` call (explicit CONTEXT.md directive, echoing 13-REVIEW.md CR-02 — the adjacent `/api/pax8/sync` route currently has **no** auth check at all; do not copy that route as a pattern).
|
||||
- `/pax8` is not in `middleware.ts`'s `publicRoutes` list (confirmed) — it requires a session cookie by default; no middleware change needed, but do **not** add it to `publicRoutes`.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
- **D-01:** Main company list uses `components/admin/DataTable.tsx`, not a bespoke card grid.
|
||||
- **D-02:** Per-company cost breakdown is a drill-down via `components/admin/DetailModal.tsx` (formatted/raw tab pattern), not inline in the table row. Formatted tab: subscriptions (product name, quantity, billing term) + cost summary. Raw tab: underlying JSON.
|
||||
- **D-03:** Cost breakdown is grouped **by subscription/product**, not a single total. Cost data must be joined from `pax8_order_items.pax8_company_id` (and `start_period`/`end_period`), never from `pax8_orders` (its `pax8_company_id` is always NULL).
|
||||
- **D-04:** The flagged/ambiguous review queue is a **section/tab within `/pax8`** (e.g., "Companies" / "Needs Review" tabs), not a separate top-level route. Reuse `device-link-conflicts`'s card-list-with-resolve-action UI pattern, embedded as a tab.
|
||||
- **D-05:** Resolution UI offers **both**: pick from top-3 stored candidates (`pax8_company_match_review.candidate_company_ids` + `match_confidences`) as primary path, plus a manual company search/picker as fallback (required for the zero-candidate case, useful even when candidates exist but none are correct).
|
||||
- **D-06:** Resolving writes `resolved_at`, `resolved_by_user_id`, `resolved_to_company_id`, optional `resolution_note` — mirrors `device_link_review`'s resolve shape.
|
||||
- **D-07:** Viewing `/pax8` requires only `requireAuth()` — any authenticated user.
|
||||
- **D-08:** The resolve action requires `requirePermission('admin', 'access')` — matching `/api/admin/device-link-conflicts/[id]/resolve`'s pattern exactly. Every new route this phase adds must have an explicit auth call.
|
||||
- **D-09:** A review row with an empty `candidate_company_ids` array shows the same review-section UI with an explicit empty state ("No suggested matches — search manually"), handled by the D-05 manual-search fallback, no special-cased flow.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- Exact tab/section labels, table column set, and DetailModal field layout.
|
||||
- Whether "Needs Review" tab shows a count badge, and sort/filter options on the main company list.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
|
||||
None — discussion stayed within Phase 14's scope. PAX8 write access, seat-adjustment actions, and any general natural-language assistant over this data are already explicitly out of scope per PROJECT.md, not deferred from this discussion.
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| PAX8-12 | An admin can view flagged/ambiguous company matches and manually resolve them to the correct Autotask company | Architecture Patterns → "Needs Review tab + resolve transaction"; Code Examples → resolve route; verified schema for `pax8_company_match_review` and `pax8_companies` match columns |
|
||||
| PAX8-13 | A new `/pax8` page lists PAX8 companies with their subscriptions and a cost breakdown | Architecture Patterns → company list query; Pitfall 1 (DetailModal extension) and Pitfall 2 (per-subscription latest period) directly govern correct implementation |
|
||||
| PAX8-14 | The `/pax8` page surfaces flagged/ambiguous company matches (PAX8-11) for manual resolution | Same as PAX8-12 — this is the UI-surfacing half of the same review queue |
|
||||
</phase_requirements>
|
||||
|
||||
## Standard Stack
|
||||
|
||||
No new packages. Everything needed is already in `package.json` and already imported by the precedents cited below.
|
||||
|
||||
### Core (reused, not new)
|
||||
| Library | Version (installed) | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `@tanstack/react-table` (via `DataTable.tsx`) | 8.21.3 | Company list table | Existing wrapper, used by every other tabular list in Pulse |
|
||||
| `pg` (via `postgresClient`) | 8.11.0 | All Postgres access | No-ORM convention |
|
||||
| shadcn `Tabs`/`Dialog`/`Card`/`Badge`/`Select` | n/a (local components) | Page/section/modal chrome | Already vendored in `components/ui/` |
|
||||
| `sonner` | 2.0.7 | Resolve success/error toasts | Matches `device-link-conflicts` page exactly |
|
||||
| `lucide-react` | 0.562.0 | Icons (nav entry, review-tab alert icon, etc.) | Project convention |
|
||||
| `zod` | 4.3.5 | Resolve-body validation | Matches `device-link-conflicts/[id]/resolve` exactly |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `DataTable` for the company list | Bespoke card grid | Rejected by CONTEXT.md D-01 — no sorting/pagination/search for free, inconsistent with rest of app |
|
||||
| Extending `DetailModal` | A bespoke `Dialog` built from scratch, matching the visual style only | Extending is more consistent with the rest of the codebase (one shared component, one place to fix bugs) but requires touching a file used by every ticket/company detail view in the app — must be additive-only (new branch, no changes to existing `TICKET_GROUPS`/`COMPANY_GROUPS` behavior). See Pitfall 1 for the concrete tradeoff analysis. |
|
||||
| Client-side company search combobox (shadcn `Command`) | Server-side `ILIKE` search endpoint | Only ~242 active companies (verified: `companies-list` route has no pagination) — fetching once and filtering client-side is simpler and avoids a new endpoint; a `Command`+`Popover` combobox (shadcn primitives already vendored) is the natural UI for this |
|
||||
|
||||
**Installation:** none required.
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
Not applicable — this phase introduces zero new npm packages. All libraries used are already installed and in active use elsewhere in the repo (confirmed via `package.json` and direct imports in `DataTable.tsx`, `DetailModal.tsx`, `device-link-conflicts/page.tsx`).
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Browser (manager, any authenticated user)
|
||||
│
|
||||
│ GET /pax8 (page load, Next.js page component)
|
||||
▼
|
||||
app/pax8/page.tsx ('use client')
|
||||
│
|
||||
├─ Tab "Companies" ──────────────────────────────────────────┐
|
||||
│ useEffect → fetch('/api/pax8/companies?...') │
|
||||
│ └─ requireAuth() only (D-07) │
|
||||
│ SELECT pax8_companies LEFT JOIN companies │
|
||||
│ ON companies.id = pax8_companies.autotask_company_id
|
||||
│ → DataTable renders rows │
|
||||
│ → onRowClick → fetch per-company cost detail │
|
||||
│ GET /api/pax8/companies/[id] (or embed in row) │
|
||||
│ pax8_subscriptions JOIN pax8_products │
|
||||
│ + LATERAL latest pax8_order_items per │
|
||||
│ subscription_id (Pitfall 2) │
|
||||
│ → DetailModal (EXTENDED, see Pitfall 1) renders │
|
||||
│ Formatted tab: subscriptions table + cost sum │
|
||||
│ Raw tab: raw_payload JSON │
|
||||
│ │
|
||||
├─ Tab "Needs Review" ────────────────────────────────────────┘
|
||||
│ useEffect → fetch('/api/pax8/company-matches')
|
||||
│ └─ requireAuth() only to VIEW (D-07) — resolve is gated
|
||||
│ SELECT pax8_company_match_review
|
||||
│ WHERE resolved_at IS NULL
|
||||
│ JOIN pax8_companies (name)
|
||||
│ + bulk-fetch candidate `companies` names
|
||||
│ → card list, top-3 candidates + manual search (D-05)
|
||||
│
|
||||
│ User picks a candidate OR searches manually → clicks "Link"
|
||||
▼
|
||||
POST /api/pax8/company-matches/[id]/resolve
|
||||
└─ requirePermission('admin','access') (D-08)
|
||||
BEGIN
|
||||
UPDATE pax8_companies
|
||||
SET autotask_company_id=$X, match_method='manual',
|
||||
matched_at=NOW(), match_confidence=NULL
|
||||
WHERE id = $pax8CompanyId
|
||||
UPDATE pax8_company_match_review
|
||||
SET resolved_at=NOW(), resolved_by_user_id=$user,
|
||||
resolved_to_company_id=$X, resolution_note=$note
|
||||
WHERE id = $reviewId AND resolved_at IS NULL
|
||||
COMMIT
|
||||
→ both writes make the resolution visible in the Companies tab
|
||||
(via pax8_companies.autotask_company_id) AND permanent against
|
||||
future re-matching (via pax8-company-matcher.ts's eligibility
|
||||
guard, which excludes match_method='manual' AND rows with a
|
||||
resolved review — see below)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
app/
|
||||
├── pax8/
|
||||
│ └── page.tsx # NEW — Companies / Needs Review tabs
|
||||
├── api/
|
||||
│ └── pax8/
|
||||
│ ├── sync/route.ts # existing — do not modify (Phase 13 scope)
|
||||
│ ├── companies/
|
||||
│ │ ├── route.ts # NEW — GET list (paginated, sortable, searchable)
|
||||
│ │ └── [id]/route.ts # NEW — GET one company's subscriptions + cost breakdown
|
||||
│ └── company-matches/
|
||||
│ ├── route.ts # NEW — GET unresolved review queue
|
||||
│ └── [id]/resolve/route.ts # NEW — POST resolve (admin-gated)
|
||||
components/
|
||||
├── admin/
|
||||
│ ├── DataTable.tsx # reused as-is
|
||||
│ └── DetailModal.tsx # EXTENDED (additive) — see Pitfall 1
|
||||
├── navigation/
|
||||
│ └── app-navigation.tsx # EDITED — add one `navigationItems` entry
|
||||
```
|
||||
|
||||
### Pattern 1: Company list query (D-01, D-03's join note)
|
||||
|
||||
`pax8_companies.autotask_company_id` (set by both the auto-matcher and — after this phase — the manual resolve action) is the **live source of truth** for "which Autotask company is this PAX8 company linked to." `pax8_company_match_review.resolved_to_company_id` is an audit trail of the resolution *event*, not a live join target — confirmed by reading `pax8-company-matcher.ts`'s re-scoring query, which checks `pax8_companies.match_method` and a `NOT EXISTS` against resolved review rows, never reads `resolved_to_company_id` to decide what to display. `[VERIFIED: codebase — lib/services/pax8-company-matcher.ts lines 216-231]`
|
||||
|
||||
```sql
|
||||
-- Source: adapted from scripts/verify-pax8-orders-matching.ts's own join pattern
|
||||
-- (lines 76-85), which already joins pax8_companies -> companies via
|
||||
-- autotask_company_id in this exact shape and is live-verified against
|
||||
-- real data (118 companies, 80 matched).
|
||||
SELECT
|
||||
pc.id, pc.name, pc.status, pc.city, pc.state_or_province, pc.country,
|
||||
pc.autotask_company_id, pc.match_confidence, pc.match_method,
|
||||
c.company_name AS matched_company_name,
|
||||
(SELECT count(*) FROM pax8_subscriptions s
|
||||
WHERE s.pax8_company_id = pc.id AND s.is_deleted = false
|
||||
AND s.status = 'Active') AS active_subscription_count
|
||||
FROM pax8_companies pc
|
||||
LEFT JOIN companies c ON c.id = pc.autotask_company_id
|
||||
WHERE pc.is_deleted = false
|
||||
ORDER BY pc.name
|
||||
LIMIT $1 OFFSET $2;
|
||||
```
|
||||
|
||||
### Pattern 2: Per-company cost breakdown — subscriptions + latest-period actuals (D-02, D-03)
|
||||
|
||||
**Two data sources are needed, not one.** `pax8_subscriptions` gives the stable "what exists" view (quantity, billing term, status — matches D-02's formatted-tab spec directly). `pax8_order_items` gives the actual billed amount, which differs from `subscriptions.price × quantity` due to proration/discounts (live-verified: one sampled line had `unit_price=155.65, quantity=1` but `line_total=109.00` — a ~30% discount not visible on the subscription record itself).
|
||||
|
||||
```sql
|
||||
-- Step 1: current subscriptions (the "what am I paying for" list)
|
||||
SELECT s.id AS subscription_id, s.product_id, p.name AS product_name, p.sku,
|
||||
s.quantity, s.billing_term, s.status, s.price, s.partner_cost, s.currency
|
||||
FROM pax8_subscriptions s
|
||||
LEFT JOIN pax8_products p ON p.id = s.product_id
|
||||
WHERE s.pax8_company_id = $1 AND s.is_deleted = false
|
||||
ORDER BY p.name NULLS LAST;
|
||||
|
||||
-- Step 2: latest actually-billed line per subscription (see Pitfall 2 for
|
||||
-- why this MUST be windowed per subscription_id, not a single MAX(start_period)
|
||||
-- across the whole company).
|
||||
SELECT DISTINCT ON (subscription_id)
|
||||
subscription_id, product_id, sku, description, item_type,
|
||||
start_period, end_period, quantity, unit_price, line_total,
|
||||
partner_cost, partner_cost_total
|
||||
FROM pax8_order_items
|
||||
WHERE pax8_company_id = $1 AND is_deleted = false
|
||||
AND subscription_id IS NOT NULL
|
||||
ORDER BY subscription_id, start_period DESC;
|
||||
```
|
||||
Join Step 1 and Step 2 in application code (or a single query with a `LATERAL` join) keyed on `subscription_id`; fall back to `subscriptions.price * quantity` when no order-item match exists (verified: 3 of 436 distinct `subscription_id`s referenced by order items have no matching row in `pax8_subscriptions` — likely tombstoned/cancelled subscriptions still present in historical invoice data; these should still appear in the cost breakdown using the order-item data alone, with no `billing_term`/`status` from step 1).
|
||||
|
||||
`[VERIFIED: live DB query]` — sample counts and the discount example above were pulled directly from this project's Postgres container on 2026-07-11 (118 companies / 445 subscriptions / 28,826 order-items-with-company / 20,174 `item_type='subscription'` rows, all with `subscription_id` populated).
|
||||
|
||||
### Pattern 3: Needs Review list (D-04, D-05)
|
||||
|
||||
Directly mirrors `app/api/admin/device-link-conflicts/route.ts`'s shape — same bulk-fetch-candidates-in-one-query optimization applies (candidate ids are `bigint[]` referencing `companies`, exactly like `device_link_review.candidate_ci_ids` references `configuration_items`):
|
||||
|
||||
```typescript
|
||||
// Source: pattern lifted from app/api/admin/device-link-conflicts/route.ts
|
||||
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 (same optimization
|
||||
// device-link-conflicts uses for configuration_items):
|
||||
const allCandidateIds = new Set<number>();
|
||||
for (const r of reviews.rows) for (const id of r.candidate_company_ids ?? []) allCandidateIds.add(Number(id));
|
||||
const companyNames = await postgresClient.query(
|
||||
`SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`,
|
||||
[Array.from(allCandidateIds)]
|
||||
);
|
||||
```
|
||||
|
||||
Note the type difference from `device-link-conflicts`: `candidate_company_ids` is `BIGINT[]` (Autotask company IDs, integers) not `UUID[]` — no `::text[]` cast needed on the array itself, but each row's `id` in the `companies` lookup is `bigint` matching Postgres's native JS number handling (values fit safely in JS number range for this dataset — confirmed IDs are small integers, not snowflake-scale).
|
||||
|
||||
### Pattern 4: Resolve action — two-table transaction (D-06, D-08)
|
||||
|
||||
This is the one place this phase's implementation must **diverge** from the `device-link-conflicts` resolve route it's told to mirror. `device-link-conflicts/[id]/resolve` only touches one other table (`device_external_ids`) because that table has no separate "confidence tier" the matcher checks before re-scoring. PAX8's matcher does: `pax8-company-matcher.ts`'s `matchPax8Companies()` selects its eligible-for-rescoring set with:
|
||||
|
||||
```sql
|
||||
-- Source: lib/services/pax8-company-matcher.ts lines 216-231 (verbatim)
|
||||
WHERE c.is_deleted = false
|
||||
AND c.match_method IS DISTINCT FROM 'manual'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pax8_company_match_review r2
|
||||
WHERE r2.pax8_company_id = c.id AND r2.resolved_at IS NOT NULL
|
||||
)
|
||||
```
|
||||
|
||||
For a manual resolution to actually "stick" against future syncs (SC#4 / D-06's "persist and be respected"), the resolve transaction must set **both** signals this query checks — `pax8_companies.match_method = 'manual'` (not just marking the review row resolved). Confirmed by direct inspection of the matcher's own SQL, not inferred:
|
||||
|
||||
```typescript
|
||||
// app/api/pax8/company-matches/[id]/resolve/route.ts — NEW
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { session, error } = await requirePermission('admin', 'access'); // D-08
|
||||
if (error) return error;
|
||||
const { id } = await params;
|
||||
// ... validate body (companyId: number, note?: string) with zod, mirroring
|
||||
// device-link-conflicts/[id]/resolve's ResolveBody shape ...
|
||||
return postgresClient.transaction(async (tx) => {
|
||||
const reviewRes = await tx.query(
|
||||
`SELECT pax8_company_id, candidate_company_ids, resolved_at
|
||||
FROM pax8_company_match_review WHERE id = $1 FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
if (reviewRes.rowCount === 0) return NextResponse.json({ error: 'Review not found' }, { status: 404 });
|
||||
if (reviewRes.rows[0].resolved_at) return NextResponse.json({ error: 'Already resolved' }, { status: 409 });
|
||||
// NOTE: unlike device-link-conflicts, do NOT restrict companyId to the
|
||||
// candidate list — D-05 explicitly requires a manual-search fallback for
|
||||
// cases with no correct candidate, so any active Autotask company id is
|
||||
// a valid resolution target here (validate it exists + is_active, but
|
||||
// don't require candidate-list membership).
|
||||
|
||||
await tx.query(
|
||||
`UPDATE pax8_companies
|
||||
SET autotask_company_id = $2, match_confidence = NULL,
|
||||
match_method = 'manual', matched_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[reviewRes.rows[0].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`,
|
||||
[id, session?.user?.id ?? null, companyId, note ?? null]
|
||||
);
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This is the concrete, code-verified answer to CONTEXT.md's open research question ("does resolving also update `pax8_companies.autotask_company_id`?") — **yes, it must**, or the resolution will appear to work in the Needs Review tab (row disappears) but silently fail to show up in the Companies tab's `autotask_company_id` join, and worse, `matchPax8Companies()` would immediately re-flag the company on the next sync since `match_method` was never set to `'manual'`.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Restricting the resolve action's target company to `candidate_company_ids`** (like `device-link-conflicts` does for `ciId`): D-05 explicitly requires a manual-search fallback for when none of the top-3 candidates are correct, and D-09's zero-candidate case has an empty candidate array by design — a candidate-membership check would make both of those cases impossible to resolve.
|
||||
- **Reading `pax8_company_match_review.resolved_to_company_id` as the display-time join target**: it's an audit column, not a live pointer. Always join through `pax8_companies.autotask_company_id`.
|
||||
- **A single `MAX(start_period)` cutoff for "current" cost data**: see Pitfall 2 — this silently drops subscriptions on an earlier anniversary cycle.
|
||||
- **Passing raw PAX8 field names straight through to `DetailModal`'s formatted tab expecting automatic formatting**: `detectGroups()` doesn't know about PAX8 shapes at all today; without extension everything falls into the generic `{ label: 'Fields', fields: Object.keys(data) }` fallback — a flat, unstyled key/value dump with no subscriptions table.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Paginated/sortable/searchable table | Custom table + manual pagination state | `components/admin/DataTable.tsx` (D-01) | Already handles manual sort/pagination dispatch, CSV export, loading/empty states |
|
||||
| Formatted/raw detail drill-down shell | New Dialog component from scratch | `components/admin/DetailModal.tsx`, extended (D-02) | Keeps visual consistency (header style, tab chrome, copy-to-clipboard) with every other detail view in the app — see Pitfall 1 for the specific extension needed |
|
||||
| Company name matching for the manual-search fallback | New fuzzy-search endpoint | Existing `/api/data/companies-list` (already returns `{id, company_name}` for all active companies) + client-side filter | Only ~242 rows; no pagination exists on that endpoint today because it's designed to be fetched once |
|
||||
| Auth/permission checks | Custom role-check logic in the route | `requireAuth()` / `requirePermission('admin','access')` from `lib/auth-utils.ts` (D-07/D-08) | Exact signatures already used by `device-link-conflicts` routes; `admin`/`access` resource-action pair confirmed to exist in `lib/permissions.ts`'s `statement` object |
|
||||
| Nav item styling/active-state logic | New nav component | Add one object to `navigationItems` in `components/navigation/app-navigation.tsx` | Both desktop `NavigationMenu` and mobile `Sheet` (`MobileNav`) consume the same array — one edit, two surfaces |
|
||||
|
||||
**Key insight:** every "don't hand-roll" item here is an existing in-repo component, not an external package — this phase's leverage comes entirely from following precedent, not researching new tools.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: DetailModal.tsx cannot render the cost breakdown without an additive extension
|
||||
**What goes wrong:** CONTEXT.md's D-02 says "used as-is" for the drill-down. Reading the actual component (`components/admin/DetailModal.tsx`) shows `detectGroups(data)` only recognizes two shapes — `'ticket_number' in data` → `TICKET_GROUPS`, `'company_name' in data` → `COMPANY_GROUPS` — and falls back to a flat, un-grouped key/value dump for anything else. Every `FieldType` branch in `resolveLabel()` renders a single scalar (date/bool/badge/phone/url/id); none render an array. A PAX8 company object has a `name` field (not `company_name`), and even if aliased, the subscriptions/cost-breakdown array has no representation path at all.
|
||||
**Why it happens:** `DetailModal` was built ticket/company-specific and extended in place; it was never designed as a generic field-group renderer with a public extension point.
|
||||
**How to avoid:** Budget a task to extend `DetailModal.tsx` additively: (1) add an explicit optional `kind` prop (e.g. `kind?: 'ticket' | 'company' | 'pax8_company'`) so `detectGroups` no longer has to guess from ambiguous field-name sniffing — pass `kind="pax8_company"` from the new page; (2) add a `PAX8_COMPANY_GROUPS` field-group set for identity/contact/system fields (reusing existing `FieldType`s: `url` for website, `bool` for `is_deleted`); (3) add a dedicated non-`FieldGroup` section rendered unconditionally in the formatted tab when `data.subscriptions` (or similar) is an array — a compact table of product/qty/billing-term/cost rows plus a summed total, styled consistently with the existing rounded-border card sections. Do not touch `TICKET_GROUPS`/`COMPANY_GROUPS` or their existing detection branches — this must be a pure addition.
|
||||
**Warning signs:** If a plan task says "pass company + subscriptions to DetailModal" without a corresponding "extend DetailModal.tsx" task, the formatted tab will render as an unstyled field dump with no subscriptions data visible at all.
|
||||
|
||||
### Pitfall 2: NCE subscriptions bill on per-subscription anniversary cycles — no single "current period" cutoff exists
|
||||
**What goes wrong:** A naive cost-breakdown query using `WHERE pax8_company_id = $1 ORDER BY start_period DESC LIMIT N` (or `MAX(start_period)` as a single cutoff) will return only the one or two subscriptions whose billing anniversary happens to be most recent, silently omitting every other active subscription on this company.
|
||||
**Why it happens:** Live-verified: a single sampled company's most-recent order-items spanned `start_period` values of `2026-07-01`, `2026-06-28`, `2026-06-22`, `2026-06-15`, and `2026-06-08` — five different subscriptions, five different anniversary dates, in the same "current" snapshot. This is Microsoft NCE's per-license anniversary billing model, not a data quality bug.
|
||||
**How to avoid:** Window the "latest" lookup **per `subscription_id`** (`DISTINCT ON (subscription_id) ... ORDER BY subscription_id, start_period DESC`, or an equivalent `ROW_NUMBER() OVER (PARTITION BY subscription_id ...)`), never a single company-wide cutoff. See Pattern 2's SQL.
|
||||
**Warning signs:** A cost breakdown that shows far fewer line items than the company's active-subscription count in `pax8_subscriptions`.
|
||||
|
||||
### Pitfall 3: `unit_price × quantity` does not equal the actual amount billed
|
||||
**What goes wrong:** Computing a cost summary as `SUM(unit_price * quantity)` overstates real spend.
|
||||
**Why it happens:** PAX8 applies discounts/proration at the line level; `line_total` (and `amountDue` in the raw API shape) is the actual charged amount, `unit_price`/`price` is closer to list price. Live-verified example: `unit_price=155.65, quantity=1, line_total=109.00` on a real synced row.
|
||||
**How to avoid:** Use `line_total` (and `partner_cost_total` for the reseller-cost figure, if shown) directly as the per-line amount; never recompute from `unit_price * quantity`.
|
||||
**Warning signs:** Cost summary totals that look suspiciously round or higher than what the company's actual PAX8 invoice shows.
|
||||
|
||||
### Pitfall 4: Some referenced products/subscriptions have blank names or no matching row
|
||||
**What goes wrong:** A cost-breakdown row renders with an empty product name, or a `LEFT JOIN` to `pax8_subscriptions` returns no billing-term/status because the subscription was later tombstoned.
|
||||
**Why it happens:** Live-verified: 3 of 436 distinct `subscription_id` values referenced by `pax8_order_items` have no matching `pax8_subscriptions` row (likely historical/cancelled subscriptions whose parent row was tombstoned or never synced). Separately, some order-item `description`/`sku` combinations exist with entirely blank product names in the raw payload (e.g. ad-hoc "Rate Plan Adjustment" / "Change of Channel" line items that aren't real product SKUs).
|
||||
**How to avoid:** Build a fallback label chain in the query or render layer: `COALESCE(product.name, order_item.description, order_item.sku, 'Unknown item')`. Don't `INNER JOIN` `pax8_products`/`pax8_subscriptions` when building the cost breakdown — use `LEFT JOIN` throughout, exactly as the existing sync service and matcher already do everywhere else in this schema.
|
||||
**Warning signs:** Blank cells in the subscriptions table, or rows silently disappearing from the cost breakdown vs. what a raw count of `pax8_order_items` for that company would suggest.
|
||||
|
||||
### Pitfall 5: `pax8_order_items` is large (28.8K rows with a company set, live-verified) — always filter by `pax8_company_id` and `is_deleted = false`, never scan the whole table
|
||||
**What goes wrong:** An unfiltered or company-unscoped query against `pax8_order_items` for the cost-breakdown drill-down will scan far more data than needed (one company's history can be 5,000+ rows — the busiest sampled company had 5,856).
|
||||
**Why it happens:** The table accumulates full historical invoice line items since first sync (some as far back as 2019), not just current-state data.
|
||||
**How to avoid:** Every cost-breakdown query must be scoped by `pax8_company_id = $1` (indexed: `idx_pax8_order_items_company`) and `is_deleted = false`; never load the table for the main company-list route, only for the per-company drill-down.
|
||||
**Warning signs:** Slow drill-down modal opens; `postgresClient`'s built-in slow-query warning (`> 1000ms`) firing in logs.
|
||||
|
||||
## Code Examples
|
||||
|
||||
See Architecture Patterns 1-4 above for verified, live-data-grounded query and route-handler examples (company list join, per-company cost breakdown windowed join, review-queue bulk-fetch, and the two-table resolve transaction). All are adapted directly from existing in-repo code (`device-link-conflicts` routes, `pax8-company-matcher.ts`, `verify-pax8-orders-matching.ts`) rather than external sources.
|
||||
|
||||
## State of the Art
|
||||
|
||||
Not applicable in the usual sense — nothing in this phase is a library/framework whose "state of the art" shifts over time. The one internal precedent worth noting:
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|---------------|--------|
|
||||
| `pax8_orders.pax8_company_id` assumed usable for per-company cost joins (Phase 10's original schema comment) | `pax8_order_items.pax8_company_id` is the only usable per-company cost join key | Migration 093 (Phase 12), confirmed still true live | Any query joining through `pax8_orders` for company-scoped cost data will return nothing — this is documented in the migration's own header comment and re-confirmed by this research's live query |
|
||||
|
||||
**Deprecated/outdated:** none — this schema is 8 migrations old (091→096) as of this research, all still current.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | The exact API route paths (`app/api/pax8/companies`, `app/api/pax8/company-matches`, `.../[id]/resolve`) are a naming recommendation, not verified against any existing convention document — CONTEXT.md itself calls these "left to the planner." | Recommended Project Structure | Low — purely a naming choice, doesn't affect correctness; planner can rename freely without invalidating any other finding in this document |
|
||||
| A2 | The recommended `DetailModal` extension approach (add a `kind` prop + a dedicated array-rendering section) is this researcher's design recommendation, not something confirmed by reading a similar precedent elsewhere in the codebase — no other phase has extended `DetailModal.tsx` for a non-ticket/non-company shape. | Pitfall 1 | Medium — if the planner instead builds a fully separate bespoke modal component, that's also valid; the risk is only in underestimating the DetailModal-reuse work if this extension path is chosen |
|
||||
| A3 | `match_confidence` on `pax8_companies` should be set to `NULL` (not e.g. `1.000`) for a manually-resolved match, since there's no numeric trigram score for a human decision. | Pattern 4 (resolve transaction) | Low — cosmetic; if the planner prefers `1.000` for display purposes ("100% confidence"), that's a one-line change with no schema impact (`match_confidence` is nullable) |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Exact column set / labels for the Companies-tab table and the cost-breakdown formatted view**
|
||||
- What we know: DataTable/DetailModal mechanics, available columns on every relevant table (verified via `\d` against the live schema), and D-02's minimum spec (product name, quantity, billing term, cost summary).
|
||||
- What's unclear: whether the main list should show a subscription *count* column (query pattern shown in Pattern 1), a matched/unmatched status badge, or both — CONTEXT.md explicitly leaves this to the planner ("Claude's Discretion").
|
||||
- Recommendation: include at minimum `name`, matched-Autotask-company (or "Unmatched"/badge), active subscription count, and city/country as a `DataTable` sort target — matches the level of detail `app/admin/data-browser/companies/page.tsx` uses for its own company list.
|
||||
|
||||
2. **Whether the cost-breakdown summary should also surface `partner_cost_total` (reseller cost) alongside `line_total` (customer-facing cost)**
|
||||
- What we know: both columns exist and are populated on `pax8_order_items`; in the sampled data, most rows had `partner_cost_total ≈ line_total` (no visible margin), but D-03/PAX8-13 only asks for "a cost breakdown," not a margin analysis.
|
||||
- What's unclear: whether managers need to see partner cost at all, given margin/reselling isn't in this milestone's stated scope (`REQUIREMENTS.md` frames this purely as "see PAX8 subscription costs... without manually cross-referencing PAX8's own portal").
|
||||
- Recommendation: show only the customer-facing `line_total`/`price` in the primary breakdown; if there's appetite for a "raw" partner-cost column it belongs in the Raw tab (which already dumps `raw_payload`), not the Formatted tab.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| PostgreSQL | All queries in this phase | ✓ | 16 (pulse-postgres container, healthy) | — |
|
||||
| Docker Compose stack (pulse-app, pulse-postgres, pulse-redis) | Local verification during planning/implementation | ✓ | running (pulse-app up ~1h, pulse-postgres up ~2h at research time) | — |
|
||||
| PAX8 API credentials | N/A — this phase reads only already-synced Postgres data, never calls PAX8 directly | not needed | — | — |
|
||||
|
||||
No missing dependencies — this phase has no new external dependency at all.
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | vitest 4.1.5 |
|
||||
| Config file | `vitest.config.ts` — `test.include: ['lib/**/*.test.ts']` (confirmed: page components and `app/api/**` route handlers are **structurally excluded** from `npm test` today) |
|
||||
| Quick run command | `npx tsc --noEmit --pretty` (the only automated gate that actually covers `app/pax8/**` and `app/api/pax8/**`) |
|
||||
| Full suite command | `npm test` (covers `lib/services/analyzer/**`, `lib/services/rmm/**`, `lib/services/b2/**` only — will not exercise any file this phase adds unless logic is extracted into `lib/services/`) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| PAX8-13 | Company list renders with subscription/cost data | manual (page has no test coverage under current vitest glob) | `npx tsc --noEmit --pretty` (type-check only) | ❌ Wave 0 (no test file planned; matches `device-link-conflicts` precedent, which also has no test file) |
|
||||
| PAX8-12 / PAX8-14 | Resolve action writes both `pax8_companies` and `pax8_company_match_review` correctly and is admin-gated | unit (if cost-breakdown/resolve SQL is extracted into a `lib/services/pax8-*` helper) OR manual otherwise | `npx vitest run lib/services/pax8-company-matches.test.ts` (if extracted) | ❌ Wave 0 — recommend extracting the resolve transaction's write logic into a small `lib/services/pax8-company-match-resolver.ts` function specifically so it becomes unit-testable under the existing `lib/**/*.test.ts` glob, following `pax8-company-matcher.test.ts`'s existing mock-postgresClient convention |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `npx tsc --noEmit --pretty`
|
||||
- **Per wave merge:** `npm test` (won't cover new files unless logic is extracted per above, but must still pass — regressions elsewhere would still be caught)
|
||||
- **Phase gate:** Full suite green + manual click-through of both tabs (list, drill-down, resolve-with-candidate, resolve-via-manual-search, empty-state for zero-candidate row) before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] Decide whether to extract resolve/cost-breakdown SQL into `lib/services/` for unit-test coverage, or accept manual-only verification (matches `device-link-conflicts`'s existing precedent — it also has zero automated tests)
|
||||
- [ ] If extracted: `lib/services/pax8-company-match-resolver.test.ts` — mock `postgresClient.query`, following `pax8-company-matcher.test.ts`'s existing convention (`vi.mock('@/lib/services/postgres-client', ...)`)
|
||||
- [ ] No new test framework/config needed — vitest is already configured project-wide
|
||||
|
||||
*(If the planner accepts manual-only verification for this phase, matching `device-link-conflicts`'s existing precedent: state that explicitly in the plan rather than silently having zero coverage.)*
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes | Better Auth session cookie, checked via `requireAuth()`/`requirePermission()` — no new auth surface |
|
||||
| V3 Session Management | no | Unchanged — Better Auth handles this, out of scope for this phase |
|
||||
| V4 Access Control | yes | `requireAuth()` for view routes (D-07), `requirePermission('admin','access')` for the resolve mutation (D-08) — verified exact signatures in `lib/auth-utils.ts` |
|
||||
| V5 Input Validation | yes | Zod schema on the resolve POST body (companyId numeric, optional note capped in length), mirroring `device-link-conflicts/[id]/resolve`'s `ResolveBody` exactly |
|
||||
| V6 Cryptography | no | No new secrets/crypto in this phase |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| SQL injection via company name / search query params | Tampering | Parameterized `postgresClient.query()` calls throughout (never string-interpolate user input) — matches every existing route in the codebase, including `pax8-company-matcher.ts`'s own `similarity($1, company_name)` discipline |
|
||||
| Privilege escalation via missing auth check on a new route (the exact gap CR-02 flagged on `/api/pax8/sync`) | Elevation of Privilege | Every new route in this phase must open with an explicit `requireAuth()` or `requirePermission()` call — no route may rely on middleware alone (middleware only checks session-cookie *presence*, never role) |
|
||||
| Resolve action linking a PAX8 company to an arbitrary/non-existent or inactive Autotask company id | Tampering | Validate the submitted `companyId` exists and (recommended) `is_active = true` in `companies` before the UPDATE — device-link-conflicts validates candidate-list membership instead, but D-05 requires accepting IDs outside the candidate list, so existence/active-state validation is the correct substitute check here |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence — direct codebase/DB verification, no external research needed)
|
||||
- `migrations/091_pax8_tables.sql`, `092_pax8_subscription_costs.sql`, `093_pax8_orders_company_matching.sql`, `094_pax8_order_items_quantity_numeric.sql`, `095_pax8_order_items_partner_cost_numeric.sql`, `096_pax8_daily_schedule.sql` — full schema history read directly
|
||||
- Live `docker exec pulse-postgres psql` queries against the actual running database (schema `\d` dumps + row counts + sample cost/period data) — run 2026-07-11
|
||||
- `lib/services/pax8-company-matcher.ts`, `lib/services/pax8-sync-service.ts`, `lib/types/pax8.ts` — read in full
|
||||
- `app/admin/device-link-conflicts/page.tsx`, `app/api/admin/device-link-conflicts/route.ts`, `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` — read in full, the direct structural precedent
|
||||
- `components/admin/DataTable.tsx`, `components/admin/DetailModal.tsx`, `lib/auth-utils.ts`, `lib/permissions.ts`, `middleware.ts`, `components/navigation/app-navigation.tsx`, `components/navigation/mobile-nav.tsx` — read in full
|
||||
- `app/admin/data-browser/companies/page.tsx` — DataTable+DetailModal composition precedent
|
||||
- `app/api/data/companies-list/route.ts` — manual-search fallback data source
|
||||
- `.planning/phases/12-orders-invoices-company-matching/12-PATTERNS.md` — corroborates the resolve-transaction two-table requirement independently
|
||||
|
||||
### Secondary / Tertiary
|
||||
None — this phase required no external library, framework, or web-search research; every claim traces to a file read or a live query in this session.
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — zero new packages, every component already in active use
|
||||
- Architecture: HIGH — every query pattern verified against live data; the resolve-transaction design is derived directly from reading the matcher's own eligibility SQL, not inferred
|
||||
- Pitfalls: HIGH — all five pitfalls are backed by a live query result from this project's actual database, not general PAX8/NCE domain knowledge
|
||||
|
||||
**Research date:** 2026-07-11
|
||||
**Valid until:** No natural expiry — this is an internal-schema/internal-component phase with no external dependency to go stale. Re-verify only if a future migration changes `pax8_*` schema before Phase 14 is implemented.
|
||||
Loading…
Add table
Add a link
Reference in a new issue