wulf-pulse/app/api/mobile/analyzer/feed/route.ts
lorentz 75238c12bb feat(06-01): add GET /api/mobile/analyzer/feed endpoint
- Create cursor-paginated analyzer feed endpoint for mobile
- Export AnalyzerFeedRow and AnalyzerFeedResponse types (D-26)
- Implement DISTINCT ON CTE for latest-per-ticket analysis (D-02)
- Apply kiosk_settings company scoping via getMobileCompanyFilter() (D-04)
- Cursor keyset pagination on (completed_at, id) with base64 JSON encoding (D-06)
- Server-side limit cap at 25 (D-05); LIMIT n+1 trick for hasMore detection
- Ordering: completed_at DESC NULLS LAST, id DESC (D-03)
- Manual snake_case to camelCase transform per CLAUDE.md conventions
- Payload whitelist: only 12 AnalyzerFeedRow fields; no model_traces, itglue_docs_referenced, or human_review_reasons (T-06-05)
- requireAuth() gate before any DB query (T-06-01)
2026-05-03 21:25:01 -04:00

169 lines
6.9 KiB
TypeScript

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<string, string> = {};
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<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);
// 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 },
);
}
}