From d2dfc341da625b4671e05fd16851160d37214dda Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 14:00:38 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01LHRgZqkzBHBbAbc3KHneuR --- .planning/STATE.md | 14 +- .../phases/14-pax8-ui-surface/14-PATTERNS.md | 431 ++++++++++++++++++ 2 files changed, 438 insertions(+), 7 deletions(-) create mode 100644 .planning/phases/14-pax8-ui-surface/14-PATTERNS.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 28402d9..578cae5 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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% diff --git a/.planning/phases/14-pax8-ui-surface/14-PATTERNS.md b/.planning/phases/14-pax8-ui-surface/14-PATTERNS.md new file mode 100644 index 0000000..5d4c20b --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-PATTERNS.md @@ -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 ( +
+
+
+

Employee Engagement

+

...

+
+
{/* actions */}
+
+ + setActiveTab(v as 'overview' | 'by-employee')} className="space-y-6"> + + Overview + By Employee + + ... + +
+); +``` +Use this shell for `/pax8`'s two tabs: `"companies"` / `"needs-review"`. Note `app/admin/device-link-conflicts/page.tsx` instead uses `` (`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 `

` 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(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? } ... + + fetchCompanies(page, undefined, column, direction)} + onSearch={(query) => fetchCompanies(1, query)} + onRowClick={handleRowClick} + isLoading={isLoading} +/> + + +``` +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 { + 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 && ( + Failed to load{error} +)} +{items === null && !error && ( +
...
+)} +{items !== null && items.length === 0 && !error && ( + No conflictsNothing waiting for review on this filter. +)} +``` +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(); +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): 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 && ( +
+

Description

+
{data.description}
+
+)} +``` +Copy this block's shape (conditional wrapper + `

` 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