From 386528a0112e68a33b2b92028a8b32695c3a2a81 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 13:51:00 -0400 Subject: [PATCH] =?UTF-8?q?docs(14):=20create=20phase=20plan=20=E2=80=94?= =?UTF-8?q?=20/pax8=20UI=20surface=20(6=20plans,=204=20waves)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR --- .planning/ROADMAP.md | 10 +- .../phases/14-pax8-ui-surface/14-01-PLAN.md | 209 +++++++++++++++ .../phases/14-pax8-ui-surface/14-02-PLAN.md | 239 ++++++++++++++++++ .../phases/14-pax8-ui-surface/14-03-PLAN.md | 159 ++++++++++++ .../phases/14-pax8-ui-surface/14-04-PLAN.md | 193 ++++++++++++++ .../phases/14-pax8-ui-surface/14-05-PLAN.md | 218 ++++++++++++++++ .../phases/14-pax8-ui-surface/14-06-PLAN.md | 115 +++++++++ 7 files changed, 1141 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/14-pax8-ui-surface/14-01-PLAN.md create mode 100644 .planning/phases/14-pax8-ui-surface/14-02-PLAN.md create mode 100644 .planning/phases/14-pax8-ui-surface/14-03-PLAN.md create mode 100644 .planning/phases/14-pax8-ui-surface/14-04-PLAN.md create mode 100644 .planning/phases/14-pax8-ui-surface/14-05-PLAN.md create mode 100644 .planning/phases/14-pax8-ui-surface/14-06-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 7beee87..3fa5c63 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -316,7 +316,13 @@ render, including the manual-resolution workflow for flagged companies. 2. Each company shows a cost breakdown (e.g., by subscription/product) built from the synced subscription and order/invoice data 3. Flagged/ambiguous company matches appear in a distinct, clearly-labeled review section on `/pax8` rather than being mixed silently into the main list 4. From that review section, an admin can pick the correct Autotask company for a flagged PAX8 company; the resolution persists and is respected (not overwritten) by future syncs -**Plans**: TBD +**Plans**: 6 plans +- [ ] 14-01-PLAN.md — GET /api/pax8/companies list + /api/pax8/companies/[id] cost-breakdown (requireAuth) (PAX8-13) +- [ ] 14-02-PLAN.md — /api/pax8/company-matches queue + admin-gated resolve route + extracted resolver service & test (PAX8-12, PAX8-14) +- [ ] 14-03-PLAN.md — DetailModal additive extension: kind prop + PAX8_COMPANY_GROUPS + subscriptions cost-breakdown section (PAX8-13) +- [ ] 14-04-PLAN.md — /pax8 page shell + Companies tab (DataTable + DetailModal drill-down) + top-level nav entry (PAX8-13) +- [ ] 14-05-PLAN.md — Needs Review tab (review cards, candidate + manual-search resolve, count badge) + companies-list auth hardening (PAX8-14, PAX8-12) +- [ ] 14-06-PLAN.md — Automated gates + human verification of all 4 SCs and the view/resolve permission split (PAX8-12, PAX8-13, PAX8-14) **UI hint**: yes ## Progress @@ -341,7 +347,7 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 ph | 11. Company, Catalog & Subscription Sync | v2.0 | 3/3 | Complete | 2026-07-11 | | 12. Orders/Invoices & Company Matching | v2.0 | 5/5 | Complete | 2026-07-11 | | 13. Scheduler & Admin Toggle | v2.0 | 3/3 | Complete | 2026-07-11 | -| 14. /pax8 UI Surface | v2.0 | 0/TBD | Not started | - | +| 14. /pax8 UI Surface | v2.0 | 0/6 | Not started | - | --- *Roadmap created: 2026-05-03* diff --git a/.planning/phases/14-pax8-ui-surface/14-01-PLAN.md b/.planning/phases/14-pax8-ui-surface/14-01-PLAN.md new file mode 100644 index 0000000..5536aa3 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-01-PLAN.md @@ -0,0 +1,209 @@ +--- +phase: 14-pax8-ui-surface +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - app/api/pax8/companies/route.ts + - app/api/pax8/companies/[id]/route.ts +autonomous: true +requirements: [PAX8-13] +must_haves: + truths: + - "An authenticated user can GET /api/pax8/companies and receive a paginated list of PAX8 companies with their matched Autotask company name and active subscription count" + - "An authenticated user can GET /api/pax8/companies/[id] and receive that company's current subscriptions plus each subscription's latest actually-billed amount" + - "Both routes reject unauthenticated requests with 401" + artifacts: + - path: "app/api/pax8/companies/route.ts" + provides: "GET company list (paginated, sortable, searchable), requireAuth-gated" + exports: ["GET"] + - path: "app/api/pax8/companies/[id]/route.ts" + provides: "GET single-company subscriptions + per-subscription latest-period cost breakdown" + exports: ["GET"] + key_links: + - from: "app/api/pax8/companies/route.ts" + to: "pax8_companies LEFT JOIN companies" + via: "postgresClient.query on autotask_company_id" + pattern: "LEFT JOIN companies c ON c.id = pc.autotask_company_id" + - from: "app/api/pax8/companies/[id]/route.ts" + to: "pax8_order_items" + via: "DISTINCT ON (subscription_id) windowed query" + pattern: "DISTINCT ON \\(subscription_id\\)" +--- + + +Create the two read-only API routes that back the Companies tab of `/pax8`: a paginated/sortable/searchable company list, and a single-company drill-down returning subscriptions joined to their latest actually-billed cost line. + +Purpose: PAX8-13 requires `/pax8` to list PAX8 companies with subscriptions and a cost breakdown. These routes are the data layer; the page (Plan 04) consumes them. Splitting the aggregation server-side (per the Architectural Responsibility Map) keeps the non-trivial per-subscription windowed join out of the browser. +Output: `app/api/pax8/companies/route.ts` (list) and `app/api/pax8/companies/[id]/route.ts` (drill-down). + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md +@.planning/phases/14-pax8-ui-surface/14-PATTERNS.md +@.planning/phases/14-pax8-ui-surface/14-CONTEXT.md + + + + +Auth helper (from lib/auth-utils.ts) — D-07 requires requireAuth() ONLY, never requirePermission: + export async function requireAuth(): Promise<{ session: Session | null; error: NextResponse | null }>; + // usage: const { error } = await requireAuth(); if (error) return error; + +Postgres access (from lib/services/postgres-client.ts): + import postgresClient from '@/lib/services/postgres-client'; + postgresClient.query(sql: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number }>; + // ALWAYS parameterized ($1,$2) — never string-interpolate user input. + +Relevant schema (verified live, migrations 091/092/093): + pax8_companies(id UUID, name TEXT, external_id TEXT, website TEXT, status TEXT, + city TEXT, state_or_province TEXT, postal_code TEXT, country TEXT, + raw_payload JSONB, synced_at TIMESTAMPTZ, is_deleted BOOL, + autotask_company_id BIGINT, match_confidence NUMERIC(4,3), + match_method TEXT, matched_at TIMESTAMPTZ) + companies(id BIGINT, company_name VARCHAR, is_active BOOL, is_deleted BOOL) + pax8_subscriptions(id UUID, pax8_company_id UUID, product_id UUID, quantity INT, + billing_term TEXT, status TEXT, start_date TIMESTAMPTZ, + price NUMERIC(12,2), partner_cost NUMERIC(12,2), currency CHAR(3), + is_deleted BOOL) + pax8_products(id UUID, sku TEXT, vendor_sku TEXT, name TEXT, category TEXT, is_deleted BOOL) + pax8_order_items(id UUID, order_id UUID, product_id UUID, quantity INT, + unit_price NUMERIC(12,2), line_total NUMERIC(12,2), currency CHAR(3), + pax8_company_id UUID, subscription_id UUID, item_type TEXT, sku TEXT, + description TEXT, start_period TIMESTAMPTZ, end_period TIMESTAMPTZ, + partner_cost NUMERIC(12,2), partner_cost_total NUMERIC(12,2), is_deleted BOOL) + Indexes present: idx_pax8_order_items_company(pax8_company_id), idx_pax8_order_items_period. + +List query template (RESEARCH.md Pattern 1, live-verified): + 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 + LIMIT $1 OFFSET $2; + +Drill-down two-query approach (RESEARCH.md Pattern 2 + Pitfalls 2/3/4): + Step 1 — current subscriptions: + 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 (windowed, NOT a global MAX): + 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 in JS on subscription_id. Fall back to price*quantity ONLY when no order-item row exists. + + +# Do NOT reference other pax8 API routes — this plan's two routes are self-contained. + + + + + + Task 1: GET /api/pax8/companies — paginated company list + + - app/api/admin/device-link-conflicts/route.ts (query construction, limit/offset parsing, snake_case→camelCase map, response envelope — the structural template) + - lib/auth-utils.ts (requireAuth signature — use requireAuth, NOT requirePermission, per D-07) + - lib/services/pax8-company-matcher.ts (lines 216-231: confirms pax8_companies.autotask_company_id / match_method are the live join-and-confidence signals this list surfaces) + - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pattern 1, Pitfall 5) + + app/api/pax8/companies/route.ts + + Create `GET(request: NextRequest)`. First line of the handler: `const { error } = await requireAuth(); if (error) return error;` (D-07 — any authenticated user, no admin gate). Parse `limit` (default 50, clamp max 200), `offset` (default 0, floor 0) exactly as device-link-conflicts/route.ts does. Parse `sort` and `order`: map `sort` through an explicit whitelist object to a real column — allowed keys `name`→`pc.name`, `status`→`pc.status`, `city`→`pc.city`, `country`→`pc.country`, `subscriptions`→`active_subscription_count`, `match`→`pc.match_method`; any other/absent value falls back to `pc.name`. Map `order` to `ASC` unless it equals `desc` (case-insensitive) → `DESC`. NEVER interpolate the raw `sort`/`order` strings into SQL — only the whitelisted literals may be concatenated into the ORDER BY. Parse optional `search`: when present, add `AND pc.name ILIKE $N` with a parameterized `%search%` bound value. Run the RESEARCH.md Pattern 1 SELECT (see interfaces) with the resolved ORDER BY, plus a matching `SELECT COUNT(*) FROM pax8_companies pc WHERE pc.is_deleted = false [AND pc.name ILIKE $1]` for the total. Transform rows to camelCase: `{ id, name, status, city, stateOrProvince, country, autotaskCompanyId, matchConfidence, matchMethod, matchedCompanyName, activeSubscriptionCount }` (coerce `active_subscription_count` and `autotask_company_id` with Number). Return `NextResponse.json({ items, total, limit, offset })`. Wrap DB work in try/catch returning `{ error }` at status 500 with a `console.error('Failed to fetch PAX8 companies:', err)` per codebase convention. + + + npx tsc --noEmit --pretty + + + - `app/api/pax8/companies/route.ts` exports an async `GET` whose first statement invokes `requireAuth()` and returns `error` when set (grep: `requireAuth` present, `requirePermission` absent in this file) + - The ORDER BY column is selected from a hardcoded whitelist map — grep confirms no `searchParams.get('sort')` value is concatenated directly into the SQL string + - All user-supplied values (search term, limit, offset) reach SQL only as `$N` bound parameters (no template-literal interpolation of request input) + - Response JSON shape is `{ items: [...], total, limit, offset }` with camelCase item keys including `matchedCompanyName` and `activeSubscriptionCount` + - `npx tsc --noEmit --pretty` passes + + GET /api/pax8/companies returns a 200 paginated list for an authenticated user and 401 otherwise; sort/search are injection-safe. + + + + Task 2: GET /api/pax8/companies/[id] — subscriptions + cost breakdown + + - app/api/admin/device-link-conflicts/route.ts (bulk-fetch-then-map-in-JS pattern for the two-query join) + - lib/auth-utils.ts (requireAuth) + - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pattern 2, Pitfall 2 per-subscription windowing, Pitfall 3 use line_total, Pitfall 4 fallback label chain, Pitfall 5 always scope by pax8_company_id + is_deleted) + - lib/types/pax8.ts (Pax8Subscription / Pax8InvoiceItem field names for the transform) + + app/api/pax8/companies/[id]/route.ts + + Create `GET(request: NextRequest, { params }: { params: Promise<{ id: string }> })`. Gate with `requireAuth()` (D-07). `const { id } = await params;` and validate `id` matches a UUID shape (`/^[0-9a-f-]{36}$/i`), returning 400 otherwise. Fetch the company header row: `SELECT id, name, status, city, state_or_province, country, website, autotask_company_id, match_confidence, match_method, synced_at, is_deleted FROM pax8_companies WHERE id = $1` — 404 if no row. Also fetch the matched Autotask company name via `SELECT company_name FROM companies WHERE id = $1` when `autotask_company_id` is set. Run the Step 1 (current subscriptions) and Step 2 (per-subscription DISTINCT ON latest order-item) queries from the interfaces block, both scoped `WHERE pax8_company_id = $1 AND is_deleted = false`. Join in application code keyed on `subscription_id`: for each Step 1 subscription build `{ subscriptionId, productName, sku, quantity, billingTerm, status, currency, latestBilledAmount, startPeriod }` where `latestBilledAmount` = the matched order-item's `line_total` (Number) when present, else `price * quantity` fallback (Pitfall 3 — never recompute from unit_price×quantity when a line_total exists). Product label uses the COALESCE chain in JS: `product_name || description || sku || 'Unknown item'` (Pitfall 4). Then append any Step-2 order-item rows whose `subscription_id` has NO matching Step 1 subscription as extra breakdown rows (using their own product/description/sku label and `line_total`, with `billingTerm`/`status` null) — these are tombstoned-subscription historical lines that must still appear. Compute `costTotal` = sum of all rows' `latestBilledAmount`. Return `NextResponse.json({ company: {...camelCase header + matchedCompanyName...}, subscriptions: [...], costTotal })`. try/catch → 500 with console.error. + + + npx tsc --noEmit --pretty + + + - Handler gates with `requireAuth()` and validates the `[id]` param as a UUID (returns 400 on malformed id, 404 on missing company) + - The order-item query uses `DISTINCT ON (subscription_id) ... ORDER BY subscription_id, start_period DESC` (grep confirms) — NOT a single company-wide `MAX(start_period)` cutoff + - Both subscription and order-item queries include `pax8_company_id = $1 AND is_deleted = false` (Pitfall 5 scoping) + - Per-line amount comes from `line_total` (grep: no `unit_price * quantity` used when a line_total is available) + - Response includes `company`, `subscriptions` (array), and a numeric `costTotal` + - `npx tsc --noEmit --pretty` passes + + GET /api/pax8/companies/[id] returns one company's subscriptions with per-subscription latest billed amounts and a summed costTotal; every active subscription appears regardless of billing anniversary. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser → API route | Authenticated manager sends list/detail requests with attacker-controllable query params (sort, order, search, limit, offset) and path param (company id) | +| API route → Postgres | Route issues parameterized queries against pax8_* and companies tables | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-03 | Information Disclosure | GET /api/pax8/companies, GET /api/pax8/companies/[id] | mitigate | Both handlers open with `requireAuth()` (D-07); `/pax8` and `/api/pax8/companies*` are NOT in middleware.ts publicRoutes — do not add them | +| T-14-04 | Tampering | list route sort/order/search/limit/offset params | mitigate | `sort`/`order` resolved through a hardcoded column whitelist (never interpolated); `search`/`limit`/`offset` bound as `$N` parameters only | +| T-14-06 | Information Disclosure | drill-down `[id]` path param | mitigate | Validate UUID shape before query; parameterized `WHERE id = $1`; return 404 (not raw error) on missing row | +| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this phase (RESEARCH.md Package Legitimacy Audit: N/A) — no supply-chain surface introduced | + + + +- `npx tsc --noEmit --pretty` passes with both new route files present +- Manual (deferred to Plan 06): both endpoints return expected JSON against the live dev DB (118 companies, 445 subscriptions) for an authenticated session + + + +- Two route files exist, both gated by `requireAuth()` +- List route is injection-safe (whitelisted ORDER BY, parameterized inputs) +- Drill-down route windows cost per subscription (Pitfall 2) and uses line_total (Pitfall 3) with fallback labels (Pitfall 4) +- Type check green + + + +Create `.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md` when done + diff --git a/.planning/phases/14-pax8-ui-surface/14-02-PLAN.md b/.planning/phases/14-pax8-ui-surface/14-02-PLAN.md new file mode 100644 index 0000000..3689f27 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-02-PLAN.md @@ -0,0 +1,239 @@ +--- +phase: 14-pax8-ui-surface +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - 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 +autonomous: true +requirements: [PAX8-12, PAX8-14] +must_haves: + truths: + - "An authenticated user can GET /api/pax8/company-matches and receive the unresolved review queue with each PAX8 company's stored top-3 candidate Autotask companies and their names" + - "An admin (and only an admin) can POST /api/pax8/company-matches/[id]/resolve to link a flagged PAX8 company to any existing active Autotask company" + - "Resolving writes BOTH pax8_companies.match_method='manual' and pax8_company_match_review.resolved_* in one transaction, so the resolution survives the next sync's re-matching pass" + - "The resolve action accepts a companyId outside candidate_company_ids (manual-search fallback / zero-candidate case) provided that company exists and is active" + artifacts: + - path: "app/api/pax8/company-matches/route.ts" + provides: "GET unresolved review queue with bulk-fetched candidate names, requireAuth-gated" + exports: ["GET"] + - path: "lib/services/pax8-company-match-resolver.ts" + provides: "resolvePax8CompanyMatch(tx, params) two-table transactional write + validation, unit-testable" + exports: ["resolvePax8CompanyMatch", "ResolveResult"] + - path: "lib/services/pax8-company-match-resolver.test.ts" + provides: "vitest coverage of the resolve logic (both writes, guards, no candidate-membership restriction)" + - path: "app/api/pax8/company-matches/[id]/resolve/route.ts" + provides: "POST resolve, requirePermission('admin','access')-gated, zod-validated" + exports: ["POST"] + key_links: + - from: "app/api/pax8/company-matches/[id]/resolve/route.ts" + to: "lib/services/pax8-company-match-resolver.ts" + via: "postgresClient.transaction(tx => resolvePax8CompanyMatch(tx, ...))" + pattern: "resolvePax8CompanyMatch" + - from: "lib/services/pax8-company-match-resolver.ts" + to: "pax8_companies + pax8_company_match_review" + via: "two UPDATE statements in the same tx" + pattern: "match_method = 'manual'" +--- + + +Build the review-queue read route, the resolve mutation route, and — the load-bearing piece — an extracted `resolvePax8CompanyMatch()` service that performs the two-table transactional write and is unit-testable under the existing `lib/**/*.test.ts` vitest glob. + +Purpose: PAX8-12 (admin resolves flagged matches) and PAX8-14 (surface flagged matches for resolution). This is the phase's only genuinely test-worthy logic: the resolution must set BOTH `pax8_companies.match_method='manual'` AND mark the review row resolved, or `pax8-company-matcher.ts`'s re-scoring guard will re-flag the company on the next sync. Extracting the write into `lib/services/` (Validation-strategy Wave 0 decision: EXTRACT for coverage) gives it real automated verification instead of manual-only. +Output: review list route, resolver service + test, resolve route. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md +@.planning/phases/14-pax8-ui-surface/14-PATTERNS.md +@.planning/phases/14-pax8-ui-surface/14-CONTEXT.md + + + + +Auth helpers (lib/auth-utils.ts): + requireAuth(): { session, error } // review LIST route (D-07) + requirePermission('admin','access'): { session, error } // resolve route ONLY (D-08) + +Postgres (lib/services/postgres-client.ts): + import postgresClient from '@/lib/services/postgres-client'; + postgresClient.query(sql, params): Promise<{ rows: T[]; rowCount: number }>; + postgresClient.transaction(cb: (tx: PoolClient) => Promise): Promise; + // PoolClient has .query(sql, params): Promise<{ rows; rowCount }> — the tx handle. + +Schema (migration 091/093, verified): + pax8_company_match_review( + id UUID PK, pax8_company_id UUID NOT NULL REFERENCES pax8_companies(id), + candidate_company_ids BIGINT[] NOT NULL, -- Autotask company ids (integers), NOT uuid[] + match_confidences TEXT[] NOT NULL, + detected_at TIMESTAMPTZ, resolved_at TIMESTAMPTZ, + resolved_by_user_id TEXT REFERENCES "user"(id), + resolved_to_company_id BIGINT REFERENCES companies(id), + resolution_note TEXT) + pax8_companies( ... autotask_company_id BIGINT, match_confidence NUMERIC(4,3), + match_method TEXT, matched_at TIMESTAMPTZ ) + companies(id BIGINT, company_name VARCHAR, is_active BOOL, is_deleted BOOL) + +Matcher re-scoring eligibility guard (lib/services/pax8-company-matcher.ts lines ~216-231) — the reason BOTH writes are required: + 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) + +Review-queue query (RESEARCH.md Pattern 3): + 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; + Then bulk-fetch candidate names: + SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[]); + +Resolver contract to CREATE (lib/services/pax8-company-match-resolver.ts): + export type ResolveResult = + | { ok: true; resolvedToCompanyId: number } + | { ok: false; code: 'not_found' | 'already_resolved' | 'company_not_found'; message: string }; + export async function resolvePax8CompanyMatch( + tx: { query: (sql: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number }> }, + params: { reviewId: string; companyId: number; note: string | null; userId: string | null } + ): Promise; + +Zod resolve body (mirror device-link-conflicts/[id]/resolve/route.ts ResolveBody): + const ResolveBody = z.object({ companyId: z.number().int().positive(), note: z.string().max(500).optional() }); + + + + + + + Task 1: GET /api/pax8/company-matches — unresolved review queue + + - app/api/admin/device-link-conflicts/route.ts (full file — near-exact template: limit/offset, bulk-fetch candidates in one query, snake_case→camelCase map, response envelope) + - lib/auth-utils.ts (requireAuth — D-07: this GET is NOT admin-gated, unlike the device-link-conflicts GET it copies) + - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pattern 3, note on BIGINT[] vs UUID[]) + + app/api/pax8/company-matches/route.ts + + Create `GET(request: NextRequest)` gated by `requireAuth()` (D-07 — deliberately NOT `requirePermission`; viewing the queue is manager-visible, only the resolve mutation is admin-gated). Parse `limit` (default 50, max 200) and `offset` (default 0) as in device-link-conflicts. Run the review-queue query from the interfaces block. Collect all candidate ids into a `Set` (coerce each with `Number`), bulk-fetch their names with `SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`, build a `Map`. Also fetch total: `SELECT COUNT(*) FROM pax8_company_match_review WHERE resolved_at IS NULL`. Transform each review row to `{ id, detectedAt, pax8CompanyId, pax8CompanyName, candidates: [...] }` where `candidates` zips `candidate_company_ids[i]` with `match_confidences[i]` into `{ companyId: Number(id), companyName: nameMap.get(Number(id)) ?? null, confidence: match_confidences[i] ?? null }`. Return `NextResponse.json({ items, total, limit, offset })`. Note the BIGINT[] type: `candidate_company_ids` needs no `::text[]` cast on the array column itself (unlike device-link-conflicts' UUID[]). try/catch → 500 + console.error. + + + npx tsc --noEmit --pretty + + + - `GET` handler's first statement is `requireAuth()` (grep: `requireAuth` present, `requirePermission` absent in this file) + - Query filters `WHERE r.resolved_at IS NULL` and joins pax8_companies for the flagged company name + - Candidate names are bulk-fetched in a single `= ANY($1::bigint[])` query (not one query per candidate) + - Each item exposes `candidates[]` with `companyId`, `companyName`, `confidence` + - `npx tsc --noEmit --pretty` passes + + GET /api/pax8/company-matches returns the 38 known open review rows (16 no-candidate, 22 ambiguous) with candidate names resolved, for any authenticated user. + + + + Task 2: resolvePax8CompanyMatch service + unit test (RED→GREEN) + + - lib/services/pax8-company-matcher.test.ts (lines 1-40 — the vi.mock('@/lib/services/postgres-client') convention and calls() helper to replicate) + - app/api/admin/device-link-conflicts/[id]/resolve/route.ts (the FOR UPDATE lock + already-resolved 409 guard + two-table write shape to adapt — but this resolver DIVERGES: no candidate-membership check, and writes pax8_companies too) + - lib/services/pax8-company-matcher.ts (lines 216-231 — the guard that makes BOTH writes mandatory) + - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pattern 4, Anti-Patterns, Security Domain row on companyId existence validation) + + lib/services/pax8-company-match-resolver.ts, lib/services/pax8-company-match-resolver.test.ts + + - Given a mock tx: SELECT review FOR UPDATE returns an unresolved row → resolver issues an UPDATE to pax8_companies setting autotask_company_id=$companyId, match_confidence=NULL, match_method='manual', matched_at=NOW(), AND an UPDATE to pax8_company_match_review setting resolved_at=NOW(), resolved_by_user_id, resolved_to_company_id, resolution_note; returns { ok: true, resolvedToCompanyId } + - Given the review row is missing (rowCount 0) → returns { ok: false, code: 'not_found' } and issues NO update statements + - Given the review row already has resolved_at set → returns { ok: false, code: 'already_resolved' } and issues NO update statements + - Given the target companyId does NOT exist / is not active in companies (existence-check query returns 0 rows) → returns { ok: false, code: 'company_not_found' } and issues NO write to pax8_companies + - Given a companyId that is NOT in candidate_company_ids but exists+is_active → still resolves successfully (candidate-membership is NOT enforced — D-05 manual-search / D-09 zero-candidate) + + + Create `lib/services/pax8-company-match-resolver.ts` exporting the `ResolveResult` type and `resolvePax8CompanyMatch(tx, params)` per the interfaces contract. Sequence inside the function using the passed `tx.query`: (1) `SELECT pax8_company_id, resolved_at FROM pax8_company_match_review WHERE id = $1 FOR UPDATE` — rowCount 0 → return `not_found`; `resolved_at` truthy → return `already_resolved`. (2) Validate the target: `SELECT 1 FROM companies WHERE id = $1 AND is_active = true AND is_deleted = false` — 0 rows → return `company_not_found` (this is the substitute for device-link-conflicts' candidate-membership check; D-05 requires accepting non-candidate ids). Do NOT check `candidate_company_ids` membership. (3) `UPDATE pax8_companies SET autotask_company_id = $2, match_confidence = NULL, match_method = 'manual', matched_at = NOW() WHERE id = $1` keyed on the review's `pax8_company_id`. (4) `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`. Return `{ ok: true, resolvedToCompanyId: companyId }`. Write the test FIRST (RED): create `lib/services/pax8-company-match-resolver.test.ts` following pax8-company-matcher.test.ts's mocking discipline — but since the resolver takes `tx` as a parameter, the test passes a hand-rolled mock tx `{ query: vi.fn() }` scripted to return the sequenced results, and asserts on the SQL strings + bound params of each `query` call and on the returned ResolveResult for each behavior case above. Run the test, confirm it fails, implement, confirm it passes. + + + npx vitest run lib/services/pax8-company-match-resolver.test.ts + + + - `lib/services/pax8-company-match-resolver.ts` exports `resolvePax8CompanyMatch` and `ResolveResult` + - The success path issues exactly the two UPDATEs with `match_method = 'manual'` and the review resolve columns (grep: both `match_method = 'manual'` and `resolved_at = NOW()` present) + - No candidate-membership check exists (grep: no reference to `candidate_company_ids` in the resolver source) + - Company existence/active validation query is present before the pax8_companies UPDATE + - `npx vitest run lib/services/pax8-company-match-resolver.test.ts` passes with all five behavior cases green + + The resolver writes both tables atomically, guards not-found/already-resolved/company-not-found, allows non-candidate ids, and is covered by a passing vitest suite. + + + + Task 3: POST /api/pax8/company-matches/[id]/resolve — admin-gated route + + - app/api/admin/device-link-conflicts/[id]/resolve/route.ts (full file — requirePermission gate, uuid id validation, zod body parse, transaction wrapper to mirror) + - lib/services/pax8-company-match-resolver.ts (the resolver created in Task 2 — this route wraps it) + - lib/auth-utils.ts (requirePermission signature; session.user.id for resolved_by_user_id) + + app/api/pax8/company-matches/[id]/resolve/route.ts + + Create `POST(request, { params }: { params: Promise<{ id: string }> })`. First statement: `const { session, error } = await requirePermission('admin', 'access'); if (error) return error;` (D-08 — copy device-link-conflicts' gate exactly; this is the one route that IS admin-gated). `const { id } = await params;` validate UUID shape (`/^[0-9a-f-]{36}$/i`) → 400 if invalid. Parse JSON body (400 on parse failure), validate with the `ResolveBody` zod schema from interfaces (`companyId` positive int, optional `note` max 500) → 400 with `parsed.error.flatten()` on failure. Call `postgresClient.transaction(tx => resolvePax8CompanyMatch(tx, { reviewId: id, companyId: parsed.data.companyId, note: parsed.data.note ?? null, userId: session?.user?.id ?? null }))`. Map the returned `ResolveResult` to HTTP: `ok` → `NextResponse.json({ ok: true, resolvedToCompanyId })` 200; `not_found` → 404; `already_resolved` → 409; `company_not_found` → 400 — each with the result's message in `{ error }`. Wrap in try/catch → 500 + `console.error('Failed to resolve PAX8 company match:', err)`. + + + npx tsc --noEmit --pretty + + + - First statement of `POST` invokes `requirePermission('admin', 'access')` (grep confirms; NOT `requireAuth` alone) + - Body is validated with a zod schema (`companyId` int positive, `note` optional max 500) before any DB work + - Route calls `resolvePax8CompanyMatch` inside `postgresClient.transaction(...)` (grep: both identifiers present) + - Result codes map to statuses: ok→200, not_found→404, already_resolved→409, company_not_found→400 + - `npx tsc --noEmit --pretty` passes + + POST resolve is admin-only, zod-validated, and delegates the two-table write to the tested resolver inside a transaction. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser → GET /api/pax8/company-matches | Any authenticated user reads the flagged-match queue (read side of the asymmetric split) | +| browser → POST .../[id]/resolve | Privilege boundary: mutating a match is admin-only (write side of the asymmetric split) | +| resolve route → Postgres | Two-table transactional write repointing pax8_companies.autotask_company_id (a BIGINT FK-by-convention to companies) | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-01 | Elevation of Privilege | POST /api/pax8/company-matches/[id]/resolve | mitigate | Handler opens with `requirePermission('admin','access')` (D-08). This is the deliberate asymmetry vs the read routes' `requireAuth()` — the GET queue is manager-visible, only the mutation crosses the admin boundary. Do not rely on middleware (it checks cookie presence only). Do NOT repeat the `/api/pax8/sync` no-auth gap (13-REVIEW CR-02) | +| T-14-02 | Tampering | resolve target companyId (D-05 accepts ids outside candidate_company_ids) | mitigate | Because the manual-search fallback intentionally allows any Autotask company id, the resolver validates `SELECT 1 FROM companies WHERE id = $1 AND is_active = true AND is_deleted = false` before writing — substituting existence/active-state validation for device-link-conflicts' candidate-membership check, preventing an invalid/dangling autotask_company_id reference | +| T-14-04 | Tampering | resolve `[id]` path param + JSON body | mitigate | UUID-shape check on `id`; zod schema on body (companyId positive int, note ≤500 chars); all values bound as `$N` parameters | +| T-14-03 | Information Disclosure | GET /api/pax8/company-matches | mitigate | `requireAuth()` gate; queue not exposed to unauthenticated callers | +| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages (RESEARCH.md audit N/A); `zod` already installed and in active use | + + + +- `npx vitest run lib/services/pax8-company-match-resolver.test.ts` green (all five behavior cases) +- `npx tsc --noEmit --pretty` green with all three files present +- Manual (Plan 06): resolve a real flagged company as admin; confirm both tables updated and a re-sync leaves it untouched; confirm a non-admin gets 403 + + + +- Review list route gated by requireAuth, resolve route gated by requirePermission('admin','access') +- Resolver writes both pax8_companies (match_method='manual') and the review row, atomically, with company-existence validation and no candidate-membership restriction +- Resolver unit test passes +- Type check green + + + +Create `.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md` when done + diff --git a/.planning/phases/14-pax8-ui-surface/14-03-PLAN.md b/.planning/phases/14-pax8-ui-surface/14-03-PLAN.md new file mode 100644 index 0000000..52612e1 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-03-PLAN.md @@ -0,0 +1,159 @@ +--- +phase: 14-pax8-ui-surface +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - components/admin/DetailModal.tsx +autonomous: true +requirements: [PAX8-13] +must_haves: + truths: + - "Passing kind='pax8_company' to DetailModal renders a PAX8 identity/system field layout instead of the flat unstyled key/value fallback" + - "When the data object carries a subscriptions array, the Formatted tab renders a subscriptions & cost-breakdown table (product, qty, billing term, amount) plus a summed total, above the field groups" + - "Every existing DetailModal caller (tickets, companies) renders exactly as before — the change is purely additive" + artifacts: + - path: "components/admin/DetailModal.tsx" + provides: "Additive kind prop + PAX8_COMPANY_GROUPS + unconditional subscriptions cost-breakdown section" + contains: "PAX8_COMPANY_GROUPS" + key_links: + - from: "DetailModal detectGroups" + to: "PAX8_COMPANY_GROUPS" + via: "kind === 'pax8_company' branch" + pattern: "kind === 'pax8_company'" + - from: "DetailModal Formatted tab" + to: "data.subscriptions" + via: "Array.isArray guard rendering a cost table" + pattern: "Array.isArray\\(data.subscriptions\\)" +--- + + +Extend `components/admin/DetailModal.tsx` additively so it can render a PAX8 company drill-down: a `kind` prop that selects a new `PAX8_COMPANY_GROUPS` field set (fixing the fact that a PAX8 company has `name`, not `company_name`, and would otherwise fall into the flat unstyled fallback), plus a new unconditional Formatted-tab section that renders the subscriptions/cost-breakdown array as a compact table. + +Purpose: PAX8-13's cost breakdown (D-02/D-03) is displayed through this shared modal. RESEARCH.md Pitfall 1 is explicit: DetailModal cannot render an array of subscription rows today — no `FieldType` renders a list. Without this extension the drill-down would show an unstyled key dump with no cost data. The extension must NOT touch `TICKET_GROUPS`/`COMPANY_GROUPS` or their detection branches — it is a pure addition, so every other detail view in the app is unaffected. +Output: extended `DetailModal.tsx`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md +@.planning/phases/14-pax8-ui-surface/14-PATTERNS.md +@.planning/phases/14-pax8-ui-surface/14-UI-SPEC.md + + + +DetailModal will be called by /pax8 as: + + +company header fields (camelCase already flattened by the page from the /api/pax8/companies/[id] response): + id, name, status, city, stateOrProvince, country, website, + autotaskCompanyId, matchedCompanyName, matchMethod, matchConfidence, syncedAt, isDeleted +subscriptions[] element shape: + { subscriptionId, productName, sku, quantity, billingTerm, status, currency, latestBilledAmount, startPeriod } +costTotal: number + +Current DetailModal structure (verified — components/admin/DetailModal.tsx): + - FieldGroup type: { label, fields: Array<{ key, label, type?: FieldType }>, paired? } + - TICKET_GROUPS / COMPANY_GROUPS constants (DO NOT MODIFY) + - detectGroups(data): sniffs 'ticket_number' in data → TICKET_GROUPS, 'company_name' in data → COMPANY_GROUPS, else flat fallback + - DetailModalProps: { open, onOpenChange, title, data, fields? } + - Header block (~lines 348-375) branches on 'ticket_number' in data; the else-branch already renders DialogTitle={title} + "Record ID: {data.id}" + optional activeBadge when 'is_active' in data — this else-branch works for a pax8 company as-is (pax8 company has no is_active, so no badge shows) + - Formatted tab renders groups, then an unconditional "Description block for tickets" (~lines 543-551) — copy THAT block's shape for the new subscriptions section + - FieldType includes 'date','bool','url','id' — reuse these, no new scalar types needed + - Amounts must use tabular-nums font-mono (UI-SPEC Typography: numerics in IBM Plex Mono) + + + + + + + Task 1: Add kind prop + PAX8_COMPANY_GROUPS field set + + - components/admin/DetailModal.tsx (full file — you must see detectGroups, DetailModalProps, TICKET_GROUPS/COMPANY_GROUPS, and the header ternary before editing) + - .planning/phases/14-pax8-ui-surface/14-UI-SPEC.md (Component Inventory item 1-2: kind prop + PAX8_COMPANY_GROUPS Identity/System groups) + - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pitfall 1 — do not touch existing detection branches) + + components/admin/DetailModal.tsx + + Add an optional `kind?: 'ticket' | 'company' | 'pax8_company'` field to `DetailModalProps` and destructure it in the component signature (default undefined). Define a new `PAX8_COMPANY_GROUPS: FieldGroup[]` constant next to `COMPANY_GROUPS` (do not edit the existing constants): an `Identity` group with fields `name` (label 'Name'), `status` (label 'Status'), `city` (label 'City'), `stateOrProvince` (label 'State/Province'), `country` (label 'Country'); and a `System` group `paired: 'Identity'`... — actually pair `System` with `Identity` is fine, or leave System unpaired — with fields `id` (label 'Record ID', type 'id'), `syncedAt` (label 'Synced At', type 'date'), `isDeleted` (label 'Deleted', type 'bool'), and `website` (label 'Website', type 'url') in the Identity group. Change the `detectGroups` signature to `detectGroups(data, kind?)` and add, as the FIRST check inside it, `if (kind === 'pax8_company') return PAX8_COMPANY_GROUPS;` BEFORE the existing `'ticket_number' in data` / `'company_name' in data` sniff branches (which remain untouched as the fallback when `kind` is absent). Update the single call site `const groups = detectGroups(data);` to `const groups = detectGroups(data, kind);`. The header else-branch already renders `title` + record id correctly for a pax8 company — do not modify the header ternary. Note the field keys are camelCase (`stateOrProvince`, `syncedAt`, `isDeleted`) because the page passes an already-transformed object. + + + npx tsc --noEmit --pretty + + + - `DetailModalProps` includes `kind?: 'ticket' | 'company' | 'pax8_company'` and the component destructures it + - `PAX8_COMPANY_GROUPS` constant exists (grep confirms) with Identity + System groups using camelCase keys + - `detectGroups` returns `PAX8_COMPANY_GROUPS` when `kind === 'pax8_company'`, checked before the existing sniff branches (grep: `kind === 'pax8_company'` present) + - `TICKET_GROUPS` and `COMPANY_GROUPS` and their `'ticket_number' in data` / `'company_name' in data` branches are unchanged (grep: both still present verbatim) + - `npx tsc --noEmit --pretty` passes + + DetailModal accepts kind='pax8_company' and renders PAX8 identity/system fields; all existing callers unaffected. + + + + Task 2: Add subscriptions & cost-breakdown array section to the Formatted tab + + - components/admin/DetailModal.tsx (the "Description block for tickets" at ~lines 543-551 — copy its conditional-wrapper + h3 + rounded-lg border shape) + - .planning/phases/14-pax8-ui-surface/14-UI-SPEC.md (Visual Hierarchy: cost table is the focal point, renders above field groups; Copywriting; Typography numerics in font-mono tabular-nums) + - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pitfall 3 use line_total/latestBilledAmount, Pitfall 4 fallback label) + + components/admin/DetailModal.tsx + + In the Formatted `TabsContent`, add a new section rendered when `Array.isArray(data.subscriptions)`. Per UI-SPEC's Visual Hierarchy, render it as the FIRST child of the formatted `space-y-6` container (above the field groups) so it answers "what am I paying for" first. Structure mirrors the ticket Description block: an `

