- /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>
177 lines
5.9 KiB
TypeScript
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,
|
|
})),
|
|
});
|
|
}
|