wulf-pulse/app/api/mobile/tickets/route.ts
lorentz 6268d1fe37 feat(04-01): rewrite /api/mobile/tickets with cursor pagination and exported interfaces
- Replace page/offset pagination with opaque base64 cursor (last_activity_date, id)
- Export MobileTicket and MobileTicketListResponse interfaces for Plan 02 import
- Add requireAuth() gate (T-04-03: legacy route lacked auth)
- Server-side limit cap at 25 rows (D-11, T-04-04)
- Default status filter [1,8,7] when no status param supplied (matches legacy t.status != 5)
- Preserve getMobileCompanyFilter() helper verbatim
- Support status/priority arrays, queue, mine, and search filters
- Cursor seek predicate: (last_activity_date, id) < (cursor) for stable keyset order
2026-05-03 17:59:54 -04:00

220 lines
8.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
// ─── Company filter helper (preserved verbatim from legacy route) ─────────────
async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }> {
try {
const result = await postgresClient.query(
`SELECT setting_key, setting_value FROM kiosk_settings WHERE setting_key IN ('mobile_company_category_ids', 'mobile_excluded_company_ids')`
);
const map: Record<string, string> = {};
result.rows.forEach((r: any) => { map[r.setting_key] = r.setting_value || ''; });
const catIds = (map['mobile_company_category_ids'] || '1')
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
const exclIds = (map['mobile_excluded_company_ids'] || '')
.split(',').map((s: string) => parseInt(s.trim(), 10)).filter((n: number) => !isNaN(n));
const catCond = catIds.length > 0 ? `c.company_category_id IN (${catIds.join(',')})` : 'true';
const exclCond = exclIds.length > 0 ? `c.id NOT IN (${exclIds.join(',')})` : '';
const condition = [catCond, exclCond].filter(Boolean).join(' AND ');
return { join: 'INNER JOIN companies c ON c.id = t.company_id', condition };
} catch (error) {
console.error('Error fetching mobile company filter:', error);
return { join: 'INNER JOIN companies c ON c.id = t.company_id', condition: 'c.company_category_id = 1' };
}
}
// ─── Exported response interfaces ────────────────────────────────────────────
export interface MobileTicket {
id: number;
ticket_number: string;
title: string;
status: number;
priority: number;
create_date: string;
last_activity_date: string;
due_date_time: string | null;
queue_id: number;
queue_label: string;
company_name: string;
assigned_to: string;
}
export interface MobileTicketListResponse {
tickets: MobileTicket[];
nextCursor: string | null;
hasMore: boolean;
}
// ─── Cursor encode/decode helpers (not exported — D-09) ──────────────────────
interface CursorPayload { last_activity_date: string; id: number; }
function encodeCursor(p: CursorPayload): string {
return Buffer.from(JSON.stringify(p), 'utf8').toString('base64');
}
function decodeCursor(raw: string | null): CursorPayload | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') {
return parsed as CursorPayload;
}
return null;
} catch { return null; }
}
// ─── Default status filter: Open (1) + In Progress (8) + Waiting (7) ─────────
// Matches the legacy route's `t.status != 5` default behaviour for managers.
const DEFAULT_STATUSES = [1, 8, 7];
// ─── GET handler ──────────────────────────────────────────────────────────────
export async function GET(request: NextRequest): Promise<NextResponse> {
const { session, error: authError } = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = request.nextUrl;
// Parse query params
const q = searchParams.get('q') ?? '';
const statusParam = searchParams.get('status');
const priorityParam = searchParams.get('priority');
const queueParam = searchParams.get('queue');
const mineParam = searchParams.get('mine');
const cursorParam = searchParams.get('cursor');
// Server-side limit cap — D-11
const limit = Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25')));
// Status: default to [1, 8, 7] when param absent; empty string = no filter
let statusIds: number[] = DEFAULT_STATUSES;
if (statusParam !== null) {
if (statusParam === '') {
statusIds = [];
} else {
const parsed = statusParam.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
statusIds = parsed.length > 0 ? parsed : DEFAULT_STATUSES;
}
}
// Priority: comma-separated ints; absent = no filter
const priorityIds: number[] = priorityParam
? priorityParam.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n))
: [];
// Queue: single int; absent = no filter
const queueId: number | null = queueParam ? parseInt(queueParam, 10) : null;
// Mine: filter by session user's email
const mine = mineParam === '1';
// Cursor: decode; null if missing or malformed
const cursor = decodeCursor(cursorParam);
// Build query
const { condition: companyCondition } = await getMobileCompanyFilter();
const conditions: string[] = [
't.is_deleted = false',
companyCondition,
];
const params: unknown[] = [];
// Status filter (always applied — defaults to [1, 8, 7] when no param)
if (statusIds.length > 0) {
params.push(statusIds);
conditions.push(`t.status = ANY($${params.length}::int[])`);
}
// Search
if (q) {
params.push(`%${q}%`);
conditions.push(`(t.title ILIKE $${params.length} OR t.ticket_number ILIKE $${params.length} OR c.company_name ILIKE $${params.length})`);
}
// Priority filter
if (priorityIds.length > 0) {
params.push(priorityIds);
conditions.push(`t.priority = ANY($${params.length}::int[])`);
}
// Queue filter
if (queueId !== null && !isNaN(queueId)) {
params.push(queueId);
conditions.push(`t.queue_id = $${params.length}`);
}
// Mine filter — email comes from verified session, never from query string (T-04-05)
if (mine && session?.user?.email) {
params.push(session.user.email);
conditions.push(`LOWER(r.email) = LOWER($${params.length})`);
}
// Cursor seek predicate — keyset pagination on (last_activity_date DESC, id DESC)
if (cursor) {
params.push(cursor.last_activity_date);
params.push(cursor.id);
conditions.push(`(t.last_activity_date, t.id) < ($${params.length - 1}::timestamp, $${params.length}::int)`);
}
const where = conditions.join(' AND ');
// Fetch limit+1 rows to detect hasMore without a COUNT query
const result = await postgresClient.query(`
SELECT t.id, t.ticket_number, t.title, t.status, t.priority,
t.create_date, t.last_activity_date, t.due_date_time,
t.queue_id, q.label AS queue_label,
c.company_name,
COALESCE(r.first_name || ' ' || r.last_name, '') AS assigned_to
FROM tickets t
INNER JOIN companies c ON c.id = t.company_id
LEFT JOIN queues q ON q.value = t.queue_id
LEFT JOIN resources r ON r.id = t.assigned_resource_id
WHERE ${where}
ORDER BY t.last_activity_date DESC NULLS LAST, t.id DESC
LIMIT ${limit + 1}
`, params);
const rows = result.rows;
const hasMore = rows.length > limit;
const sliced = hasMore ? rows.slice(0, limit) : rows;
const tickets: MobileTicket[] = sliced.map(row => ({
id: row.id,
ticket_number: row.ticket_number,
title: row.title,
status: row.status,
priority: row.priority,
create_date: row.create_date,
last_activity_date: row.last_activity_date,
due_date_time: row.due_date_time ?? null,
queue_id: row.queue_id,
queue_label: row.queue_label ?? '',
company_name: row.company_name ?? '',
assigned_to: row.assigned_to ?? '',
}));
const nextCursor = hasMore
? encodeCursor({
last_activity_date: tickets[tickets.length - 1].last_activity_date,
id: tickets[tickets.length - 1].id,
})
: null;
return NextResponse.json({ tickets, nextCursor, hasMore } satisfies MobileTicketListResponse);
} catch (error) {
console.error('GET /api/mobile/tickets failed:', error);
return NextResponse.json(
{ error: 'Failed to fetch tickets', message: error instanceof Error ? error.message : 'unknown' },
{ status: 500 },
);
}
}