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>
397 lines
14 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
}
|