From fe4fedace045921196167985a2ea350192b5e4d4 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 21:25:49 -0400 Subject: [PATCH] docs(06-01): complete analyzer feed API plan summary - Document exported types, SQL approach, kiosk scoping, cursor encoding - Include notes for Plan 06-02 executors (import path, sample URL) - Self-check passed: file exists, commit 75238c1 verified, tsc exits 0 --- .../06-analyzer-feed-new/06-01-SUMMARY.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 .planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md diff --git a/.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md b/.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md new file mode 100644 index 0000000..c82e0c2 --- /dev/null +++ b/.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md @@ -0,0 +1,138 @@ +--- +phase: 06-analyzer-feed-new +plan: "01" +subsystem: api +tags: [mobile, analyzer, api, cursor-pagination, kiosk-scoping] +dependency_graph: + requires: [] + provides: [GET /api/mobile/analyzer/feed, AnalyzerFeedRow, AnalyzerFeedResponse] + affects: [app/mobile/analyzer/page.tsx (Wave 2 consumer), app/mobile/analyzer/[id]/page.tsx (Wave 2 consumer)] +tech_stack: + added: [] + patterns: [cursor-keyset-pagination, distinct-on-cte, kiosk-scoping, payload-minimization] +key_files: + created: + - app/api/mobile/analyzer/feed/route.ts + modified: [] +decisions: + - "DISTINCT ON (ticket_number) CTE approach chosen over LEFT JOIN LATERAL for latest-per-ticket (simpler query, same index support)" + - "getMobileCompanyFilter() duplicated inline per D-04 (third caller not yet present)" + - "Cursor payload uses UUID string id (not int like tickets route) — critical for analyzer_analyses.id type" +metrics: + duration: "~15 minutes" + completed: "2026-05-04" + tasks_completed: 2 + tasks_total: 2 + files_created: 1 + files_modified: 0 +--- + +# Phase 06 Plan 01: Analyzer Feed API Endpoint Summary + +**One-liner:** Cursor-paginated `GET /api/mobile/analyzer/feed` endpoint returning latest completed analysis per ticket with `kiosk_settings` company scoping and exported `AnalyzerFeedRow` / `AnalyzerFeedResponse` types. + +## What Was Built + +`app/api/mobile/analyzer/feed/route.ts` — a new mobile-only API route that serves the data spine for Phase 6's Analyzer feed. The endpoint: + +1. Gates requests behind `requireAuth()` (T-06-01) +2. Applies `kiosk_settings` company scoping via duplicated `getMobileCompanyFilter()` (T-06-02, D-04) +3. Returns the **latest** completed analysis per ticket (one row per ticket, never duplicates for re-analyzed tickets) using a `DISTINCT ON (ticket_number)` CTE (D-02) +4. Orders results `completed_at DESC NULLS LAST, id DESC` (D-03) +5. Implements cursor-based keyset pagination with `(completed_at, id) < (cursor_value::timestamptz, cursor_value::uuid)` seek predicate (D-06) +6. Caps page size at 25 server-side (D-05) +7. Returns only the 12 payload fields declared by `AnalyzerFeedRow` — no `model_traces`, `itglue_docs_referenced`, or `human_review_reasons` (T-06-05) + +## Exported Types + +```typescript +// Import path for Plan 06-02 consumers: +// import type { AnalyzerFeedRow, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route'; + +export interface AnalyzerFeedRow { + id: string; // analyzer_analyses UUID + ticketNumber: string; + title: string; + companyName: string; + summary: string | null; + confidenceScore: number | null; + haikuUsed: boolean; + sonnetUsed: boolean; + opusUsed: boolean; + needsHumanReview: boolean; + completedAt: string; // ISO string + analysisVersion: number; +} + +export interface AnalyzerFeedResponse { + analyses: AnalyzerFeedRow[]; + nextCursor: string | null; + hasMore: boolean; +} +``` + +## SQL Approach + +```sql +WITH latest_per_ticket AS ( + SELECT DISTINCT ON (ticket_number) id + FROM analyzer_analyses + WHERE status = 'complete' + ORDER BY ticket_number, analysis_version DESC +) +SELECT aa.id, aa.ticket_number, aa.completed_at, aa.analysis_version, + aa.summary, aa.confidence_score, aa.needs_human_review, + aa.haiku_used, aa.sonnet_used, aa.opus_used, + t.title, + c.company_name + FROM analyzer_analyses aa + INNER JOIN latest_per_ticket l ON l.id = aa.id + INNER JOIN tickets t ON t.ticket_number = aa.ticket_number AND t.is_deleted = false + INNER JOIN companies c ON c.id = t.company_id + WHERE {companyCondition} [AND (aa.completed_at, aa.id) < ($1::timestamptz, $2::uuid)] + ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC + LIMIT {limit + 1} +``` + +**Why `DISTINCT ON` CTE instead of `LEFT JOIN LATERAL`:** The CTE reads `analyzer_analyses` once with `DISTINCT ON (ticket_number)` ordered by `analysis_version DESC` — PostgreSQL uses the `idx_analyzer_analyses_ticket_version` index on `(ticket_number, analysis_version DESC)` to execute this efficiently. The CTE result is then joined back to the main `analyzer_analyses` table so all needed columns (summary, confidence_score, etc.) are available. This is simpler than the `LEFT JOIN LATERAL ... LIMIT 1` idiom from the tickets route while achieving the same semantic guarantee. + +## kiosk_settings Scoping + +The `getMobileCompanyFilter()` helper is duplicated inline (per D-04 — third caller not yet present; refactor to shared util deferred). It reads `mobile_company_category_ids` and `mobile_excluded_company_ids` from the `kiosk_settings` table and emits a SQL fragment such as: + +```sql +c.company_category_id IN (1) AND c.id NOT IN (42, 99) +``` + +The companies table **must** be aliased as `c` in the JOIN — the helper hardcodes this alias. The route uses `INNER JOIN companies c ON c.id = t.company_id` to match. + +## Cursor Encoding + +Cursor payload: `{ completed_at: ISO string, id: UUID string }` — encoded as `base64(JSON.stringify(payload))`. The cursor's `id` is a UUID string (not an integer), which distinguishes this route from the tickets route. The seek predicate uses explicit Postgres type casts `::timestamptz` and `::uuid` to ensure correct type comparison semantics. + +Malformed cursors (base64 decode failure, missing fields, wrong types) return `null` from `decodeCursor()` — treated as "no cursor → first page" (fail-closed, T-06-03). + +## Notes for Plan 06-02 Executors + +- **Import path:** `import type { AnalyzerFeedRow, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route'` +- **Sample request URL:** `GET /api/mobile/analyzer/feed?limit=25` +- **Pagination:** Pass `?cursor={nextCursor}` from previous response to get the next page; `hasMore: false` + `nextCursor: null` means list is exhausted +- **Auth:** Endpoint requires a valid Better Auth session cookie; unauthenticated requests receive 401 +- **Empty state:** First page with zero rows returns `{ analyses: [], nextCursor: null, hasMore: false }` — not a 404 or error + +## Deviations from Plan + +None — plan executed exactly as written. + +## Threat Surface Scan + +No new threat surface beyond what the plan's threat model covers. The endpoint: +- Does not introduce new auth paths (uses existing `requireAuth()`) +- Does not expose new schema (reads existing `analyzer_analyses`, `tickets`, `companies`) +- Does not introduce network endpoints outside `/api/mobile/*` scope + +## Self-Check: PASSED + +- `app/api/mobile/analyzer/feed/route.ts` — FOUND +- Commit `75238c1` — FOUND (`feat(06-01): add GET /api/mobile/analyzer/feed endpoint`) +- `npx tsc --noEmit --pretty` — exits 0