From 5dfd68affd40985af390f502a3e2f4c887ed60c3 Mon Sep 17 00:00:00 2001 From: lorentz Date: Mon, 23 Mar 2026 11:09:29 -0400 Subject: [PATCH] perf: switch ticket modal from live API to Postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/tickets/[id]/notes/route.ts | 54 ++++++--------- app/api/tickets/[id]/time-entries/route.ts | 53 +++++---------- .../tickets/by-number/[ticketNumber]/route.ts | 68 +++++++++++++++++++ components/quotes/ticket-detail-modal.tsx | 19 ++---- 4 files changed, 112 insertions(+), 82 deletions(-) create mode 100644 app/api/tickets/by-number/[ticketNumber]/route.ts diff --git a/app/api/tickets/[id]/notes/route.ts b/app/api/tickets/[id]/notes/route.ts index ec624f0..2b8766d 100644 --- a/app/api/tickets/[id]/notes/route.ts +++ b/app/api/tickets/[id]/notes/route.ts @@ -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 = {}; - 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( diff --git a/app/api/tickets/[id]/time-entries/route.ts b/app/api/tickets/[id]/time-entries/route.ts index b9197e7..137e26f 100644 --- a/app/api/tickets/[id]/time-entries/route.ts +++ b/app/api/tickets/[id]/time-entries/route.ts @@ -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( diff --git a/app/api/tickets/by-number/[ticketNumber]/route.ts b/app/api/tickets/by-number/[ticketNumber]/route.ts new file mode 100644 index 0000000..02ef5dd --- /dev/null +++ b/app/api/tickets/by-number/[ticketNumber]/route.ts @@ -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 | null = null; +let _priorityPicklist: Record | 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 }); + } +} diff --git a/components/quotes/ticket-detail-modal.tsx b/components/quotes/ticket-detail-modal.tsx index a851681..15f8016 100644 --- a/components/quotes/ticket-detail-modal.tsx +++ b/components/quotes/ticket-detail-modal.tsx @@ -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');