28 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-analyzer-feed-new | 01 | execute | 1 |
|
true |
|
|
Purpose: This is the data spine for Phase 6. Plan 06-02 (feed page UI) cannot consume real data without it. The endpoint must mirror the patterns from app/api/mobile/tickets/route.ts exactly so the manager's mental model from Tickets carries over with zero learning cost — same envelope shape ({...List, nextCursor, hasMore}), same cursor encoding (base64 JSON), same kiosk_settings scoping helper, same camelCase response transform.
Output:
app/api/mobile/analyzer/feed/route.ts— GET handler, requireAuth-gated, cursor-paginated, scoped, with exported TS interfaces.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/06-analyzer-feed-new/06-CONTEXT.md @.planning/phases/06-analyzer-feed-new/06-UI-SPEC.md @CLAUDE.md @app/api/mobile/tickets/route.ts @app/api/analyzer/tickets/route.ts @migrations/069_create_analyzer_tables.sqlFrom app/api/mobile/tickets/route.ts (pattern source — the new feed route mirrors this exactly):
// Exported response interfaces (mirror this shape)
export interface MobileTicket { /* fields */ }
export interface MobileTicketListResponse {
tickets: MobileTicket[];
nextCursor: string | null;
hasMore: boolean;
}
// Cursor helpers (inline in the route file — D-06)
interface CursorPayload { last_activity_date: string; id: number; }
function encodeCursor(p: CursorPayload): string { return Buffer.from(JSON.stringify(p),'utf8').toString('base64'); }
function decodeCursor(raw: string | null): CursorPayload | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw,'base64').toString('utf8'));
if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') return parsed as CursorPayload;
return null;
} catch { return null; }
}
// Auth + scoping pattern
const { session, error: authError } = await requireAuth();
if (authError) return authError;
const { condition: companyCondition } = await getMobileCompanyFilter();
// companyCondition is "c.company_category_id IN (...) AND c.id NOT IN (...)" (or a fallback)
// LIMIT n+1 trick to detect hasMore without a COUNT query
LIMIT ${limit + 1}
const hasMore = rows.length > limit;
const sliced = hasMore ? rows.slice(0, limit) : rows;
From app/api/analyzer/tickets/route.ts (latest-version-per-ticket pattern reference, lines 319–328):
LEFT JOIN LATERAL (
SELECT aa.id, aa.triggered_at, aa.completed_at,
aa.needs_human_review, aa.confidence_score,
aa.aggregate_fingerprint, aa.analysis_version
FROM analyzer_analyses aa
WHERE aa.ticket_number = f.ticket_number
AND aa.status = 'complete'
ORDER BY aa.analysis_version DESC
LIMIT 1
) latest ON TRUE
NOTE: this endpoint joins tickets→latest analysis. Phase 6's feed reverses the direction — it joins analyses→tickets (one row per latest-completed analysis) so the same physical ticket re-analyzed N times appears once. The LEFT JOIN LATERAL ... ORDER BY analysis_version DESC LIMIT 1 idiom is the same; only the FROM table changes.
From migrations/069_create_analyzer_tables.sql — analyzer_analyses columns this plan reads:
id UUID PRIMARY KEY
ticket_number TEXT NOT NULL
autotask_ticket_id BIGINT NOT NULL
analysis_version INT NOT NULL
status TEXT (must equal 'complete')
completed_at TIMESTAMPTZ (nullable on pending; non-null when complete)
summary TEXT (nullable)
confidence_score NUMERIC(3,2) (nullable)
needs_human_review BOOLEAN NOT NULL
haiku_used BOOLEAN NOT NULL
sonnet_used BOOLEAN NOT NULL
opus_used BOOLEAN NOT NULL
UNIQUE constraint exists on (ticket_number, analysis_version), and an index idx_analyzer_analyses_ticket_version on (ticket_number, analysis_version DESC) — the LATERAL select is index-supported.
- Add the file header imports:
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
-
Duplicate the
getMobileCompanyFilter()helper fromapp/api/mobile/tickets/route.tslines 7–29 verbatim (D-04 says "duplicate inline; keep this phase's diff small"). The helper signature isasync function getMobileCompanyFilter(): Promise<{ join: string; condition: string }>. Do NOT import it — duplicate inline. Add a// ─── Company filter helper (duplicated from /api/mobile/tickets/route.ts per D-04) ───comment. -
Export the response types EXACTLY as specified in 06-UI-SPEC.md §"API Shape Contract" (D-26):
// ─── Exported response interfaces (D-26) ─────────────────────────────────────
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;
}
- Add cursor encode/decode helpers inline (D-06). The cursor payload shape is
{ completed_at: ISO string, id: uuid string }— DIFFERENT from tickets route (which uses{ last_activity_date, id: number }). Use base64 of JSON:
// ─── Cursor encode/decode (inline per D-06) ──────────────────────────────────
interface CursorPayload { completed_at: string; id: string; }
function encodeCursor(p: CursorPayload): string {
return Buffer.from(JSON.stringify(p), 'utf8').toString('base64');
}
function decodeCursor(raw: string | null): CursorPayload | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
if (typeof parsed?.completed_at === 'string' && typeof parsed?.id === 'string') {
return parsed as CursorPayload;
}
return null;
} catch { return null; }
}
The try/catch around JSON.parse is the cursor-injection mitigation: malformed input returns null (treated as "no cursor → first page"), never throws.
- Add the GET handler skeleton (Task 2 fills in the SQL):
// ─── GET handler ─────────────────────────────────────────────────────────────
export async function GET(request: NextRequest): Promise<NextResponse> {
const { error: authError } = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = request.nextUrl;
const cursorParam = searchParams.get('cursor');
// Server-side limit cap — D-05 (page size 25, cap at 25)
const limit = Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25')));
const cursor = decodeCursor(cursorParam);
// TODO Task 2: build SQL, execute, transform, return.
return NextResponse.json({ analyses: [], nextCursor: null, hasMore: false } satisfies AnalyzerFeedResponse);
} catch (error) {
console.error('GET /api/mobile/analyzer/feed failed:', error);
return NextResponse.json(
{ error: 'Failed to fetch analyses', message: error instanceof Error ? error.message : 'unknown' },
{ status: 500 },
);
}
}
- NO Zod (D-39, CLAUDE.md). NO ORM (CLAUDE.md). NO new state libraries (D-38). NO request validation library — direct
searchParams.get()reads.
After this task the file compiles, returns an empty list, and the types are exported for Task 2 (and Plan 06-02) to consume.
npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/analyzer/feed" || echo "TypeScript clean for new route file"
<acceptance_criteria>
- File exists: test -f app/api/mobile/analyzer/feed/route.ts
- Exports the correct types: grep -E '^export interface AnalyzerFeedRow' app/api/mobile/analyzer/feed/route.ts returns one match
- Exports the response envelope type: grep -E '^export interface AnalyzerFeedResponse' app/api/mobile/analyzer/feed/route.ts returns one match
- Exports GET: grep -E '^export async function GET' app/api/mobile/analyzer/feed/route.ts returns one match
- Auth gate present: grep -F 'requireAuth()' app/api/mobile/analyzer/feed/route.ts returns at least one match
- Cursor payload shape matches D-06: grep -F 'completed_at: string' app/api/mobile/analyzer/feed/route.ts returns at least one match (NOT last_activity_date)
- Cursor cap enforced: grep -E 'Math\.min\(25,' app/api/mobile/analyzer/feed/route.ts returns at least one match
- getMobileCompanyFilter helper duplicated inline: grep -F 'kiosk_settings' app/api/mobile/analyzer/feed/route.ts returns at least one match
- No Zod imports: grep -E "from\s+['\"]zod['\"]" app/api/mobile/analyzer/feed/route.ts returns zero matches
- All response field names match camelCase per UI-SPEC: grep -E '\\b(ticketNumber|companyName|confidenceScore|haikuUsed|sonnetUsed|opusUsed|needsHumanReview|completedAt|analysisVersion):' app/api/mobile/analyzer/feed/route.ts | wc -l returns at least 9
- npx tsc --noEmit --pretty exits 0 (no type errors introduced)
</acceptance_criteria>
File compiles, exports the two interfaces and the GET handler, returns an empty envelope on every call. Wave 2 plans can import type { AnalyzerFeedRow, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route'.
- Apply the kiosk_settings scope. Just before building the SQL, call:
const { condition: companyCondition } = await getMobileCompanyFilter();
The helper returns condition like c.company_category_id IN (1) AND c.id NOT IN (42, 99) (or c.company_category_id = 1 fallback). The helper aliases the companies table as c — your SQL must alias companies as c to match (D-04). This is the security boundary for ANL-01: a manager must not see analyses for tickets in companies outside their kiosk scope.
- Build the predicate list. Mirror the
conditions: string[]+params: unknown[]pattern fromapp/api/mobile/tickets/route.tslines 125–166:
const conditions: string[] = [
"aa.status = 'complete'", // D-01
't.is_deleted = false', // hide soft-deleted tickets
companyCondition, // D-04 (kiosk_settings scoping)
];
const params: unknown[] = [];
- Cursor seek predicate (D-03, D-06). When
cursoris non-null, append the keyset predicate(completed_at, id) < (cursor.completed_at, cursor.id):
if (cursor) {
params.push(cursor.completed_at);
params.push(cursor.id);
conditions.push(`(aa.completed_at, aa.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
}
The cast $N::uuid is critical because analyzer_analyses.id is UUID (not int like tickets.id).
- The query — analyses-first, with LATERAL latest-per-ticket guard (D-02, D-03). The shape: from
analyzer_analysesrows, only include the row if it IS the latestanalysis_versionfor thatticket_numberamongstatus='complete'rows. This naturally produces "one row per ticket, latest first":
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 ${conditions.filter excluding the t.is_deleted and aa.status which are now in the CTE/inner join — keep only companyCondition + cursor predicate}
ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC
LIMIT ${limit + 1}
Practical implementation: keep companyCondition and the cursor predicate in the WHERE; absorb aa.status='complete' into the CTE and t.is_deleted=false into the JOIN. Final WHERE has 1–2 predicates. Use LIMIT ${limit + 1} so you can detect hasMore without a COUNT (mirrors tickets route line 183).
Build the final SQL string by interpolating ${conditions.join(' AND ')} and ${limit + 1}. Pass params to postgresClient.query(sql, params).
- Transform rows snake_case → camelCase (CLAUDE.md: manual transform, no ORM). Map each pg row to
AnalyzerFeedRow:
const rows = result.rows;
const hasMore = rows.length > limit;
const sliced = hasMore ? rows.slice(0, limit) : rows;
const analyses: AnalyzerFeedRow[] = sliced.map(row => ({
id: String(row.id),
ticketNumber: row.ticket_number,
title: row.title ?? '',
companyName: row.company_name ?? '',
summary: row.summary ?? null,
confidenceScore: row.confidence_score === null ? null : Number(row.confidence_score),
haikuUsed: row.haiku_used,
sonnetUsed: row.sonnet_used,
opusUsed: row.opus_used,
needsHumanReview: row.needs_human_review,
completedAt: row.completed_at instanceof Date ? row.completed_at.toISOString() : String(row.completed_at),
analysisVersion: row.analysis_version,
}));
confidence_score comes back from pg as a string (NUMERIC type) → coerce with Number(). completed_at is a Date from pg → .toISOString(). (Compare to app/api/analyzer/tickets/route.ts:362 for the same .toISOString() pattern.)
- Compute nextCursor from the LAST row of
sliced(D-06):
const nextCursor = hasMore && analyses.length > 0
? encodeCursor({
completed_at: analyses[analyses.length - 1].completedAt,
id: analyses[analyses.length - 1].id,
})
: null;
Note the cursor's completed_at is the ISO string already in analyses[N].completedAt — consistent with the WHERE predicate's ::timestamptz cast.
- Return with
satisfies AnalyzerFeedResponse:
return NextResponse.json({ analyses, nextCursor, hasMore } satisfies AnalyzerFeedResponse);
- Security review (per
<security_threat_model>):- cursor injection → mitigated by
decodeCursortry/catch + shape validation (returns null on malformed input) - kiosk scoping → mitigated by
getMobileCompanyFilter()companyCondition (T-06-02 in threat model) - payload leakage → only the columns the row card needs are returned; NOT
model_traces, NOThuman_review_reasons, NOTitglue_docs_referenced, NOT IT Glue doc bodies (T-06-05) - rate limiting → server-side
Math.min(25, ...)cap (T-06-04) npx tsc --noEmit --pretty 2>&1 | (! grep -E "app/api/mobile/analyzer/feed") <acceptance_criteria> npx tsc --noEmit --prettyexits 0 (no errors)- SQL contains the latest-per-ticket CTE:
grep -E 'DISTINCT ON \(ticket_number\)' app/api/mobile/analyzer/feed/route.tsreturns at least one match - SQL filters status complete only:
grep -E "status\s*=\s*'complete'" app/api/mobile/analyzer/feed/route.tsreturns at least one match - Ordering matches D-03:
grep -F "ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC" app/api/mobile/analyzer/feed/route.tsreturns at least one match - Cursor seek predicate uses correct types:
grep -E '\\(aa\\.completed_at, aa\\.id\\) < \\(\\$' app/api/mobile/analyzer/feed/route.tsreturns at least one match - kiosk scoping wired:
grep -F 'getMobileCompanyFilter()' app/api/mobile/analyzer/feed/route.tsreturns at least one match - LIMIT n+1 trick used:
grep -E 'LIMIT \\$\\{limit \\+ 1\\}' app/api/mobile/analyzer/feed/route.tsreturns at least one match (or equivalent pattern) - hasMore detection:
grep -E 'rows\\.length > limit' app/api/mobile/analyzer/feed/route.tsreturns at least one match - camelCase transform present:
grep -E 'ticketNumber: row\\.ticket_number' app/api/mobile/analyzer/feed/route.tsreturns at least one match - Companies table aliased as
c:grep -E 'INNER JOIN companies c\\b' app/api/mobile/analyzer/feed/route.tsreturns at least one match (matches helper's expectation) - Tickets joined:
grep -E 'INNER JOIN tickets t\\b' app/api/mobile/analyzer/feed/route.tsreturns at least one match - Sensitive columns NOT selected:
grep -E "(model_traces|itglue_docs_referenced|human_review_reasons)" app/api/mobile/analyzer/feed/route.tsreturns ZERO matches (security: payload minimization) - Manual smoke test:
curl -s 'http://localhost:3100/api/mobile/analyzer/feed' -H "Cookie: <auth>"returns JSON withanalysesarray (or 401 if not authed) — NOT a 500. (Optional; auth-gated, dev-only.) </acceptance_criteria> Endpoint returns the latest-completed analysis per ticket, ordered bycompleted_at DESC, id DESC, scoped bykiosk_settings, paginated with cursor (≤ 25/page). The response envelope matchesAnalyzerFeedResponse. Plan 06-02 can fetch and render real data from this route.
- cursor injection → mitigated by
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
client → API (/api/mobile/analyzer/feed) |
Untrusted query string (cursor, limit) crosses into server; session cookie verified |
| API → Postgres | Parameterized queries; no string interpolation of user input |
| API → response payload | Server controls which columns leave the trust boundary; potentially-sensitive analyzer fields must NOT cross |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-06-01 | Spoofing / Auth Bypass | GET /api/mobile/analyzer/feed |
mitigate | Call requireAuth() from lib/auth-utils.ts BEFORE any DB query (Task 1, line const { error: authError } = await requireAuth(); if (authError) return authError;). Better Auth session cookie is the gate; no bypass path. ASVS L1 §V2.1. |
| T-06-02 | Information Disclosure (IDOR / cross-tenant read) | feed route SQL | mitigate | Apply getMobileCompanyFilter() companyCondition to the WHERE clause (Task 2). Without it a manager could list analyses for tickets in companies outside their kiosk scope. The helper reads kiosk_settings.mobile_company_category_ids and mobile_excluded_company_ids and emits a SQL fragment scoped to c.*. The companies table alias c MUST be used in JOIN to match the helper. |
| T-06-03 | Tampering (cursor injection) | decodeCursor() |
mitigate | Wrap JSON.parse(Buffer.from(raw,'base64').toString('utf8')) in try/catch. Shape-validate decoded object: only return non-null when completed_at is a string AND id is a string. Any malformed input returns null → handler treats as "first page". Failure mode is fail-closed (no SQL injection vector — params are still parameterized; worst case is a cursor that doesn't match any row, returning empty). |
| T-06-04 | Denial of Service (large pagination) | feed route limit param | mitigate | Server-side Math.min(25, Math.max(1, ...)) cap on limit query param (Task 1). Even if a client sends ?limit=10000, the server reads at most 26 rows (limit + 1 for hasMore detection). LIMIT in SQL is integer-interpolated AFTER the cap. |
| T-06-05 | Information Disclosure (sensitive analyzer payload leakage) | feed route SELECT list | mitigate | Whitelist columns in the SELECT — only the 12 fields AnalyzerFeedRow declares. Do NOT select model_traces, itglue_docs_referenced, human_review_reasons, or error_message. These can contain client data, IT Glue references, and IT Glue doc bodies (per migration 069 comments). The detail page (Plan 06-03) reuses the existing /api/analyzer/analyses/[id] endpoint which is already auth-gated, but its IDOR posture is OUT OF SCOPE for this plan and is flagged in Plan 06-03's threat model. |
| T-06-06 | Repudiation | feed route logging | accept | Auth gate logs are produced by Better Auth middleware; per-request access audit logging is NOT implemented for /api/mobile/* today (Phase 4 didn't add it either). Risk is low: read-only endpoint, no state change. Future phase can add structured access logs if compliance requires. |
| T-06-07 | Information Disclosure (SQL error messages) | error catch block | mitigate | The catch block returns error instanceof Error ? error.message : 'unknown'. Postgres error messages can include schema details. For a read-only endpoint with parameterized SQL the leakage surface is small (no user-controlled SQL fragments); the team's existing /api/mobile/tickets route uses the same pattern, so this matches established convention. Error is also logged to console.error for server-side observability. |
| </threat_model> |
<success_criteria>
app/api/mobile/analyzer/feed/route.tsexists and exportsGET,AnalyzerFeedRow,AnalyzerFeedResponse- The endpoint returns ONE row per ticket (latest analysis_version among status='complete' rows) — NOT multiple rows for re-analyzed tickets
- Ordering is
completed_at DESC NULLS LAST, id DESC - Out-of-scope companies (per
kiosk_settings) are excluded - Cursor pagination works: passing the returned
nextCursorreturns the next page; nullnextCursormeans exhausted - Server-side limit cap of 25 is enforced regardless of
?limit=value - Unauthenticated requests return 401 (via
requireAuth()) - Response payload contains ONLY the 12 fields declared by
AnalyzerFeedRow(nomodel_traces, no IT Glue bodies, nohuman_review_reasonsarray) npx tsc --noEmit --prettypasses </success_criteria>