wulf-pulse/app/api/analyzer/tickets/route.ts
lorentz bd3401df1c feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards
Eight sub-phases per docs/ticket-analyzer-phase2-spec.md:

2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot,
    aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses.
    model_traces marked LEGACY (kept for back-compat).
2.2 Every pipeline stage records a row to analyzer_stage_executions, success
    or failure. Worker persists a status='failed' analyzer_analyses row when
    the pipeline throws so partial stage records have a parent. Pipeline
    exposes raw triage/sonnet/opus responses for downstream stages.
2.3 Stage 3 prompt updated with markdown formatting rules + banned filler
    phrases. Added react-markdown + remark-gfm + @tailwindcss/typography.
    New <AnalysisMarkdown> component replaces <ProseText>; coerces stray
    headers to bold paragraphs.
2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant.
    scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the
    legacy model_traces blob.
2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/
    queue/status/priority/assignee, sticky filter bar, active-filter chips,
    bulk selection persisted via localStorage, "Analyze N selected" +
    "Generate aggregate report" actions. New <MultiSelect> primitive.
    Staleness uses last_activity_date > completed_at heuristic per spec C.1.
2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists
    SQL distributions immediately so UI shows partial state during the
    Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new
    /:id]). IT Glue context fetcher capped at 200 doc titles.
2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at
    $20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS
    override. Every gating decision audited.
2.8 Runbook + build notes updated.

128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent
(IF NOT EXISTS). model_traces double-write retained — drop in a future
migration once aggregate reports have soaked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:00:22 -04:00

397 lines
14 KiB
TypeScript

/**
* 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<Period> = new Set([
'today',
'yesterday',
'this_week',
'last_week',
'last_30d',
'last_60d',
'custom',
'all',
]);
type AnalyzedFilter = 'any' | 'yes' | 'no' | 'stale';
const ALLOWED_ANALYZED: ReadonlySet<AnalyzedFilter> = new Set([
'any',
'yes',
'no',
'stale',
]);
type SortKey =
| 'created_desc'
| 'created_asc'
| 'last_activity_desc'
| 'last_activity_asc'
| 'priority';
const ALLOWED_SORT: ReadonlySet<SortKey> = 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<TicketRow>(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,
},
});
}