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:
parent
cb4552a3eb
commit
5dfd68affd
4 changed files with 112 additions and 82 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
68
app/api/tickets/by-number/[ticketNumber]/route.ts
Normal file
68
app/api/tickets/by-number/[ticketNumber]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue