docs(14): create phase plan — /pax8 UI surface (6 plans, 4 waves)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
This commit is contained in:
parent
22fd417d17
commit
386528a011
7 changed files with 1141 additions and 2 deletions
|
|
@ -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*
|
||||
|
|
|
|||
209
.planning/phases/14-pax8-ui-surface/14-01-PLAN.md
Normal file
209
.planning/phases/14-pax8-ui-surface/14-01-PLAN.md
Normal file
|
|
@ -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\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Executor uses these directly — no codebase exploration needed. -->
|
||||
|
||||
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<T>(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 <whitelisted column> <dir>
|
||||
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.
|
||||
</interfaces>
|
||||
|
||||
# Do NOT reference other pax8 API routes — this plan's two routes are self-contained.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: GET /api/pax8/companies — paginated company list</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>app/api/pax8/companies/route.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>GET /api/pax8/companies returns a 200 paginated list for an authenticated user and 401 otherwise; sort/search are injection-safe.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: GET /api/pax8/companies/[id] — subscriptions + cost breakdown</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>app/api/pax8/companies/[id]/route.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md` when done
|
||||
</output>
|
||||
239
.planning/phases/14-pax8-ui-surface/14-02-PLAN.md
Normal file
239
.planning/phases/14-pax8-ui-surface/14-02-PLAN.md
Normal file
|
|
@ -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'"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Executor uses these directly. -->
|
||||
|
||||
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<T>(sql, params): Promise<{ rows: T[]; rowCount: number }>;
|
||||
postgresClient.transaction<T>(cb: (tx: PoolClient) => Promise<T>): Promise<T>;
|
||||
// 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: <T = any>(sql: string, params?: unknown[]) => Promise<{ rows: T[]; rowCount: number }> },
|
||||
params: { reviewId: string; companyId: number; note: string | null; userId: string | null }
|
||||
): Promise<ResolveResult>;
|
||||
|
||||
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() });
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: GET /api/pax8/company-matches — unresolved review queue</name>
|
||||
<read_first>
|
||||
- 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[])
|
||||
</read_first>
|
||||
<files>app/api/pax8/company-matches/route.ts</files>
|
||||
<action>
|
||||
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<number>` (coerce each with `Number`), bulk-fetch their names with `SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`, build a `Map<number,string>`. 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: resolvePax8CompanyMatch service + unit test (RED→GREEN)</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>lib/services/pax8-company-match-resolver.ts, lib/services/pax8-company-match-resolver.test.ts</files>
|
||||
<behavior>
|
||||
- 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)
|
||||
</behavior>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx vitest run lib/services/pax8-company-match-resolver.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: POST /api/pax8/company-matches/[id]/resolve — admin-gated route</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>app/api/pax8/company-matches/[id]/resolve/route.ts</files>
|
||||
<action>
|
||||
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)`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>POST resolve is admin-only, zod-validated, and delegates the two-table write to the tested resolver inside a transaction.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14-pax8-ui-surface/14-02-SUMMARY.md` when done
|
||||
</output>
|
||||
159
.planning/phases/14-pax8-ui-surface/14-03-PLAN.md
Normal file
159
.planning/phases/14-pax8-ui-surface/14-03-PLAN.md
Normal file
|
|
@ -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\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Shape the page (Plan 04) will pass into the extended DetailModal. -->
|
||||
DetailModal will be called by /pax8 as:
|
||||
<DetailModal open={...} onOpenChange={...} kind="pax8_company"
|
||||
title={`PAX8: ${company.name}`}
|
||||
data={{ ...companyHeaderFields, subscriptions: [...], costTotal }} />
|
||||
|
||||
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)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add kind prop + PAX8_COMPANY_GROUPS field set</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>components/admin/DetailModal.tsx</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>DetailModal accepts kind='pax8_company' and renders PAX8 identity/system fields; all existing callers unaffected.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add subscriptions & cost-breakdown array section to the Formatted tab</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>components/admin/DetailModal.tsx</files>
|
||||
<action>
|
||||
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 `<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">` 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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>The Formatted tab renders a styled subscriptions/cost-breakdown table with a total whenever data.subscriptions is an array; additive-only.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14-pax8-ui-surface/14-03-SUMMARY.md` when done
|
||||
</output>
|
||||
193
.planning/phases/14-pax8-ui-surface/14-04-PLAN.md
Normal file
193
.planning/phases/14-pax8-ui-surface/14-04-PLAN.md
Normal file
|
|
@ -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\""
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts this page consumes. Plans 01 + 03 must be complete. -->
|
||||
|
||||
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):
|
||||
<DetailModal open onOpenChange title data kind /> — 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):
|
||||
<PageHeader title description breadcrumbs={[{ label: 'PAX8' }]} accent />
|
||||
|
||||
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).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: /pax8 page shell + Companies tab (DataTable + DetailModal drill-down)</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>app/pax8/page.tsx</files>
|
||||
<action>
|
||||
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 `<Badge variant="secondary">Unmatched</Badge>` (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 `<DataTable columns data={companies} totalCount page pageSize onPageChange={setPage-then-refetch} onSort onSearch onRowClick isLoading />` with `emptyTitle="No PAX8 companies synced yet"` and `emptyDescription="Run the PAX8 sync from /admin/integrations, then refresh this page."` (UI-SPEC copy). Render `<DetailModal open={modalOpen} onOpenChange={setModalOpen} kind="pax8_company" title={\`PAX8: ${selectedCompany?.name ?? ''}\`} data={selectedCompany} />`. 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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>/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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add top-level PAX8 nav entry</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>components/navigation/app-navigation.tsx</files>
|
||||
<action>
|
||||
Add ONE new object to the top-level `navigationItems` array (not inside any `children`): `{ title: 'PAX8', href: '/pax8', icon: <lucide 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>A top-level PAX8 nav item links to /pax8 and is visible to every authenticated user on both desktop and mobile.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14-pax8-ui-surface/14-04-SUMMARY.md` when done
|
||||
</output>
|
||||
218
.planning/phases/14-pax8-ui-surface/14-05-PLAN.md
Normal file
218
.planning/phases/14-pax8-ui-surface/14-05-PLAN.md
Normal file
|
|
@ -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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts this tab consumes. Plans 02 + 04 must be complete. -->
|
||||
|
||||
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: <Card className="border-amber-200"> 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.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Harden /api/data/companies-list with requireAuth()</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>app/api/data/companies-list/route.ts</files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `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
|
||||
</acceptance_criteria>
|
||||
<done>companies-list requires an authenticated session; unauthenticated callers get 401.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Needs Review tab — review cards + candidate resolve + count badge</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>app/pax8/page.tsx</files>
|
||||
<action>
|
||||
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 `<Card className="border-amber-200">` 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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>The Needs Review tab distinctly surfaces flagged matches and resolves them via candidate buttons against the admin-gated route, with a live count badge.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Manual company-search combobox fallback (D-05 / D-09)</name>
|
||||
<read_first>
|
||||
- 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)
|
||||
</read_first>
|
||||
<files>app/pax8/page.tsx</files>
|
||||
<action>
|
||||
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<reviewId, {id,name}>` 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).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- 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
|
||||
</acceptance_criteria>
|
||||
<done>Every review can be resolved via a stored candidate or a manual company search; zero-candidate rows resolve purely through the manual picker.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14-pax8-ui-surface/14-05-SUMMARY.md` when done
|
||||
</output>
|
||||
115
.planning/phases/14-pax8-ui-surface/14-06-PLAN.md
Normal file
115
.planning/phases/14-pax8-ui-surface/14-06-PLAN.md
Normal file
|
|
@ -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: []
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.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
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Run the automated gates</name>
|
||||
<read_first>
|
||||
- .planning/phases/14-pax8-ui-surface/14-VALIDATION.md (Sampling Rate: tsc + npm test)
|
||||
</read_first>
|
||||
<files></files>
|
||||
<action>
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit --pretty && npm test</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `npx tsc --noEmit --pretty` exits 0 with no type errors
|
||||
- `npm test` exits 0; `pax8-company-match-resolver.test.ts` is included and green
|
||||
</acceptance_criteria>
|
||||
<done>Type-check and full test suite are green.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 2: Verify Phase 14 SC#1-4 + permission split against the running app</name>
|
||||
<action>
|
||||
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".
|
||||
</action>
|
||||
<what-built>
|
||||
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).
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
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).
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all four success criteria + the permission split + sync-persistence hold, or describe any gap.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## 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 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npx tsc --noEmit --pretty && npm test` green
|
||||
- Human checkpoint confirms all four success criteria, the permission split, and sync-persistence
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/14-pax8-ui-surface/14-06-SUMMARY.md` when done
|
||||
</output>
|
||||
Loading…
Add table
Add a link
Reference in a new issue