/** * GET /api/analyzer/tickets * * Browse view backing /analyzer/tickets. Filters tickets by activity window * + multi-axis filter set. Joins to analyzer_analyses to surface analyzed * state per ticket. * * Phase 2 staleness heuristic: a ticket is "stale" when * tickets.last_activity_date > latest_analysis.completed_at * The spec calls for content-hash-based staleness; that requires either * caching the current hash on the tickets row or computing on read for the * visible page. For Phase 2 V1 we use the date heuristic — see * docs/ticket-analyzer-phase2-spec.md C.1 ("compute on-read for now and * discuss caching strategy after we see real load") and the build notes. * * Query params: * period today|yesterday|this_week|last_week|last_30d|last_60d|custom|all * startDate ISO date, only when period=custom * endDate ISO date, only when period=custom (inclusive) * clientId comma-separated companies.id values * issueType comma-separated issue_types.value values * queue comma-separated queues.value values * status comma-separated statuses.value values * priority comma-separated priorities.value values * assignedTo comma-separated resources.id values * analyzed any|yes|no|stale (default: any) * needsReview true|false (default: any) * sort created_desc|created_asc|last_activity_desc|last_activity_asc|priority * search substring match against ticket_number or title * limit default 50, max 200 * offset default 0 */ 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' | 'custom' | 'all'; const ALLOWED_PERIODS: ReadonlySet = new Set([ 'today', 'yesterday', 'this_week', 'last_week', 'last_30d', 'last_60d', 'custom', 'all', ]); type AnalyzedFilter = 'any' | 'yes' | 'no' | 'stale'; const ALLOWED_ANALYZED: ReadonlySet = new Set([ 'any', 'yes', 'no', 'stale', ]); type SortKey = | 'created_desc' | 'created_asc' | 'last_activity_desc' | 'last_activity_asc' | 'priority'; const ALLOWED_SORT: ReadonlySet = new Set([ 'created_desc', 'created_asc', 'last_activity_desc', 'last_activity_asc', 'priority', ]); function parseCsv(raw: string | null): number[] | null { if (!raw) return null; const parts = raw .split(',') .map((s) => Number(s.trim())) .filter((n) => Number.isFinite(n)); return parts.length > 0 ? parts : null; } /** SQL fragment for the date predicate (no params — date math is in Postgres). */ function periodPredicate( period: Period, startDate: string | null, endDate: string | null ): { sql: string; params: unknown[] } { switch (period) { case 'today': return { sql: `t.last_activity_date >= date_trunc('day', NOW())`, params: [] }; case 'yesterday': return { sql: `t.last_activity_date >= date_trunc('day', NOW()) - INTERVAL '1 day' AND t.last_activity_date < date_trunc('day', NOW())`, params: [], }; case 'this_week': return { sql: `t.last_activity_date >= date_trunc('week', NOW())`, params: [] }; case 'last_week': return { sql: `t.last_activity_date >= date_trunc('week', NOW()) - INTERVAL '1 week' AND t.last_activity_date < date_trunc('week', NOW())`, params: [], }; case 'last_30d': return { sql: `t.last_activity_date >= NOW() - INTERVAL '30 days'`, params: [] }; case 'last_60d': return { sql: `t.last_activity_date >= NOW() - INTERVAL '60 days'`, params: [] }; case 'custom': // Both dates required; if missing fall through to "all". if (!startDate || !endDate) return { sql: `TRUE`, params: [] }; return { sql: `t.last_activity_date >= $__START__ AND t.last_activity_date < ($__END__::timestamptz + INTERVAL '1 day')`, params: [startDate, endDate], }; case 'all': return { sql: `TRUE`, params: [] }; } } function sortClause(sort: SortKey): string { switch (sort) { case 'created_desc': return `f.create_date DESC NULLS LAST`; case 'created_asc': return `f.create_date ASC NULLS LAST`; case 'last_activity_desc': return `f.last_activity_date DESC NULLS LAST`; case 'last_activity_asc': return `f.last_activity_date ASC NULLS LAST`; case 'priority': // Lower priority value = higher importance in Autotask. return `f.priority ASC NULLS LAST, f.last_activity_date DESC NULLS LAST`; } } interface TicketRow { ticket_number: string; autotask_ticket_id: string; title: string | null; client_name: string | null; client_id: string | null; status_label: string | null; priority_label: string | null; queue_label: string | null; issue_type_label: string | null; sub_issue_type_label: string | null; assigned_resource_name: string | null; create_date: Date | null; last_activity_date: Date | null; age_in_days: number | null; latest_analysis_id: string | null; latest_analysis_at: Date | null; latest_completed_at: Date | null; needs_human_review: boolean | null; confidence_score: string | null; primary_category: string | 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 startDate = url.searchParams.get('startDate'); const endDate = url.searchParams.get('endDate'); const clientIds = parseCsv(url.searchParams.get('clientId')); const issueTypes = parseCsv(url.searchParams.get('issueType')); const queues = parseCsv(url.searchParams.get('queue')); const statuses = parseCsv(url.searchParams.get('status')); const priorities = parseCsv(url.searchParams.get('priority')); const assignedTo = parseCsv(url.searchParams.get('assignedTo')); const analyzedRaw = (url.searchParams.get('analyzed') ?? 'any') as AnalyzedFilter; const analyzed: AnalyzedFilter = ALLOWED_ANALYZED.has(analyzedRaw) ? analyzedRaw : 'any'; const needsReviewRaw = url.searchParams.get('needsReview'); const needsReview = needsReviewRaw === 'true' ? true : needsReviewRaw === 'false' ? false : null; const sortRaw = (url.searchParams.get('sort') ?? 'last_activity_desc') as SortKey; const sort: SortKey = ALLOWED_SORT.has(sortRaw) ? sortRaw : 'last_activity_desc'; 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); // Build the parameter list and SQL fragment incrementally so each filter is // optional. Using $N indexed params; period predicate is an SQL fragment. const params: unknown[] = []; const where: string[] = ['t.is_deleted = false']; const periodFragment = periodPredicate(period, startDate, endDate); if (periodFragment.params.length > 0) { params.push(...periodFragment.params); let frag = periodFragment.sql; frag = frag.replace('$__START__', `$${params.length - 1}::timestamptz`); frag = frag.replace('$__END__', `$${params.length}::timestamptz`); where.push(frag); } else { where.push(periodFragment.sql); } if (clientIds) { params.push(clientIds); where.push(`t.company_id = ANY($${params.length}::bigint[])`); } if (issueTypes) { params.push(issueTypes); where.push(`t.issue_type = ANY($${params.length}::int[])`); } if (queues) { params.push(queues); where.push(`t.queue_id = ANY($${params.length}::int[])`); } if (statuses) { params.push(statuses); where.push(`t.status = ANY($${params.length}::int[])`); } if (priorities) { params.push(priorities); where.push(`t.priority = ANY($${params.length}::int[])`); } if (assignedTo) { params.push(assignedTo); where.push(`t.assigned_resource_id = ANY($${params.length}::bigint[])`); } if (search) { params.push(search); where.push( `(t.ticket_number ILIKE '%' || $${params.length} || '%' OR t.title ILIKE '%' || $${params.length} || '%')` ); } const analyzedHavingParts: string[] = []; if (analyzed === 'yes') { analyzedHavingParts.push(`latest.id IS NOT NULL`); } else if (analyzed === 'no') { analyzedHavingParts.push(`latest.id IS NULL`); } else if (analyzed === 'stale') { analyzedHavingParts.push( `latest.id IS NOT NULL AND f.last_activity_date > latest.completed_at` ); } if (needsReview === true) { analyzedHavingParts.push(`latest.needs_human_review = true`); } else if (needsReview === false) { analyzedHavingParts.push( `(latest.needs_human_review IS DISTINCT FROM true)` ); } const havingFragment = analyzedHavingParts.length > 0 ? `WHERE ${analyzedHavingParts.join(' AND ')}` : ''; params.push(limit); const limitParamIdx = params.length; params.push(offset); const offsetParamIdx = params.length; const sql = ` WITH filtered AS ( SELECT t.id, t.ticket_number, t.title, t.company_id, t.issue_type, t.sub_issue_type, t.status, t.priority, t.queue_id, t.assigned_resource_id, t.create_date, t.last_activity_date FROM tickets t WHERE ${where.join(' AND ')} ) SELECT f.ticket_number, f.id::text AS autotask_ticket_id, f.title, c.company_name AS client_name, c.id::text AS client_id, s.label AS status_label, pr.label AS priority_label, q.label AS queue_label, it.label AS issue_type_label, sit.label AS sub_issue_type_label, CASE WHEN r.id IS NOT NULL THEN trim(coalesce(r.first_name,'') || ' ' || coalesce(r.last_name,'')) ELSE NULL END AS assigned_resource_name, f.create_date, f.last_activity_date, CASE WHEN f.create_date IS NULL THEN NULL ELSE EXTRACT(DAY FROM NOW() - f.create_date)::int END AS age_in_days, latest.id::text AS latest_analysis_id, latest.triggered_at AS latest_analysis_at, latest.completed_at AS latest_completed_at, latest.needs_human_review, latest.confidence_score::text AS confidence_score, (latest.aggregate_fingerprint ->> 'category') AS primary_category, 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 issue_types sit ON sit.value = f.sub_issue_type LEFT JOIN statuses s ON s.value = f.status LEFT JOIN priorities pr ON pr.value = f.priority LEFT JOIN queues q ON q.value = f.queue_id LEFT JOIN resources r ON r.id = f.assigned_resource_id LEFT JOIN LATERAL ( SELECT aa.id, aa.triggered_at, aa.completed_at, aa.needs_human_review, aa.confidence_score, aa.aggregate_fingerprint, 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 ${havingFragment} ORDER BY ${sortClause(sort)} LIMIT $${limitParamIdx} OFFSET $${offsetParamIdx} `; const res = await postgresClient.query(sql, params); const total = res.rows.length > 0 ? Number(res.rows[0].total_count) : 0; const tickets = res.rows.map((r) => { let analyzedState: 'none' | 'current' | 'stale' = 'none'; if (r.latest_analysis_id) { if ( r.last_activity_date && r.latest_completed_at && r.last_activity_date.getTime() > r.latest_completed_at.getTime() ) { analyzedState = 'stale'; } else { analyzedState = 'current'; } } return { ticketNumber: r.ticket_number, autotaskTicketId: Number(r.autotask_ticket_id), title: r.title, clientName: r.client_name, clientId: r.client_id ? Number(r.client_id) : null, status: r.status_label, priority: r.priority_label, queue: r.queue_label, issueType: r.issue_type_label, subIssueType: r.sub_issue_type_label, assignedResourceName: r.assigned_resource_name, createdAtAutotask: r.create_date ? r.create_date.toISOString() : null, lastActivityAtAutotask: r.last_activity_date ? r.last_activity_date.toISOString() : null, ageInDays: r.age_in_days, analyzedState, latestAnalysisId: r.latest_analysis_id, latestAnalysisAt: r.latest_analysis_at ? r.latest_analysis_at.toISOString() : null, needsHumanReview: r.needs_human_review ?? false, confidenceScore: r.confidence_score === null ? null : Number(r.confidence_score), primaryCategory: r.primary_category, }; }); return NextResponse.json({ tickets, total, filters: { period, startDate, endDate, clientIds, issueTypes, queues, statuses, priorities, assignedTo, analyzed, needsReview, sort, search, limit, offset, }, }); }