docs(14): record planning completion and pattern map
STATE.md now reflects Phase 14 as ready to execute (6 plans). Adds 14-PATTERNS.md (analog files + code excerpts) produced during planning, consumed by execute-phase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR
This commit is contained in:
parent
4a2230c457
commit
d2dfc341da
2 changed files with 438 additions and 7 deletions
|
|
@ -2,16 +2,16 @@
|
|||
gsd_state_version: 1.0
|
||||
milestone: v2.0
|
||||
milestone_name: PAX8 Integration
|
||||
status: planning
|
||||
status: executing
|
||||
stopped_at: Phase 14 UI-SPEC approved
|
||||
last_updated: "2026-07-11T17:25:07.470Z"
|
||||
last_activity: 2026-07-11
|
||||
last_updated: "2026-07-11T18:00:19.418Z"
|
||||
last_activity: 2026-07-11 -- Phase 14 planning complete
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 4
|
||||
total_plans: 14
|
||||
total_plans: 20
|
||||
completed_plans: 14
|
||||
percent: 80
|
||||
percent: 70
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
|
@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-07-10)
|
|||
|
||||
Phase: 14
|
||||
Plan: Not started
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-07-11
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-07-11 -- Phase 14 planning complete
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
|
||||
|
|
|
|||
431
.planning/phases/14-pax8-ui-surface/14-PATTERNS.md
Normal file
431
.planning/phases/14-pax8-ui-surface/14-PATTERNS.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# Phase 14: /pax8 UI Surface - Pattern Map
|
||||
|
||||
**Mapped:** 2026-07-11
|
||||
**Files analyzed:** 8 (1 page, 4 API routes, 1 extended component, 1 modified component, 1 nav edit)
|
||||
**Analogs found:** 8 / 8
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|--------------------|------|-----------|-----------------|---------------|
|
||||
| `app/pax8/page.tsx` | component (page) | request-response (list + drill-down) | `app/engagement/page.tsx` (Tabs shell) + `app/admin/data-browser/companies/page.tsx` (DataTable/DetailModal composition) + `app/admin/device-link-conflicts/page.tsx` (review/resolve cards) | exact (composite of 3 precedents, no single one covers the whole page) |
|
||||
| `app/api/pax8/companies/route.ts` | route (API) | CRUD (read, paginated/sortable) | `app/api/admin/device-link-conflicts/route.ts` (query shape) + `app/admin/data-browser/companies/page.tsx`'s consumed `/api/data/companies` (pagination/sort param contract) | role-match |
|
||||
| `app/api/pax8/companies/[id]/route.ts` | route (API) | CRUD (read, single-entity aggregate) | `app/api/admin/device-link-conflicts/route.ts` (bulk-fetch-then-map pattern) | role-match |
|
||||
| `app/api/pax8/company-matches/route.ts` | route (API) | CRUD (read, list with filter) | `app/api/admin/device-link-conflicts/route.ts` | exact |
|
||||
| `app/api/pax8/company-matches/[id]/resolve/route.ts` | route (API) | CRUD (write, transactional) | `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` | exact (with one required divergence — see Pattern 4 below) |
|
||||
| `components/admin/DetailModal.tsx` | component (modal, extended in place) | transform (render) | itself — additive extension only, see Pitfall/Pattern below | n/a (modification, not new file) |
|
||||
| `components/navigation/app-navigation.tsx` | component (nav config, edited) | n/a | itself — one array entry added | n/a (modification, not new file) |
|
||||
| `lib/services/pax8-company-match-resolver.ts` (optional, if extracting for testability) | service | CRUD (transactional write) | `lib/services/pax8-company-matcher.ts` (query/eligibility conventions) | role-match |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `app/pax8/page.tsx` (component/page, request-response)
|
||||
|
||||
**Analogs:** `app/engagement/page.tsx` (top-level Tabs page shell), `app/admin/data-browser/companies/page.tsx` (DataTable + DetailModal composition), `app/admin/device-link-conflicts/page.tsx` (review-card list + resolve action + Select filter + Skeleton/Alert states)
|
||||
|
||||
**Page shell + Tabs pattern** (`app/engagement/page.tsx` lines 597-644):
|
||||
```tsx
|
||||
'use client';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
...
|
||||
return (
|
||||
<div className="container mx-auto px-6 py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Employee Engagement</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">...</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">{/* actions */}</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'overview' | 'by-employee')} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="by-employee">By Employee</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-6">...</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
Use this shell for `/pax8`'s two tabs: `"companies"` / `"needs-review"`. Note `app/admin/device-link-conflicts/page.tsx` instead uses `<PageHeader>` (`components/navigation/page-header.tsx`) for its title block — either is acceptable; `PageHeader` gives breadcrumbs (`accent` prop) if the planner wants a heavier header, `engagement`'s inline `<h1>` is lighter. Given `/pax8` is a new top-level route (not nested under `/admin`), prefer the `PageHeader` pattern with `breadcrumbs={[{ label: 'PAX8' }]}` for consistency with how `device-link-conflicts` (its closest functional cousin) presents itself.
|
||||
|
||||
**DataTable + DetailModal composition** (`app/admin/data-browser/companies/page.tsx` lines 38-78, 166-216):
|
||||
```tsx
|
||||
const [companies, setCompanies] = useState([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedCompany, setSelectedCompany] = useState<any>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const fetchCompanies = async (currentPage: number, search?: string, sortBy?: string, sortOrder?: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: currentPage.toString(), limit: pageSize.toString() });
|
||||
if (search) params.append('search', search);
|
||||
if (sortBy) params.append('sort', sortBy);
|
||||
if (sortOrder) params.append('order', sortOrder);
|
||||
const response = await fetch(`/api/data/companies?${params}`);
|
||||
const result = await response.json();
|
||||
setCompanies(result.data || []);
|
||||
setTotalCount(result.pagination?.total || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch companies:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowClick = (company: any) => {
|
||||
setSelectedCompany(company);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// ... columns array with { key, label, sortable?, render? } ...
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={companies}
|
||||
totalCount={totalCount}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onSort={(column, direction) => fetchCompanies(page, undefined, column, direction)}
|
||||
onSearch={(query) => fetchCompanies(1, query)}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<DetailModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
title={`Company: ${selectedCompany?.name ?? selectedCompany?.id}`}
|
||||
data={selectedCompany}
|
||||
/>
|
||||
```
|
||||
For `/pax8`, `handleRowClick` should trigger a *second* fetch (`GET /api/pax8/companies/[id]`) to get subscriptions/cost-breakdown before opening the modal, since the list row won't carry the full drill-down payload (per research's Architecture Patterns 1-2 split). Fetch-then-open, not open-then-fetch-in-modal, keeps loading state visible on the row rather than inside the dialog.
|
||||
|
||||
**Review-list + resolve-action + empty/error states** (`app/admin/device-link-conflicts/page.tsx` lines 80-129, 172-196, 198-282):
|
||||
```tsx
|
||||
async function resolve(reviewId: string, ciId: string): Promise<void> {
|
||||
setResolving(`${reviewId}:${ciId}`);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/device-link-conflicts/${reviewId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ciId }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `Request failed: ${res.status}`);
|
||||
toast.success(`Linked to CI ${ciId}`);
|
||||
setItems((prev) => prev?.filter((r) => r.id !== reviewId) ?? null);
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Resolve failed');
|
||||
} finally {
|
||||
setResolving(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
Error/loading/empty-state trio to copy verbatim (swap copy text):
|
||||
```tsx
|
||||
{error && (
|
||||
<Alert variant="destructive"><AlertTitle>Failed to load</AlertTitle><AlertDescription>{error}</AlertDescription></Alert>
|
||||
)}
|
||||
{items === null && !error && (
|
||||
<div className="space-y-2"><Skeleton className="h-24 w-full" />...</div>
|
||||
)}
|
||||
{items !== null && items.length === 0 && !error && (
|
||||
<Alert><CheckCircle2 className="size-4" /><AlertTitle>No conflicts</AlertTitle><AlertDescription>Nothing waiting for review on this filter.</AlertDescription></Alert>
|
||||
)}
|
||||
```
|
||||
D-05's manual-search fallback and D-09's empty-candidate-array empty state ("No suggested matches — search manually") are new UI not present in `device-link-conflicts` (which has no manual-picker fallback) — build a `Command`/`Popover` combobox fed by `/api/data/companies-list` (see below), rendered inside each review card alongside (or instead of, when `candidate_company_ids` is empty) the candidate buttons.
|
||||
|
||||
---
|
||||
|
||||
### `app/api/pax8/companies/route.ts` (route, CRUD/read)
|
||||
|
||||
**Analog:** `app/api/admin/device-link-conflicts/route.ts` (query construction + auth placement), consumed-contract shape from `app/admin/data-browser/companies/page.tsx`'s `/api/data/companies` (page/limit/sort/order params + `{ data, pagination: { total } }` response envelope — verify actual response shape against that route before matching exactly, this phase's route can use either envelope shape but should be internally consistent)
|
||||
|
||||
**Auth pattern** — D-07 requires only `requireAuth()`, NOT `requirePermission()` (this file's routes must NOT copy `device-link-conflicts`' `requirePermission('admin','access')` for the read routes):
|
||||
```typescript
|
||||
import { requireAuth } from '@/lib/auth-utils';
|
||||
export async function GET(request: NextRequest) {
|
||||
const { error } = await requireAuth();
|
||||
if (error) return error;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Paginated/parameterized query pattern** (`app/api/admin/device-link-conflicts/route.ts` lines 40-55):
|
||||
```typescript
|
||||
const url = request.nextUrl;
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200);
|
||||
const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0);
|
||||
const params: unknown[] = [limit, offset];
|
||||
```
|
||||
|
||||
**Company list join** (verified live against schema — RESEARCH.md Pattern 1, adapted from `scripts/verify-pax8-orders-matching.ts` lines 76-85):
|
||||
```sql
|
||||
SELECT
|
||||
pc.id, pc.name, pc.status, pc.city, pc.state_or_province, pc.country,
|
||||
pc.autotask_company_id, pc.match_confidence, pc.match_method,
|
||||
c.company_name AS matched_company_name,
|
||||
(SELECT count(*) FROM pax8_subscriptions s
|
||||
WHERE s.pax8_company_id = pc.id AND s.is_deleted = false
|
||||
AND s.status = 'Active') AS active_subscription_count
|
||||
FROM pax8_companies pc
|
||||
LEFT JOIN companies c ON c.id = pc.autotask_company_id
|
||||
WHERE pc.is_deleted = false
|
||||
ORDER BY pc.name
|
||||
LIMIT $1 OFFSET $2;
|
||||
```
|
||||
|
||||
**Response transform (snake_case → camelCase)** — no existing file does this exact shape for pax8, but every route in the codebase manually maps in the handler, e.g. `app/api/admin/device-link-conflicts/route.ts` lines 103-130:
|
||||
```typescript
|
||||
const items = reviews.rows.map((r) => ({
|
||||
id: r.id,
|
||||
detectedAt: r.detected_at,
|
||||
// ...
|
||||
}));
|
||||
return NextResponse.json({ items, total, limit, offset });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `app/api/pax8/companies/[id]/route.ts` (route, CRUD/read, aggregate)
|
||||
|
||||
**Analog:** `app/api/admin/device-link-conflicts/route.ts`'s bulk-fetch-then-map pattern (lines 74-92) for the two-step "current subscriptions" + "latest order-item per subscription" join described in RESEARCH.md Pattern 2. No exact single-entity aggregate precedent exists elsewhere in the codebase for pax8 — this is a genuinely new query shape, follow the two-SQL-then-join-in-JS approach RESEARCH.md's Pattern 2 lays out rather than a single mega-query, to keep the per-subscription `DISTINCT ON` windowing (Pitfall 2) legible:
|
||||
```sql
|
||||
-- Step 1
|
||||
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 (per-subscription windowed latest, NOT a single MAX(start_period))
|
||||
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;
|
||||
```
|
||||
Auth: `requireAuth()` only (D-07) — same as the list route.
|
||||
|
||||
---
|
||||
|
||||
### `app/api/pax8/company-matches/route.ts` (route, CRUD/read)
|
||||
|
||||
**Analog:** `app/api/admin/device-link-conflicts/route.ts` — near-exact structural match, entire file (34-133) is the template.
|
||||
|
||||
**Auth** — D-07: view is `requireAuth()`, NOT `requirePermission('admin','access')` (this is the one place this phase's routes diverge from `device-link-conflicts`, which gates its GET admin-only — CONTEXT.md D-08 explicitly calls this out as deliberate).
|
||||
|
||||
**Bulk-fetch-candidates query** (adapted, `app/api/admin/device-link-conflicts/route.ts` lines 56-92, RESEARCH.md Pattern 3):
|
||||
```typescript
|
||||
const reviews = await postgresClient.query(
|
||||
`SELECT r.id::text, r.detected_at::text, r.candidate_company_ids,
|
||||
r.match_confidences,
|
||||
pc.id AS pax8_company_id, pc.name AS pax8_company_name
|
||||
FROM pax8_company_match_review r
|
||||
JOIN pax8_companies pc ON pc.id = r.pax8_company_id
|
||||
WHERE r.resolved_at IS NULL
|
||||
ORDER BY r.detected_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
const allCandidateIds = new Set<number>();
|
||||
for (const r of reviews.rows) for (const id of r.candidate_company_ids ?? []) allCandidateIds.add(Number(id));
|
||||
const companyNames = await postgresClient.query(
|
||||
`SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`,
|
||||
[Array.from(allCandidateIds)]
|
||||
);
|
||||
```
|
||||
Note the type divergence flagged in RESEARCH.md: `candidate_company_ids` is `BIGINT[]`, not `UUID[]` like `device_link_review.candidate_ci_ids` — no `::text[]` cast needed on the array column itself in the SELECT.
|
||||
|
||||
---
|
||||
|
||||
### `app/api/pax8/company-matches/[id]/resolve/route.ts` (route, CRUD/write, transactional)
|
||||
|
||||
**Analog:** `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` — full file is the template, with one required divergence (see below).
|
||||
|
||||
**Auth pattern (D-08 — copy exactly, this is the one route that DOES match device-link-conflicts' gating):**
|
||||
```typescript
|
||||
const { session, error } = await requirePermission('admin', 'access');
|
||||
if (error) return error;
|
||||
```
|
||||
|
||||
**Zod validation pattern** (lines 18-21, adapt field names):
|
||||
```typescript
|
||||
const ResolveBody = z.object({
|
||||
companyId: z.number().int().positive(),
|
||||
note: z.string().max(500).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
**Transaction + row-lock pattern** (lines 51-98) — copy the `FOR UPDATE` + already-resolved 409 guard verbatim:
|
||||
```typescript
|
||||
return postgresClient.transaction(async (tx) => {
|
||||
const reviewRes = await tx.query(
|
||||
`SELECT pax8_company_id, candidate_company_ids, resolved_at
|
||||
FROM pax8_company_match_review WHERE id = $1 FOR UPDATE`,
|
||||
[id]
|
||||
);
|
||||
if (reviewRes.rowCount === 0) return NextResponse.json({ error: 'Review not found' }, { status: 404 });
|
||||
if (reviewRes.rows[0].resolved_at) return NextResponse.json({ error: 'Already resolved' }, { status: 409 });
|
||||
// DIVERGENCE: device-link-conflicts validates ciId is in candidate_ci_ids here
|
||||
// and 400s otherwise. DO NOT copy that check — D-05 requires accepting any
|
||||
// valid Autotask companyId, not just candidates (manual-search fallback).
|
||||
// Instead: validate companyId exists in `companies` (and is_active) before writing.
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
**Required two-table write (the actual divergence from the analog — device-link-conflicts only writes one other table):**
|
||||
```typescript
|
||||
await tx.query(
|
||||
`UPDATE pax8_companies
|
||||
SET autotask_company_id = $2, match_confidence = NULL,
|
||||
match_method = 'manual', matched_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[reviewRes.rows[0].pax8_company_id, companyId]
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE pax8_company_match_review
|
||||
SET resolved_at = NOW(), resolved_by_user_id = $2,
|
||||
resolved_to_company_id = $3, resolution_note = $4
|
||||
WHERE id = $1`,
|
||||
[id, session?.user?.id ?? null, companyId, note ?? null]
|
||||
);
|
||||
```
|
||||
This is load-bearing: `lib/services/pax8-company-matcher.ts` lines 216-231 excludes companies from re-matching via `c.match_method IS DISTINCT FROM 'manual'` AND a `NOT EXISTS` against resolved review rows — both signals must be set or the resolution won't "stick" against the next sync (D-06's persistence requirement).
|
||||
|
||||
**Anti-pattern to avoid** (explicit — matches `device-link-conflicts`' own candidate-membership check, which must NOT be copied here): don't restrict `companyId` to `candidate_company_ids` membership. Validate existence/active-state in `companies` instead.
|
||||
|
||||
---
|
||||
|
||||
### `components/admin/DetailModal.tsx` (component, extended in place — not a new file)
|
||||
|
||||
**This is a modification, not a from-scratch pattern.** Read in full; confirmed structure:
|
||||
|
||||
- `detectGroups(data)` (lines 256-260) sniffs field presence to pick a group set:
|
||||
```typescript
|
||||
function detectGroups(data: Record<string, any>): FieldGroup[] {
|
||||
if ('ticket_number' in data) return TICKET_GROUPS;
|
||||
if ('company_name' in data) return COMPANY_GROUPS;
|
||||
return [{ label: 'Fields', fields: Object.keys(data).map(k => ({ key: k, label: k })) }];
|
||||
}
|
||||
```
|
||||
A PAX8 company row has `name`, not `company_name` — it will silently fall into the flat unstyled fallback branch today. Required additive change: add a `kind?: 'ticket' | 'company' | 'pax8_company'` prop to `DetailModalProps` (line 266-272) so the new page passes `kind="pax8_company"` explicitly instead of relying on field-name sniffing, and branch `detectGroups`/the header block on that prop when present (fall back to existing sniffing when absent, so `TICKET_GROUPS`/`COMPANY_GROUPS`/every other current caller is untouched).
|
||||
|
||||
- Header block also branches on `'ticket_number' in data` (lines 348-375) — same pattern, needs a `kind`-aware branch alongside, not instead of, the existing ternary.
|
||||
|
||||
- Every `FieldType` in `resolveLabel()` (lines 136-254) renders a single scalar — **no array-rendering path exists**. The subscriptions/cost-breakdown table (D-02/D-03) needs a new, unconditionally-rendered section in the Formatted tab (parallel to the existing "Description block for tickets" section at lines 543-551, which is exactly this shape — an unconditional extra block keyed on field presence):
|
||||
```tsx
|
||||
{'description' in data && data.description && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Description</h3>
|
||||
<div className="rounded-lg border p-4 text-sm whitespace-pre-wrap ...">{data.description}</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
Copy this block's shape (conditional wrapper + `<h3>` label + bordered container) for a new `{data.subscriptions && Array.isArray(data.subscriptions) && (...)}` section rendering a compact table (product name / qty / billing term / line total) plus a summed total, styled with the same `rounded-lg border` / `text-xs uppercase tracking-wider` conventions used throughout this file — do not introduce a different visual language.
|
||||
|
||||
**Do not touch:** `TICKET_GROUPS`, `COMPANY_GROUPS`, or their existing detection branches (lines 49-132, 256-260's first two conditions) — this must be a pure addition per RESEARCH.md Pitfall 1.
|
||||
|
||||
---
|
||||
|
||||
### `components/navigation/app-navigation.tsx` (nav config, edited)
|
||||
|
||||
**Pattern** (lines 50-195: `navigationItems` array; lines 211-217: role-based filter):
|
||||
```typescript
|
||||
{
|
||||
title: 'PAX8',
|
||||
href: '/pax8',
|
||||
icon: /* pick a lucide icon not already used at top level, e.g. ShoppingCart or CreditCard */,
|
||||
description: 'PAX8 companies, subscriptions, and cost breakdown',
|
||||
},
|
||||
```
|
||||
Add as a **top-level, non-nested** entry (like `Dashboard`/`Configuration Items`) — not inside the `Admin` children array — because the `visibleItems` filter (lines 212-217) only hides items titled `'Engagement'` or `'Admin'` from non-super-admins; every other top-level item (including a new `PAX8` one) is visible to all authenticated users by default, matching D-07's view-access requirement without any filter-logic change. `MobileNav` (`components/navigation/mobile-nav.tsx`) consumes the same `navigationItems` array — confirm no separate edit is needed there (per RESEARCH.md, one array edit covers both surfaces).
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Authentication / Authorization split (D-07 / D-08)
|
||||
**Source:** `lib/auth-utils.ts` lines 31-45 (`requireAuth`), 51-74 (`requirePermission`)
|
||||
**Apply to:**
|
||||
- `requireAuth()` only → `app/api/pax8/companies/route.ts`, `app/api/pax8/companies/[id]/route.ts`, `app/api/pax8/company-matches/route.ts` (view routes, D-07)
|
||||
- `requirePermission('admin', 'access')` → `app/api/pax8/company-matches/[id]/resolve/route.ts` (mutation, D-08)
|
||||
```typescript
|
||||
const { session, error } = await requireAuth(); // view routes
|
||||
if (error) return error;
|
||||
|
||||
const { session, error } = await requirePermission('admin', 'access'); // resolve route
|
||||
if (error) return error;
|
||||
```
|
||||
Every new route MUST call one of these explicitly at the top of the handler — do not rely on `middleware.ts` (it only checks session-cookie presence, confirmed by reading `middleware.ts`; `/api/pax8/sync/route.ts` at lines 1-30 is the existing counter-example with **no** auth check at all — CONTEXT.md/RESEARCH.md both explicitly flag this as a gap not to repeat).
|
||||
|
||||
### Error handling / response shape
|
||||
**Source:** every route above; convention is uniform across the codebase.
|
||||
```typescript
|
||||
try {
|
||||
// ...
|
||||
} catch (err) {
|
||||
console.error('Failed to ...:', err);
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to ...' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
```
|
||||
404/409/400 are returned inline (not via catch) for expected conditions (`Review not found`, `Already resolved`, validation failure) — see the resolve route analog.
|
||||
|
||||
### Toasts + resolve-action UX
|
||||
**Source:** `app/admin/device-link-conflicts/page.tsx` lines 111-129
|
||||
**Apply to:** the resolve button/handler in the "Needs Review" tab
|
||||
```typescript
|
||||
toast.success(`Linked to CI ${ciId}`); // adapt message
|
||||
toast.error(err instanceof Error ? err.message : 'Resolve failed');
|
||||
```
|
||||
|
||||
### Manual-search fallback data source (D-05)
|
||||
**Source:** `app/api/data/companies-list/route.ts` (full file, 15 lines) — returns `[{id, company_name}, ...]` for all active, non-deleted companies, no pagination, no auth check today (fine to leave as-is per RESEARCH.md — only ~242 rows, fetched once and filtered client-side; consider whether this phase should add a `requireAuth()` to it too, since D-08's spirit is "every new route" — this route isn't new, but flagging for planner judgment).
|
||||
```typescript
|
||||
const result = await postgresClient.query(
|
||||
`SELECT id, company_name FROM companies WHERE is_active = true AND is_deleted = false ORDER BY company_name ASC`
|
||||
);
|
||||
return NextResponse.json(result.rows);
|
||||
```
|
||||
Fetch once on mount/tab-open in the page, filter client-side with a shadcn `Command`/`Popover` combobox for D-05's manual-picker fallback.
|
||||
|
||||
### Postgres transaction pattern
|
||||
**Source:** `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` lines 51-98
|
||||
**Apply to:** `app/api/pax8/company-matches/[id]/resolve/route.ts`
|
||||
```typescript
|
||||
return postgresClient.transaction(async (tx) => {
|
||||
const row = await tx.query(`... FOR UPDATE`, [id]);
|
||||
// guards (not found / already resolved) return early inside the transaction callback
|
||||
await tx.query(`UPDATE ...`, [...]);
|
||||
await tx.query(`UPDATE ...`, [...]);
|
||||
return NextResponse.json({ ok: true });
|
||||
});
|
||||
```
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|------|------|-----------|--------|
|
||||
| Per-company windowed cost-breakdown query (`DISTINCT ON (subscription_id) ... ORDER BY subscription_id, start_period DESC`) | query pattern within `app/api/pax8/companies/[id]/route.ts` | transform/aggregate | No existing route in the codebase performs a per-subscription-windowed latest-row join; this is genuinely new SQL. RESEARCH.md Pattern 2 + Pitfall 2 is the authoritative source — follow that, not a codebase analog. |
|
||||
| Array-rendering section inside `DetailModal`'s Formatted tab | component (modal section) | transform (render) | No existing `FieldGroup`/`FieldType` renders a list of rows; closest precedent is the unconditional "Description block for tickets" (single scalar, not a table) — extend from that shape, budgeted as its own task per RESEARCH.md Pitfall 1. |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `app/admin/device-link-conflicts/**`, `app/api/admin/device-link-conflicts/**`, `components/admin/DataTable.tsx`, `components/admin/DetailModal.tsx`, `components/navigation/app-navigation.tsx`, `app/admin/data-browser/companies/page.tsx`, `app/api/data/companies-list/route.ts`, `app/engagement/page.tsx`, `lib/auth-utils.ts`, `lib/services/pax8-company-matcher.ts`, `middleware.ts`
|
||||
**Files scanned:** 11 read in full (all cited above); `migrations/091,093_pax8_*.sql` schema referenced via RESEARCH.md's already-verified live-DB queries rather than re-read (no new information would be gained from re-reading migration files RESEARCH.md already fully quotes)
|
||||
**Pattern extraction date:** 2026-07-11
|
||||
Loading…
Add table
Add a link
Reference in a new issue