- 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
220 lines
8.2 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|