- 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)
169 lines
6.9 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|