/** * POST /api/analyzer/aggregate-reports * GET /api/analyzer/aggregate-reports * * POST: queue a new aggregate report. Validates inputs, creates a 'pending' * row, fires runAggregateReport in the background, returns immediately with * the report id. * * GET: list reports (paginated, optionally filtered by user). */ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { requireAuth } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; import { createAggregateReport, listAggregateReports, runAggregateReport, } from '@/lib/services/analyzer/aggregate-persistence'; import { estimateAggregateReportCost, evaluateCost, recordCostAuditDecision, } from '@/lib/services/analyzer/cost-guard'; const MAX_TICKETS_PER_REPORT = 100; const PostBody = z.object({ analysisIds: z.array(z.string().uuid()).max(MAX_TICKETS_PER_REPORT).optional(), ticketNumbers: z.array(z.string()).max(MAX_TICKETS_PER_REPORT).optional(), includeItglueContext: z.boolean().default(true), reportTitle: z.string().max(200).nullable().optional(), /** Acknowledges the per-request cost guard ($5 threshold). */ confirmedCost: z.boolean().default(false), }); interface FingerprintCheckRow { id: string; ticket_number: string; analysis_version: number; has_fingerprint: boolean; is_stale: boolean; } export async function POST(request: NextRequest) { const { session, error } = await requireAuth(); if (error) return error; const body = await request.json().catch(() => ({})); const parsed = PostBody.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: 'Invalid request body', details: parsed.error.issues }, { status: 400 } ); } const { analysisIds, ticketNumbers, includeItglueContext, reportTitle } = parsed.data; // Resolve analysisIds: explicit list > ticketNumbers (latest analysis per ticket). let resolvedIds: string[] = []; if (analysisIds && analysisIds.length > 0) { resolvedIds = analysisIds; } else if (ticketNumbers && ticketNumbers.length > 0) { const res = await postgresClient.query<{ id: string }>( `SELECT DISTINCT ON (ticket_number) id::text AS id FROM analyzer_analyses WHERE ticket_number = ANY($1::text[]) AND status = 'complete' ORDER BY ticket_number, analysis_version DESC`, [ticketNumbers] ); resolvedIds = res.rows.map((r) => r.id); } else { return NextResponse.json( { error: 'Provide analysisIds or ticketNumbers' }, { status: 400 } ); } if (resolvedIds.length === 0) { return NextResponse.json( { error: 'No matching complete analyses found for the given inputs' }, { status: 400 } ); } if (resolvedIds.length > MAX_TICKETS_PER_REPORT) { return NextResponse.json( { error: `Too many tickets (${resolvedIds.length}). Cap is ${MAX_TICKETS_PER_REPORT}.`, }, { status: 400 } ); } // Validate fingerprints + staleness. const validation = await postgresClient.query( `SELECT aa.id::text AS id, aa.ticket_number, aa.analysis_version, (aa.aggregate_fingerprint IS NOT NULL) AS has_fingerprint, (t.last_activity_date > aa.completed_at) AS is_stale FROM analyzer_analyses aa LEFT JOIN tickets t ON t.ticket_number = aa.ticket_number AND t.is_deleted = false WHERE aa.id = ANY($1::uuid[])`, [resolvedIds] ); const missingFingerprint = validation.rows .filter((r) => !r.has_fingerprint) .map((r) => `${r.ticket_number} v${r.analysis_version}`); const staleAnalyses = validation.rows .filter((r) => r.is_stale) .map((r) => `${r.ticket_number} v${r.analysis_version}`); if (missingFingerprint.length > 0) { return NextResponse.json( { error: 'Some analyses are missing aggregate_fingerprint', missingFingerprint, hint: 'Run scripts/backfill-fingerprints.ts to fill in legacy analyses, or re-analyze.', }, { status: 400 } ); } if (staleAnalyses.length > 0) { return NextResponse.json( { error: 'Some selected tickets have new activity since their last analysis. Re-analyze first.', staleAnalyses, }, { status: 400 } ); } const userId = (session?.user as { id: string } | undefined)?.id ?? null; // Cost-guard evaluation. Hard-blocks at $50/day, asks for confirmation at >$5. const estimatedCost = estimateAggregateReportCost({ ticketCount: resolvedIds.length, includeItglueContext: parsed.data.includeItglueContext, }); const evaluation = await evaluateCost({ userId, estimatedCost, confirmedCost: parsed.data.confirmedCost, }); await recordCostAuditDecision({ userId, action: 'aggregate_report', evaluation, context: { ticketCount: resolvedIds.length, includeItglueContext: parsed.data.includeItglueContext }, }); if (evaluation.decision === 'blocked') { return NextResponse.json( { error: 'Daily cost limit reached', message: evaluation.decisionReason, estimatedCost: evaluation.estimatedCost, dailySpendBefore: evaluation.dailySpendBefore, }, { status: 403 } ); } if (evaluation.decision === 'requires_confirmation') { return NextResponse.json( { error: 'Confirmation required', message: evaluation.decisionReason, estimatedCost: evaluation.estimatedCost, dailySpendBefore: evaluation.dailySpendBefore, requiresConfirmation: true, retryWith: { confirmedCost: true }, }, { status: 400 } ); } const created = await createAggregateReport({ generatedByUserId: userId, filterCriteria: { analysisIds: resolvedIds }, analysisIds: resolvedIds, ticketCount: resolvedIds.length, includeItglueContext, reportTitle: reportTitle ?? null, }); // Fire and forget — runner persists results when done. void runAggregateReport(created.id).catch((err) => { console.error('[ANALYZER-REPORT] background runner threw:', err); }); return NextResponse.json({ reportId: created.id, status: 'pending', ticketCount: resolvedIds.length, }); } export async function GET(request: NextRequest) { const { error } = await requireAuth(); if (error) return error; const url = new URL(request.url); const limit = Number(url.searchParams.get('limit') ?? 50); const offset = Number(url.searchParams.get('offset') ?? 0); const reports = await listAggregateReports({ limit, offset }); return NextResponse.json({ reports }); }