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
This commit is contained in:
lorentz 2026-05-03 21:25:49 -04:00
parent 75238c12bb
commit fe4fedace0

View file

@ -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