wulf-pulse/.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md
lorentz fe4fedace0 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
2026-05-03 21:25:49 -04:00

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:

  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

// 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: 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