/** * GET /api/analyzer/tickets/list * * Browse view backing the /analyzer/tickets page. Filters tickets by * `last_activity_date` (the most useful axis for "what's worth analyzing * right now") plus optional company / issue type / free-text search. * * Query params: * period one of today | yesterday | this_week | last_week | last_30d | last_60d | all * companyId numeric companies.id, optional * issueType numeric issue_types.value, optional * search substring match against ticket_number or title * limit default 50, capped at 200 * offset default 0 * * Returns: * { tickets: TicketRow[], total: number } * * Each row carries `latestAnalysisId` if the ticket already has a complete * analysis, so the UI can offer "View analysis" alongside "Analyze". */ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; type Period = | 'today' | 'yesterday' | 'this_week' | 'last_week' | 'last_30d' | 'last_60d' | 'all'; const ALLOWED_PERIODS: ReadonlySet = new Set([ 'today', 'yesterday', 'this_week', 'last_week', 'last_30d', 'last_60d', 'all', ]); /** * Returns the SQL fragment for the date predicate. Uses Postgres-side NOW() * so "today" reflects the database server's clock — this is an internal tool * and the DB and app process share the same clock. * * Returns the predicate string with no parameters — these date expressions * are constants from the API perspective, computed in Postgres. */ function periodPredicate(period: Period): string { switch (period) { case 'today': return `t.last_activity_date >= date_trunc('day', NOW())`; case 'yesterday': return `t.last_activity_date >= date_trunc('day', NOW()) - INTERVAL '1 day' AND t.last_activity_date < date_trunc('day', NOW())`; case 'this_week': return `t.last_activity_date >= date_trunc('week', NOW())`; case 'last_week': return `t.last_activity_date >= date_trunc('week', NOW()) - INTERVAL '1 week' AND t.last_activity_date < date_trunc('week', NOW())`; case 'last_30d': return `t.last_activity_date >= NOW() - INTERVAL '30 days'`; case 'last_60d': return `t.last_activity_date >= NOW() - INTERVAL '60 days'`; case 'all': return `TRUE`; } } interface TicketRow { ticket_number: string; title: string | null; company_name: string | null; issue_type_label: string | null; status_label: string | null; priority_label: string | null; last_activity_date: Date | null; create_date: Date | null; latest_analysis_id: string | null; latest_analysis_version: number | null; total_count: string; } export async function GET(request: NextRequest) { const { error } = await requireAuth(); if (error) return error; const url = new URL(request.url); const periodParam = (url.searchParams.get('period') ?? 'last_30d') as Period; const period: Period = ALLOWED_PERIODS.has(periodParam) ? periodParam : 'last_30d'; const companyIdRaw = url.searchParams.get('companyId'); const companyId = companyIdRaw ? Number(companyIdRaw) : null; const issueTypeRaw = url.searchParams.get('issueType'); const issueType = issueTypeRaw ? Number(issueTypeRaw) : null; const search = (url.searchParams.get('search') ?? '').trim() || null; const limit = Math.min(Number(url.searchParams.get('limit') ?? 50) || 50, 200); const offset = Math.max(Number(url.searchParams.get('offset') ?? 0) || 0, 0); const sql = ` WITH filtered AS ( SELECT t.id, t.ticket_number, t.title, t.company_id, t.issue_type, t.status, t.priority, t.last_activity_date, t.create_date FROM tickets t WHERE t.is_deleted = false AND ${periodPredicate(period)} AND ($1::bigint IS NULL OR t.company_id = $1::bigint) AND ($2::int IS NULL OR t.issue_type = $2::int) AND ($3::text IS NULL OR ( t.ticket_number ILIKE '%' || $3::text || '%' OR t.title ILIKE '%' || $3::text || '%' )) ) SELECT f.ticket_number, f.title, c.company_name, it.label AS issue_type_label, s.label AS status_label, pr.label AS priority_label, f.last_activity_date, f.create_date, latest.id::text AS latest_analysis_id, latest.analysis_version AS latest_analysis_version, COUNT(*) OVER () AS total_count FROM filtered f LEFT JOIN companies c ON c.id = f.company_id LEFT JOIN issue_types it ON it.value = f.issue_type LEFT JOIN statuses s ON s.value = f.status LEFT JOIN priorities pr ON pr.value = f.priority LEFT JOIN LATERAL ( SELECT aa.id, 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 ORDER BY f.last_activity_date DESC NULLS LAST LIMIT $4 OFFSET $5 `; const res = await postgresClient.query(sql, [ companyId, issueType, search, limit, offset, ]); const total = res.rows.length > 0 ? Number(res.rows[0].total_count) : 0; return NextResponse.json({ period, total, limit, offset, tickets: res.rows.map((r) => ({ ticketNumber: r.ticket_number, title: r.title, companyName: r.company_name, issueTypeLabel: r.issue_type_label, statusLabel: r.status_label, priorityLabel: r.priority_label, lastActivityDate: r.last_activity_date ? r.last_activity_date.toISOString() : null, createDate: r.create_date ? r.create_date.toISOString() : null, latestAnalysisId: r.latest_analysis_id, latestAnalysisVersion: r.latest_analysis_version, })), }); }