wulf-pulse/app/api/analyzer/tickets/list/route.ts
lorentz b20c94ea1a feat(analyzer): browse-tickets page + analysis-view typography
- /analyzer/tickets — period chips (today/yesterday/this+last
  week/30d/60d/all), client + issue-type Selects, debounced search,
  per-row Analyze/Re-analyze plus View shortcut when an analysis
  already exists.
- API: /api/analyzer/tickets/list (period/companyId/issueType/search,
  paginated via COUNT(*) OVER) and /filter-options (companies that
  actually have tickets, active issue types).
- ProseText helper in analysis-view splits on blank lines and renders
  each chunk with leading-7 — Summary, Next Step, rationale, and
  Post-Resolution now have proper paragraph rhythm. Next Step card
  re-styled with bg-primary/5 tint, ArrowRight icon, and an indented
  rationale block.
- Top-level "Analyzer" nav menu (Browse Tickets + Needs Review).

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

177 lines
5.9 KiB
TypeScript

/**
* 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<Period> = 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<TicketRow>(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,
})),
});
}