perf: switch ticket modal from live API to Postgres

- New GET /api/tickets/by-number/[ticketNumber]: single query with resources
  join, returns ticket + picklist labels (status/priority cached per deploy)
- /api/tickets/[id]/notes: Postgres ticket_notes + resources join (was N+1 API calls)
- /api/tickets/[id]/time-entries: Postgres time_entries + resources join (was N+1 API calls)
- Modal: replaces 2-step fetch (all tickets → by id) with single by-number lookup
This commit is contained in:
lorentz 2026-03-23 11:09:29 -04:00
parent cb4552a3eb
commit 5dfd68affd
4 changed files with 112 additions and 82 deletions

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(
request: NextRequest,
@ -7,42 +7,30 @@ export async function GET(
) {
try {
const { id } = await params;
const client = getAutotaskClient();
const notes = await client.queryEntity('TicketNotes', {
filter: [
{ op: 'eq', field: 'ticketID', value: parseInt(id) }
],
});
// Enrich with resource names
const resourceCache: Record<number, string> = {};
const enriched = await Promise.all(
(notes as any[]).map(async (note) => {
let creatorName = null;
const resourceId = note.creatorResourceID;
if (resourceId) {
if (resourceCache[resourceId]) {
creatorName = resourceCache[resourceId];
} else {
try {
const resource = await client.getEntityById('Resources', resourceId) as any;
creatorName = resource ? `${resource.firstName} ${resource.lastName}` : null;
if (creatorName) resourceCache[resourceId] = creatorName;
} catch {
// ignore
}
}
}
return { ...note, creatorName };
})
const result = await postgresClient.query(
`SELECT n.*,
r.first_name || ' ' || r.last_name AS creator_name
FROM ticket_notes n
LEFT JOIN resources r ON r.id = n.creator_resource_id
WHERE n.ticket_id = $1
AND (n.is_deleted = false OR n.is_deleted IS NULL)
ORDER BY n.create_date_time DESC`,
[id]
);
enriched.sort((a, b) =>
new Date(b.createDateTime || 0).getTime() - new Date(a.createDateTime || 0).getTime()
);
const notes = result.rows.map((row) => ({
id: Number(row.id),
noteType: row.note_type,
title: row.title,
description: row.description,
createDateTime: row.create_date_time,
creatorResourceId: row.creator_resource_id ? Number(row.creator_resource_id) : null,
creatorName: row.creator_name ?? null,
publish: row.publish,
}));
return NextResponse.json({ notes: enriched });
return NextResponse.json({ notes });
} catch (error) {
console.error('Error fetching ticket notes:', error);
return NextResponse.json(

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(
request: NextRequest,
@ -7,44 +7,27 @@ export async function GET(
) {
try {
const { id } = await params;
const client = getAutotaskClient();
// Get time entries for this ticket
const timeEntries = await client.queryEntity('TimeEntries', {
filter: [
{ op: 'eq', field: 'ticketID', value: parseInt(id) }
],
});
// Enrich with resource names
const enrichedEntries = await Promise.all(
timeEntries.map(async (entry: any) => {
let resourceName = null;
if (entry.resourceID) {
try {
const resource = await client.getEntityById('Resources', entry.resourceID) as any;
resourceName = resource ? `${resource.firstName} ${resource.lastName}` : null;
} catch (err) {
console.error('Error fetching resource:', err);
}
}
return {
...entry,
resourceName,
};
})
const result = await postgresClient.query(
`SELECT te.*,
r.first_name || ' ' || r.last_name AS resource_name
FROM time_entries te
LEFT JOIN resources r ON r.id = te.resource_id
WHERE te.ticket_id = $1
ORDER BY te.entry_date DESC`,
[id]
);
// Sort by date worked (most recent first)
enrichedEntries.sort((a: any, b: any) => {
const dateA = new Date(a.dateWorked || 0).getTime();
const dateB = new Date(b.dateWorked || 0).getTime();
return dateB - dateA;
});
const timeEntries = result.rows.map((row) => ({
id: Number(row.id),
hoursWorked: row.hours_worked ? parseFloat(row.hours_worked) : 0,
dateWorked: row.entry_date,
summaryNotes: row.notes,
resourceName: row.resource_name ?? null,
billable: row.billable,
}));
return NextResponse.json({
timeEntries: enrichedEntries,
});
return NextResponse.json({ timeEntries });
} catch (error) {
console.error('Error fetching time entries:', error);
return NextResponse.json(

View file

@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import { getAutotaskClient } from '@/lib/services/autotask-factory';
let _statusPicklist: Record<number, string> | null = null;
let _priorityPicklist: Record<number, string> | null = null;
async function getPicklists() {
const client = getAutotaskClient();
if (!_statusPicklist) {
try { _statusPicklist = await client.getTicketStatusPicklist(); } catch { _statusPicklist = {}; }
}
if (!_priorityPicklist) {
try { _priorityPicklist = await client.getTicketPriorityPicklist(); } catch { _priorityPicklist = {}; }
}
return { statusPicklist: _statusPicklist!, priorityPicklist: _priorityPicklist! };
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ ticketNumber: string }> }
) {
try {
const { ticketNumber } = await params;
const [result, { statusPicklist, priorityPicklist }] = await Promise.all([
postgresClient.query(
`SELECT t.*,
r.first_name || ' ' || r.last_name AS assigned_resource_name
FROM tickets t
LEFT JOIN resources r ON r.id = t.assigned_resource_id
WHERE t.ticket_number = $1
AND t.is_deleted = false
LIMIT 1`,
[ticketNumber]
),
getPicklists(),
]);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Ticket not found' }, { status: 404 });
}
const row = result.rows[0];
const ticket = {
id: Number(row.id),
ticketNumber: row.ticket_number,
title: row.title,
description: row.description,
resolution: row.resolution,
status: row.status,
priority: row.priority,
statusLabel: statusPicklist[row.status] ?? null,
priorityLabel: priorityPicklist[row.priority] ?? null,
assignedResourceName: row.assigned_resource_name ?? null,
createDate: row.create_date,
dueDateTime: row.due_date_time,
resolvedDateTime: row.resolved_date_time,
purchaseOrderNumber: row.purchase_order_number ?? null,
};
return NextResponse.json({ ticket });
} catch (error) {
console.error('Error fetching ticket by number:', error);
return NextResponse.json({ error: 'Failed to fetch ticket' }, { status: 500 });
}
}

View file

@ -66,23 +66,14 @@ export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDe
setTimelineOpen(false);
setShowSystem(false);
try {
const response = await fetch('/api/tickets');
const response = await fetch(`/api/tickets/by-number/${encodeURIComponent(ticketNumber)}`);
if (response.ok) {
const data = await response.json();
const matchingTicket = data.tickets?.find((t: any) => t.ticketNumber === ticketNumber);
if (matchingTicket) {
const detailResponse = await fetch(`/api/tickets/${matchingTicket.id}`);
if (detailResponse.ok) {
const detailData = await detailResponse.json();
setTicket(detailData.ticket);
} else {
setError('Failed to fetch ticket details');
}
} else {
setError(`Ticket ${ticketNumber} not found`);
}
setTicket(data.ticket);
} else if (response.status === 404) {
setError(`Ticket ${ticketNumber} not found`);
} else {
setError('Failed to fetch tickets list');
setError('Failed to fetch ticket details');
}
} catch (err) {
setError('Failed to fetch ticket');