` reading "Subscriptions & Cost", then a `rounded-lg border overflow-hidden` container holding a compact table. Each subscription row shows: product label = `sub.productName || sub.sku || 'Unknown item'` (Pitfall 4 fallback), quantity, billing term (or '—' when null), and the amount `sub.latestBilledAmount` — formatted as currency (e.g. `sub.currency ?? 'USD'` + `Number(latestBilledAmount).toFixed(2)`) in `font-mono tabular-nums` (UI-SPEC numerics rule); NEVER recompute from unit_price×quantity (Pitfall 3). Render a header row (Product / Qty / Term / Amount) and a final total row summing `latestBilledAmount` across the array, also font-mono tabular-nums. Use the same `text-xs`/`text-muted-foreground`/`Separator`-or-border-row conventions already used in this file — do not introduce a new visual language (UI-SPEC: match rounded-border card look). Guard the empty array (`data.subscriptions.length === 0`) with a muted "No subscriptions" line inside the same bordered container. The Raw tab needs no change — `data.subscriptions` already serializes as JSON through the existing object branch of `renderRaw`. + + + npx tsc --noEmit --pretty + + + - A Formatted-tab section guarded by `Array.isArray(data.subscriptions)` exists (grep confirms) and renders before the field-group map + - Row amounts render `latestBilledAmount` in `font-mono` with `tabular-nums` and there is a summed total row (grep: `tabular-nums` present; no `unit_price` recomputation in the render) + - Product label uses the `productName || sku || 'Unknown item'` fallback chain + - Empty subscriptions array shows a "No subscriptions" state, not a crash + - The ticket "Description block" and all other Formatted-tab logic remain present and unchanged + - `npx tsc --noEmit --pretty` passes + + The Formatted tab renders a styled subscriptions/cost-breakdown table with a total whenever data.subscriptions is an array; additive-only. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| API JSON → React render | DetailModal renders DB-sourced company/product/subscription strings client-side | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-06 | Tampering (stored XSS) | DetailModal rendering product/company names from Postgres | mitigate | Values rendered as React text children (JSX `{value}`), which auto-escapes — no `dangerouslySetInnerHTML`, no raw HTML injection path introduced | +| T-14-07 | Denial of Service | subscriptions array size in the modal | accept | Per-company subscription counts are small (max seen ~tens); order-item history is server-scoped in Plan 01, not passed to the modal — no unbounded client render | +| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages; only existing shadcn/lucide primitives used | + + + +- `npx tsc --noEmit --pretty` passes +- Manual (Plan 06): open a PAX8 company drill-down and confirm the cost table renders above the identity fields with a correct total, and that an existing ticket/company DetailModal still renders identically + + + +- `kind` prop + `PAX8_COMPANY_GROUPS` added; existing constants and detection branches untouched +- Subscriptions cost-breakdown table + total renders when `data.subscriptions` is an array, using `latestBilledAmount` (never recomputed) +- Type check green; change is additive + + + +Create `.planning/phases/14-pax8-ui-surface/14-03-SUMMARY.md` when done + diff --git a/.planning/phases/14-pax8-ui-surface/14-04-PLAN.md b/.planning/phases/14-pax8-ui-surface/14-04-PLAN.md new file mode 100644 index 0000000..75d97b8 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-04-PLAN.md @@ -0,0 +1,193 @@ +--- +phase: 14-pax8-ui-surface +plan: 04 +type: execute +wave: 2 +depends_on: ["14-01", "14-03"] +files_modified: + - app/pax8/page.tsx + - components/navigation/app-navigation.tsx +autonomous: true +requirements: [PAX8-13] +must_haves: + truths: + - "Navigating to /pax8 renders a page with a PageHeader and a Companies / Needs Review Tabs bar" + - "The Companies tab shows a DataTable of PAX8 companies (name, matched Autotask company or Unmatched badge, active subscription count, city/country) with sort/search/pagination" + - "Clicking a company row fetches its drill-down and opens the extended DetailModal showing the subscriptions & cost breakdown" + - "A top-level PAX8 nav entry appears for all authenticated users on desktop and mobile" + artifacts: + - path: "app/pax8/page.tsx" + provides: "Companies tab (DataTable + DetailModal drill-down) + tab shell with a Needs Review placeholder" + min_lines: 120 + - path: "components/navigation/app-navigation.tsx" + provides: "Top-level PAX8 navigationItems entry" + contains: "'/pax8'" + key_links: + - from: "app/pax8/page.tsx" + to: "/api/pax8/companies" + via: "fetch in a load function" + pattern: "fetch\\(`?/api/pax8/companies" + - from: "app/pax8/page.tsx row click" + to: "/api/pax8/companies/[id]" + via: "fetch-then-open DetailModal" + pattern: "/api/pax8/companies/" + - from: "app/pax8/page.tsx" + to: "DetailModal" + via: "kind='pax8_company'" + pattern: "kind=\"pax8_company\"" +--- + + +Create `app/pax8/page.tsx` as a top-level client page: a `PageHeader` + a `Companies` / `Needs Review` `Tabs` shell, with the Companies tab fully implemented (a `DataTable` of PAX8 companies whose row-click fetches the drill-down and opens the extended `DetailModal`). Leave a clearly-marked `Needs Review` `TabsContent` placeholder for Plan 05 to fill. Add the top-level `PAX8` nav entry. + +Purpose: PAX8-13 SC#1-2 — `/pax8` lists companies with subscriptions and a per-company cost breakdown. This plan delivers the list + drill-down half and the page skeleton both tabs share. Interface-first: the tab shell and shared state land here so Plan 05 only implements the second tab. +Output: `app/pax8/page.tsx` (Companies tab live, Needs Review stubbed) + nav entry. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md +@.planning/phases/14-pax8-ui-surface/14-PATTERNS.md +@.planning/phases/14-pax8-ui-surface/14-UI-SPEC.md +@.planning/phases/14-pax8-ui-surface/14-CONTEXT.md +@.planning/phases/14-01-SUMMARY.md + + + + +GET /api/pax8/companies?page&limit&sort&order&search (Plan 01) → + { items: Array<{ id, name, status, city, stateOrProvince, country, + autotaskCompanyId, matchConfidence, matchMethod, + matchedCompanyName, activeSubscriptionCount }>, + total, limit, offset } + Note: route pages by limit/offset — convert the DataTable 1-based `page` to `offset = (page-1)*pageSize`. + Valid `sort` keys: name | status | city | country | subscriptions | match. + +GET /api/pax8/companies/[id] (Plan 01) → + { company: { id, name, status, city, stateOrProvince, country, website, + autotaskCompanyId, matchedCompanyName, matchMethod, matchConfidence, + syncedAt, isDeleted }, + subscriptions: Array<{ subscriptionId, productName, sku, quantity, billingTerm, + status, currency, latestBilledAmount, startPeriod }>, + costTotal } + +DetailModal (Plan 03, components/admin/DetailModal.tsx, default export): + — pass kind="pax8_company", + data = { ...company, subscriptions, costTotal }. + +DataTable (components/admin/DataTable.tsx, default export) — manual mode: + columns: Array<{ key, label, sortable?, render?(value,row) }> + props: data, totalCount, page, pageSize, onPageChange, onSort(col,dir), + onSearch(q), onRowClick(row), isLoading + onSort receives the column `key`; map those keys to the API `sort` values above. + +PageHeader (components/navigation/page-header.tsx): + + +Page shell reference: app/engagement/page.tsx (Tabs) + app/admin/data-browser/companies/page.tsx (DataTable+DetailModal composition). + +Nav (components/navigation/app-navigation.tsx): navigationItems is a flat array of + { title, href?, icon?, description?, children? }. The visibleItems filter only special-cases + titles 'Engagement' and 'Admin' (super-admin only). A top-level entry with any other title is + visible to all authenticated users — exactly what D-07 requires. Both desktop NavigationMenu and + mobile-nav.tsx consume this same array. Pick a lucide icon not already used at top level + (ShoppingCart or CreditCard). + + + + + + + Task 1: /pax8 page shell + Companies tab (DataTable + DetailModal drill-down) + + - app/engagement/page.tsx (Tabs page-shell pattern: 'use client', container, PageHeader/h1, Tabs/TabsList/TabsTrigger/TabsContent, activeTab state) + - app/admin/data-browser/companies/page.tsx (DataTable + DetailModal composition: fetch function, columns array, handleRowClick, page/pageSize/totalCount state) + - components/admin/DataTable.tsx (Column shape + manual-mode props + onSort/onSearch/onRowClick/onPageChange contract) + - components/admin/DetailModal.tsx (props including the new kind prop from Plan 03) + - components/navigation/page-header.tsx (PageHeader props) + - .planning/phases/14-pax8-ui-surface/14-UI-SPEC.md (Copywriting: tab labels 'Companies'/'Needs Review'; Companies empty-state copy; column set; Color: matched-company name as a link, Unmatched badge) + - .planning/phases/14-01-SUMMARY.md (final response shapes if they differ from the interfaces block) + + app/pax8/page.tsx + + Create a `'use client'` page component `Pax8Page`. Layout: `PageHeader title="PAX8" description="PAX8 companies, subscriptions, and cost breakdown" breadcrumbs={[{ label: 'PAX8' }]} accent` then a `container mx-auto px-6 py-6 space-y-6` wrapper holding a `Tabs` with `TabsList` triggers `Companies` (value `companies`) and `Needs Review` (value `needs-review`), controlled by an `activeTab` state defaulting to `companies`. Implement the Companies `TabsContent` fully: state `companies`, `totalCount`, `page` (1-based), `pageSize` (e.g. 25), `isLoading`, plus `selectedCompany` and `modalOpen`. A `fetchCompanies(page, search?, sort?, order?)` builds a query string translating `page`→`offset=(page-1)*pageSize` and mapping the DataTable column key to the API `sort` value (name/status/city/country/subscriptions/match), fetches `/api/pax8/companies`, sets `companies`/`totalCount`. Define a `columns` array: `name` (sortable), a `matched` column rendering `row.matchedCompanyName` as a primary-colored link-styled span when set else an `Unmatched` (UI-SPEC Color), `activeSubscriptionCount` (label 'Subs', sortable, font-mono tabular-nums), and a `location` column showing `city, country` (or `stateOrProvince`). `onRowClick` = a `handleRowClick(row)` that FETCHES `/api/pax8/companies/${row.id}` FIRST (fetch-then-open, per PATTERNS.md — keep loading visible on the row, not the dialog), then sets `selectedCompany` to `{ ...resp.company, subscriptions: resp.subscriptions, costTotal: resp.costTotal }` and opens the modal. Render `` with `emptyTitle="No PAX8 companies synced yet"` and `emptyDescription="Run the PAX8 sync from /admin/integrations, then refresh this page."` (UI-SPEC copy). Render ``. For the `needs-review` `TabsContent`, insert a placeholder comment `{/* Needs Review tab implemented in Plan 14-05 */}` plus a minimal muted "Loading…"/empty node so the tab is not blank — Plan 05 replaces this block. Match error/loading handling to the surrounding codebase (try/catch, console.error, isLoading toggles). No SWR/react-query — useState/useEffect/fetch only (CLAUDE.md). + + + npx tsc --noEmit --pretty + + + - `app/pax8/page.tsx` is `'use client'` and renders a `Tabs` with `Companies` and `Needs Review` triggers (grep: both `value="companies"` and `value="needs-review"`) + - Companies tab fetches `/api/pax8/companies` and renders a `DataTable` with columns including matched-company (link or Unmatched badge) and active subscription count + - Row click fetches `/api/pax8/companies/${id}` before opening `DetailModal` with `kind="pax8_company"` (grep: `kind="pax8_company"` present) + - The Needs Review `TabsContent` exists as a marked placeholder (grep: `Plan 14-05` comment) — not blank, not implemented + - No SWR/react-query import; state via useState/useEffect + - `npx tsc --noEmit --pretty` passes + + /pax8 renders the tab shell; the Companies tab lists companies and opens a cost-breakdown drill-down; Needs Review is a stub for Plan 05. + + + + Task 2: Add top-level PAX8 nav entry + + - components/navigation/app-navigation.tsx (the navigationItems array lines ~50-195 and the visibleItems filter lines ~211-217 — confirm only 'Engagement'/'Admin' are role-filtered) + - .planning/phases/14-pax8-ui-surface/14-PATTERNS.md (nav section: top-level, non-nested, icon choice) + + components/navigation/app-navigation.tsx + + Add ONE new object to the top-level `navigationItems` array (not inside any `children`): `{ title: 'PAX8', href: '/pax8', icon: , description: 'PAX8 companies, subscriptions, and cost breakdown' }`. Choose an icon not already used at the top level (e.g. `ShoppingCart` or `CreditCard`) and add it to the existing `lucide-react` import. Place it near the other top-level operational entries (e.g. after `Configuration Items`). Do NOT modify the `visibleItems` filter — leaving `PAX8` out of the `'Engagement' || 'Admin'` special-case means it is visible to all authenticated users (D-07). Do NOT edit `components/navigation/mobile-nav.tsx` — it consumes the same array. + + + npx tsc --noEmit --pretty + + + - `navigationItems` contains a top-level entry with `href: '/pax8'` and `title: 'PAX8'` (grep: `'/pax8'` present in app-navigation.tsx) + - The chosen lucide icon is imported and not previously used at the top level + - The `visibleItems` filter is unchanged (PAX8 not added to the Engagement/Admin super-admin gate) + - `npx tsc --noEmit --pretty` passes + + A top-level PAX8 nav item links to /pax8 and is visible to every authenticated user on both desktop and mobile. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser → /api/pax8/companies* | Client page fetches list + drill-down data (server routes already gated in Plan 01) | +| DB JSON → React render | Company/subscription strings rendered in DataTable + DetailModal | +| nav visibility | Nav entry visible to all authenticated users by design (D-07); no data leak — the routes themselves enforce auth | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-03 | Information Disclosure | /pax8 page data fetches | mitigate | Page relies on Plan 01's `requireAuth()` gates; `/pax8` is a normal (non-public) route so middleware requires a session cookie — do not add `/pax8` to middleware.ts publicRoutes | +| T-14-06 | Tampering (XSS) | DataTable/DetailModal rendering DB strings | mitigate | React text-node auto-escaping; matched-company "link" is a styled span/anchor with no user-controlled href scheme | +| T-14-08 | Information Disclosure | nav entry exposing existence of /pax8 to all roles | accept | D-07 intentionally makes the page manager-visible; the sensitive mutation is separately admin-gated (Plan 02). Nav visibility ≠ data access | +| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages; only existing components/icons used | + + + +- `npx tsc --noEmit --pretty` passes +- Manual (Plan 06): /pax8 loads for an authenticated user, Companies tab lists real companies, a row opens the cost-breakdown modal, PAX8 appears in the nav + + + +- Page shell with both tabs; Companies tab fully functional (list + drill-down) +- Needs Review tab is a marked stub for Plan 05 +- Top-level PAX8 nav entry visible to all authenticated users +- Type check green + + + +Create `.planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md` when done + diff --git a/.planning/phases/14-pax8-ui-surface/14-05-PLAN.md b/.planning/phases/14-pax8-ui-surface/14-05-PLAN.md new file mode 100644 index 0000000..0519576 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-05-PLAN.md @@ -0,0 +1,218 @@ +--- +phase: 14-pax8-ui-surface +plan: 05 +type: execute +wave: 3 +depends_on: ["14-02", "14-04"] +files_modified: + - app/pax8/page.tsx + - app/api/data/companies-list/route.ts +autonomous: true +requirements: [PAX8-14, PAX8-12] +must_haves: + truths: + - "The Needs Review tab lists every unresolved flagged/ambiguous PAX8 company match in amber-bordered cards, distinct from the Companies list" + - "Each card offers a 'Link company' button per stored top-3 candidate AND a manual company-search combobox fallback" + - "A card with an empty candidate list shows the 'No suggested matches — search manually' empty state (D-09) with only the manual picker" + - "Clicking a resolve action POSTs to the admin-gated resolve route; on success the card disappears, a success toast shows, and the count badge decrements" + - "The Needs Review trigger shows a count badge when there are open reviews" + artifacts: + - path: "app/pax8/page.tsx" + provides: "Needs Review tab: review cards, candidate resolve buttons, manual-search combobox, count badge, empty/error/loading states" + contains: "company-matches" + - path: "app/api/data/companies-list/route.ts" + provides: "requireAuth-hardened companies-list used by the manual-search fallback" + exports: ["GET"] + key_links: + - from: "app/pax8/page.tsx Needs Review tab" + to: "/api/pax8/company-matches" + via: "fetch on tab load" + pattern: "/api/pax8/company-matches" + - from: "app/pax8/page.tsx resolve handler" + to: "/api/pax8/company-matches/[id]/resolve" + via: "POST { companyId, note? }" + pattern: "/resolve" + - from: "manual-search combobox" + to: "/api/data/companies-list" + via: "fetch-once + client-side filter" + pattern: "/api/data/companies-list" +--- + + +Fill in the `Needs Review` tab stubbed by Plan 04: an amber-bordered card list (mirroring `device-link-conflicts`) of unresolved PAX8 company matches, each offering per-candidate "Link company" buttons plus a manual company-search combobox fallback (D-05), with the D-09 zero-candidate empty state, count badge, and resolve wiring to the admin-gated route. Also harden `/api/data/companies-list` with `requireAuth()` since this phase makes it a data source for authenticated UI. + +Purpose: PAX8-14 (surface flagged matches for resolution) and PAX8-12's UI half (admin resolves them). This tab is the manual-resolution workflow that lets an admin fix the 38 open reviews without psql, satisfying roadmap SC#3-4. +Output: completed `app/pax8/page.tsx` Needs Review tab + auth-hardened companies-list route. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md +@.planning/phases/14-pax8-ui-surface/14-PATTERNS.md +@.planning/phases/14-pax8-ui-surface/14-UI-SPEC.md +@.planning/phases/14-pax8-ui-surface/14-CONTEXT.md +@.planning/phases/14-02-SUMMARY.md +@.planning/phases/14-04-SUMMARY.md + + + + +GET /api/pax8/company-matches?limit&offset (Plan 02) → + { items: Array<{ id, detectedAt, pax8CompanyId, pax8CompanyName, + candidates: Array<{ companyId, companyName, confidence }> }>, + total, limit, offset } + +POST /api/pax8/company-matches/[id]/resolve (Plan 02, admin-gated) → + body { companyId: number, note?: string } + 200 { ok: true, resolvedToCompanyId } | 400/403/404/409 { error } + +GET /api/data/companies-list → [{ id, company_name }, ...] (all active companies, ~242 rows, no pagination) + This plan adds requireAuth() to it. + +Existing page (Plan 04): app/pax8/page.tsx already has the Tabs shell, activeTab state, +and a marked `{/* Needs Review tab implemented in Plan 14-05 */}` placeholder in the +needs-review TabsContent. This plan replaces that block; it also adds a count badge to the +needs-review TabsTrigger. + +Precedent to mirror: app/admin/device-link-conflicts/page.tsx + - amber card: with an AlertTriangle text-amber-500 icon + - resolve(reviewId, ...) handler: POST, on !res.ok throw, toast.success/toast.error, + optimistic removal setItems(prev => prev.filter(...)) + setTotal(t => t-1) + - error Alert (variant="destructive"), Skeleton loading trio, empty-state Alert with CheckCircle2 + +Combobox: shadcn Command + Popover (both already vendored in components/ui/). Fetch +/api/data/companies-list once on first tab open, filter client-side by company_name. + +UI-SPEC copy (Copywriting Contract): + - candidate button: "Link company"; manual path button: "Link to selected company" + - empty (no open reviews): heading "No companies need review", body + "Every synced PAX8 company is matched to an Autotask company." + - per-row zero-candidate (D-09): heading "No suggested matches", body + "Search manually to link this company to its Autotask counterpart." + - error: heading "Couldn't load PAX8 data", body includes the /admin/integrations hint + - success toast: "Linked to {companyName}"; failure toast: err.message ?? 'Resolve failed' + - tab count badge: show open-review total when > 0 +Color: amber border/icon = status hue (NOT the primary accent); "Link company"/"Link to +selected company" buttons + focus rings use --primary. + + + + + + + Task 1: Harden /api/data/companies-list with requireAuth() + + - app/api/data/companies-list/route.ts (full 15-line file — current GET has no auth) + - lib/auth-utils.ts (requireAuth) + - app/admin/display-settings/page.tsx (the only other consumer — an admin page already behind auth, so adding requireAuth is safe) + + app/api/data/companies-list/route.ts + + Add `const { error } = await requireAuth(); if (error) return error;` as the first statement of the `GET` handler, importing `requireAuth` from `@/lib/auth-utils`. Change the signature to accept the request if needed (requireAuth reads headers internally, so no request arg is required — keep `GET()` as-is and just call requireAuth). Leave the query and response shape unchanged (`[{ id, company_name }]`). This closes the gap RESEARCH.md flagged (D-08's "every route" spirit) now that this route feeds an authenticated UI's manual-search fallback; the sole other consumer is an authenticated admin page, so no caller breaks. + + + npx tsc --noEmit --pretty + + + - `app/api/data/companies-list/route.ts` GET calls `requireAuth()` and returns `error` before querying (grep: `requireAuth`) + - Response shape unchanged: array of `{ id, company_name }` + - `npx tsc --noEmit --pretty` passes + + companies-list requires an authenticated session; unauthenticated callers get 401. + + + + Task 2: Needs Review tab — review cards + candidate resolve + count badge + + - app/pax8/page.tsx (the Plan 04 shell — locate the needs-review placeholder and the TabsTrigger to add the badge to; reuse its error/loading conventions) + - app/admin/device-link-conflicts/page.tsx (full file — amber Card layout, resolve handler with optimistic removal + toasts, Skeleton/Alert/empty-state trio to copy) + - .planning/phases/14-02-SUMMARY.md (final /api/pax8/company-matches + resolve response shapes) + - .planning/phases/14-pax8-ui-surface/14-UI-SPEC.md (Copywriting, Color amber-vs-primary, Visual Hierarchy: amber cards are the focal point) + + app/pax8/page.tsx + + Replace the needs-review placeholder with a full implementation. Add state: `reviews` (`Review[] | null`), `reviewTotal`, `reviewError`, `resolving` (a string key like `${reviewId}:${companyId}` or null). On first activation of the needs-review tab (or on mount), `fetch('/api/pax8/company-matches?limit=100')`, set `reviews`/`reviewTotal`; handle error into `reviewError`. Render the device-link-conflicts trio: destructive `Alert` on error (heading "Couldn't load PAX8 data", body with the /admin/integrations hint), `Skeleton` list while `reviews === null`, and an empty-state `Alert` with `CheckCircle2` (heading "No companies need review", body "Every synced PAX8 company is matched to an Autotask company.") when the list is empty. For each review render `` with an `AlertTriangle className="text-amber-500"` header showing `pax8CompanyName`. Inside, list each candidate as a row with its `companyName` + a `Badge` showing `confidence`, and a primary "Link company" `Button` that calls `resolve(reviewId, candidate.companyId, candidate.companyName)`. The `resolve(reviewId, companyId, companyName)` handler POSTs to `/api/pax8/company-matches/${reviewId}/resolve` with body `{ companyId }`; on non-ok throw with the parsed `error`; on success `toast.success(\`Linked to ${companyName}\`)`, optimistically remove the review (`setReviews(prev => prev?.filter(r => r.id !== reviewId) ?? null)`), and `setReviewTotal(t => Math.max(0, t-1))`; on failure `toast.error(err.message ?? 'Resolve failed')`; always clear `resolving`. Add a count badge to the `Needs Review` `TabsTrigger`: when `reviewTotal > 0`, render a small rounded badge with the number (mirror DetailModal's tab count-badge style referenced in UI-SPEC). Leave a clearly-marked insertion point inside each card for the manual-search combobox (Task 3). Amber = status hue only; the resolve buttons + focus rings use `--primary` (UI-SPEC Color). + + + npx tsc --noEmit --pretty + + + - Needs Review tab fetches `/api/pax8/company-matches` and renders amber-bordered cards (grep: `border-amber-200` and `/api/pax8/company-matches`) + - Each candidate has a "Link company" button whose handler POSTs to `.../resolve` with `{ companyId }` (grep: `/resolve`) + - On success the card is optimistically removed and `toast.success` fires with "Linked to {name}"; on failure `toast.error` fires + - The Needs Review `TabsTrigger` shows a count badge when `reviewTotal > 0` + - Error/loading/empty states use the device-link-conflicts Alert/Skeleton copy from UI-SPEC + - `npx tsc --noEmit --pretty` passes + + The Needs Review tab distinctly surfaces flagged matches and resolves them via candidate buttons against the admin-gated route, with a live count badge. + + + + Task 3: Manual company-search combobox fallback (D-05 / D-09) + + - app/pax8/page.tsx (the Task 2 card insertion point + resolve handler to reuse) + - components/ui/command.tsx and components/ui/popover.tsx (Command/Popover primitive APIs) + - .planning/phases/14-pax8-ui-surface/14-UI-SPEC.md (Copywriting: "Link to selected company", zero-candidate empty state; Color: Command focus ring uses --primary) + - .planning/phases/14-pax8-ui-surface/14-CONTEXT.md (D-05 dual path, D-09 empty-candidate handling) + + app/pax8/page.tsx + + Add a manual company picker to every review card, using shadcn `Command` inside a `Popover`. Fetch `/api/data/companies-list` once (on first needs-review tab open) into a shared `allCompanies` state (`{ id, company_name }[]`); guard against refetching. In each card render a `Popover` whose trigger is a `Button variant="outline"` reading the currently-selected company name or "Search company…", and whose content is a `Command` with a `CommandInput` filtering `allCompanies` by `company_name` (client-side filter — ~242 rows, no server search) and `CommandItem`s that set a per-card `selectedManualCompany` (track selection per review id, e.g. a `Record` or local card component state). Below the picker render a primary `Button` "Link to selected company" that is disabled until a company is chosen and calls the SAME `resolve(reviewId, selected.id, selected.name)` handler from Task 2. For the D-09 zero-candidate case (`candidates.length === 0`): render the empty-state copy inside the card (heading "No suggested matches", body "Search manually to link this company to its Autotask counterpart.") and show ONLY the manual picker + "Link to selected company" button (no candidate buttons). When candidates exist, show both the candidate "Link company" buttons AND the manual picker as an alternative. Do not add a new endpoint — reuse the resolve route; the resolver already accepts non-candidate company ids (Plan 02). + + + npx tsc --noEmit --pretty + + + - Each review card includes a `Command`/`Popover` combobox fed by `/api/data/companies-list` fetched once (grep: `/api/data/companies-list`) + - Manual selection enables a "Link to selected company" button that calls the shared resolve handler with the chosen company id + - A zero-candidate review renders the D-09 empty state ("No suggested matches") and shows ONLY the manual picker (no candidate buttons) + - When candidates exist, both candidate buttons and the manual picker are available + - `npx tsc --noEmit --pretty` passes + + Every review can be resolved via a stored candidate or a manual company search; zero-candidate rows resolve purely through the manual picker. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| browser → GET /api/pax8/company-matches | Authenticated read of the flagged queue (Plan 02 gate) | +| browser → POST .../resolve | Admin-only mutation (Plan 02 gate); non-admin clicks receive 403 and surface as a toast | +| browser → GET /api/data/companies-list | Manual-search data source — hardened to requireAuth in Task 1 | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-01 | Elevation of Privilege | resolve action reachable from a manager-visible tab | mitigate | The mutation is enforced server-side by Plan 02's `requirePermission('admin','access')`. The UI does not rely on hiding the button for security — a non-admin POST returns 403, surfaced via `toast.error`. (Optionally hide/disable the resolve controls for non-admins as UX, but server gate is the control) | +| T-14-05 | Information Disclosure | /api/data/companies-list feeding the manual search | mitigate | Task 1 adds `requireAuth()` to the previously-unauthenticated route now that it backs authenticated UI (closes RESEARCH.md-flagged gap) | +| T-14-02 | Tampering | manual picker submitting an arbitrary company id | mitigate | Accepted by design (D-05) but bounded server-side: Plan 02's resolver validates the id exists and is_active before writing — the client picker only offers active companies, and the server re-validates | +| T-14-06 | Tampering (XSS) | rendering flagged/candidate company names | mitigate | React text auto-escaping; no dangerouslySetInnerHTML | +| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages; Command/Popover already vendored | + + + +- `npx tsc --noEmit --pretty` passes +- Manual (Plan 06): resolve an ambiguous review via a candidate button and a zero-candidate review via manual search; confirm cards vanish, toasts fire, and the count badge decrements + + + +- Needs Review tab surfaces flagged matches distinctly (amber cards) with candidate + manual resolution paths and D-09 empty state +- companies-list route requires auth +- Resolve wiring hits the admin-gated route; success/failure feedback via toasts; live count badge +- Type check green + + + +Create `.planning/phases/14-pax8-ui-surface/14-05-SUMMARY.md` when done + diff --git a/.planning/phases/14-pax8-ui-surface/14-06-PLAN.md b/.planning/phases/14-pax8-ui-surface/14-06-PLAN.md new file mode 100644 index 0000000..4c54d42 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-06-PLAN.md @@ -0,0 +1,115 @@ +--- +phase: 14-pax8-ui-surface +plan: 06 +type: execute +wave: 4 +depends_on: ["14-05"] +files_modified: [] +autonomous: false +requirements: [PAX8-12, PAX8-13, PAX8-14] +must_haves: + truths: + - "All four Phase 14 success criteria are confirmed working against the live dev DB by a human click-through" + - "The view/resolve permission split is confirmed: a non-admin can view /pax8 but cannot resolve; an admin can resolve" + - "A resolved match persists in both tables and survives a subsequent PAX8 sync" + artifacts: [] + key_links: [] +--- + + +Human verification of the complete `/pax8` surface end-to-end against the live dev database, plus the automated gate. This is the phase's acceptance gate before `/gsd:verify-work`. + +Purpose: Phase 14 has no route/page automated test coverage (page components + `app/api/**` are outside vitest's `lib/**/*.test.ts` glob — matching the `device-link-conflicts` precedent, which also has zero automated tests). The one unit-tested piece is the resolver (Plan 02). Everything else is verified manually per the Validation Strategy. This plan runs the full suite, the type-check, and the documented click-through. +Output: confirmation of all four success criteria + the permission split + persistence across sync. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/14-pax8-ui-surface/14-VALIDATION.md +@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md +@.planning/phases/14-01-SUMMARY.md +@.planning/phases/14-02-SUMMARY.md +@.planning/phases/14-03-SUMMARY.md +@.planning/phases/14-04-SUMMARY.md +@.planning/phases/14-05-SUMMARY.md + + + + + + Task 1: Run the automated gates + + - .planning/phases/14-pax8-ui-surface/14-VALIDATION.md (Sampling Rate: tsc + npm test) + + + + Run `npx tsc --noEmit --pretty` (the only automated gate covering the new app/pax8 + app/api/pax8 files) and `npm test` (full vitest suite — must stay green; it exercises the Plan 02 resolver test at `lib/services/pax8-company-match-resolver.test.ts` and must not regress the analyzer/rmm/b2 suites). Both must pass with zero errors before the human checkpoint. If either fails, stop and report the failure rather than proceeding to the checkpoint. + + + npx tsc --noEmit --pretty && npm test + + + - `npx tsc --noEmit --pretty` exits 0 with no type errors + - `npm test` exits 0; `pax8-company-match-resolver.test.ts` is included and green + + Type-check and full test suite are green. + + + + Task 2: Verify Phase 14 SC#1-4 + permission split against the running app + + After the automated gates pass, drive the running dev app through the steps in how-to-verify below and confirm every Phase 14 success criterion plus the D-07/D-08 view/resolve permission split and sync-persistence. Do not mark this done until a human types "approved". + + + A new `/pax8` page (Companies + Needs Review tabs), four `/api/pax8/*` routes (company list, company drill-down, review queue, admin-gated resolve), an additively-extended `DetailModal` cost-breakdown view, a hardened `companies-list` route, and a top-level PAX8 nav entry. This checkpoint confirms all four Phase 14 success criteria and the D-07/D-08 permission split against the live dev DB (118 companies, 445 subscriptions, 38 open reviews). + + + 1. Start/confirm the dev app (`npm run dev`, http://localhost:3100) and sign in. + 2. Confirm a top-level "PAX8" item appears in the desktop nav (and mobile Sheet); click it → lands on `/pax8`. + 3. SC#1 — Companies tab: confirm a DataTable of PAX8 companies renders with name, matched Autotask company (or an "Unmatched" badge), active subscription count, and city/country; sort by a column and run a name search — both update the list. + 4. SC#2 — cost breakdown: click a matched company row → the DetailModal opens with a "Subscriptions & Cost" table (product, qty, billing term, amount) above the identity fields, showing a summed total; verify the number of line items is not suspiciously fewer than the company's active-subscription count (Pitfall 2 windowing), and amounts look like billed totals not list-price×qty (Pitfall 3). Check the Raw tab shows the underlying JSON. + 5. SC#3 — Needs Review tab: switch tabs; confirm ~38 amber-bordered review cards appear, visually distinct from the main list, and the tab shows a count badge. Confirm a card with candidates shows "Link company" buttons AND a manual search; confirm a zero-candidate card shows "No suggested matches" with only the manual picker. + 6. SC#4 (resolve + persist) — as an ADMIN: resolve one ambiguous review via a candidate button and one zero-candidate review via manual search; confirm each card disappears with a "Linked to {name}" toast and the count badge decrements. In psql confirm both resolved companies now have `pax8_companies.match_method = 'manual'` + `autotask_company_id` set AND `pax8_company_match_review.resolved_at IS NOT NULL`. Trigger a PAX8 sync (`POST /api/pax8/sync` or the scheduler) and confirm the two resolutions are NOT overwritten (still `match_method='manual'`, review still resolved). + 7. Permission split (D-07/D-08) — as a NON-ADMIN authenticated user: confirm `/pax8` and both tabs load and the drill-down works, but attempting a resolve is rejected (403 surfaced as an error toast; the row does not resolve). + + Type "approved" if all four success criteria + the permission split + sync-persistence hold, or describe any gap. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| verification only | This plan makes no code changes; it exercises the boundaries established in Plans 01-05 | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-14-01 | Elevation of Privilege | resolve route (verification) | mitigate | Step 7 explicitly exercises the non-admin path to confirm the 403 gate holds in practice, not just in code | +| T-14-02 | Tampering | resolve persistence across sync | mitigate | Step 6 confirms both write signals (match_method='manual' + resolved review) survive a real re-sync — the load-bearing correctness check | +| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages introduced across the phase | + + + +- `npx tsc --noEmit --pretty && npm test` green +- Human checkpoint confirms all four success criteria, the permission split, and sync-persistence + + + +- Automated gates green +- Human confirms: SC#1 list, SC#2 cost breakdown, SC#3 distinct review section, SC#4 admin resolve + persist-through-sync, and the D-07/D-08 view/resolve split + + + +Create `.planning/phases/14-pax8-ui-surface/14-06-SUMMARY.md` when done +