- 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
6.4 KiB
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:
- Gates requests behind
requireAuth()(T-06-01) - Applies
kiosk_settingscompany scoping via duplicatedgetMobileCompanyFilter()(T-06-02, D-04) - 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) - Orders results
completed_at DESC NULLS LAST, id DESC(D-03) - Implements cursor-based keyset pagination with
(completed_at, id) < (cursor_value::timestamptz, cursor_value::uuid)seek predicate (D-06) - Caps page size at 25 server-side (D-05)
- Returns only the 12 payload fields declared by
AnalyzerFeedRow— nomodel_traces,itglue_docs_referenced, orhuman_review_reasons(T-06-05)
Exported Types
// 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
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:
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: nullmeans 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