diff --git a/app/api/mobile/analyzer/feed/route.ts b/app/api/mobile/analyzer/feed/route.ts new file mode 100644 index 0000000..309993b --- /dev/null +++ b/app/api/mobile/analyzer/feed/route.ts @@ -0,0 +1,169 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +// ─── Company filter helper (duplicated from /api/mobile/tickets/route.ts per D-04) ─── + +async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }> { + try { + const result = await postgresClient.query( + `SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('mobile_company_category_ids', 'mobile_excluded_company_ids')` + ); + const map: Record = {}; + result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; }); + + const catIds = (map['mobile_company_category_ids'] || '1') + .split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n)); + const exclIds = (map['mobile_excluded_company_ids'] || '') + .split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n)); + + const catCond = catIds.length > 0 ? `c.company_category_id IN (${catIds.join(',')})` : 'true'; + const exclCond = exclIds.length > 0 ? `c.id NOT IN (${exclIds.join(',')})` : ''; + const condition = [catCond, exclCond].filter(Boolean).join(' AND '); + + return { join: 'INNER JOIN companies c ON c.id = t.company_id', condition }; + } catch (error) { + console.error('Error fetching mobile company filter:', error); + return { join: 'INNER JOIN companies c ON c.id = t.company_id', condition: 'c.company_category_id = 1' }; + } +} + +// ─── 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; +} + +// ─── 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; } +} + +// ─── GET handler ───────────────────────────────────────────────────────────── + +export async function GET(request: NextRequest): Promise { + 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); + + // Apply kiosk_settings company scoping (D-04, T-06-02) + const { condition: companyCondition } = await getMobileCompanyFilter(); + + // Build WHERE conditions — absorb status='complete' into the CTE and + // t.is_deleted=false into the JOIN; only company scoping and cursor predicate here + const conditions: string[] = [ + companyCondition, // D-04: kiosk_settings scoping (security boundary) + ]; + const params: unknown[] = []; + + // Cursor seek predicate (D-03, D-06) — keyset on (completed_at DESC, id DESC) + 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)`); + } + + const where = conditions.join(' AND '); + + // Latest-per-ticket CTE (D-02): DISTINCT ON returns the highest analysis_version + // per ticket among status='complete' rows. The CTE result is then joined to the + // main query so each ticket appears exactly once. + const 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 ${where} + ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC + LIMIT ${limit + 1} + `; + + const result = await postgresClient.query(sql, params); + + const rows = result.rows; + const hasMore = rows.length > limit; + const sliced = hasMore ? rows.slice(0, limit) : rows; + + // Transform snake_case → camelCase (CLAUDE.md: manual transform, no ORM) + 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, + })); + + // Compute nextCursor from the last row of the current page (D-06) + const nextCursor = hasMore && analyses.length > 0 + ? encodeCursor({ + completed_at: analyses[analyses.length - 1].completedAt, + id: analyses[analyses.length - 1].id, + }) + : null; + + return NextResponse.json({ analyses, nextCursor, hasMore } 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 }, + ); + } +}