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:
parent
75238c12bb
commit
fe4fedace0
1 changed files with 138 additions and 0 deletions
138
.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md
Normal file
138
.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue