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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|
@ -7,42 +7,30 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const client = getAutotaskClient();
|
|
||||||
|
|
||||||
const notes = await client.queryEntity('TicketNotes', {
|
const result = await postgresClient.query(
|
||||||
filter: [
|
`SELECT n.*,
|
||||||
{ op: 'eq', field: 'ticketID', value: parseInt(id) }
|
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
|
||||||
// Enrich with resource names
|
AND (n.is_deleted = false OR n.is_deleted IS NULL)
|
||||||
const resourceCache: Record<number, string> = {};
|
ORDER BY n.create_date_time DESC`,
|
||||||
const enriched = await Promise.all(
|
[id]
|
||||||
(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 };
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
enriched.sort((a, b) =>
|
const notes = result.rows.map((row) => ({
|
||||||
new Date(b.createDateTime || 0).getTime() - new Date(a.createDateTime || 0).getTime()
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching ticket notes:', error);
|
console.error('Error fetching ticket notes:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getAutotaskClient } from '@/lib/services/autotask-factory';
|
import { postgresClient } from '@/lib/services/postgres-client';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|
@ -7,44 +7,27 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
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 result = await postgresClient.query(
|
||||||
const enrichedEntries = await Promise.all(
|
`SELECT te.*,
|
||||||
timeEntries.map(async (entry: any) => {
|
r.first_name || ' ' || r.last_name AS resource_name
|
||||||
let resourceName = null;
|
FROM time_entries te
|
||||||
if (entry.resourceID) {
|
LEFT JOIN resources r ON r.id = te.resource_id
|
||||||
try {
|
WHERE te.ticket_id = $1
|
||||||
const resource = await client.getEntityById('Resources', entry.resourceID) as any;
|
ORDER BY te.entry_date DESC`,
|
||||||
resourceName = resource ? `${resource.firstName} ${resource.lastName}` : null;
|
[id]
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching resource:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...entry,
|
|
||||||
resourceName,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort by date worked (most recent first)
|
const timeEntries = result.rows.map((row) => ({
|
||||||
enrichedEntries.sort((a: any, b: any) => {
|
id: Number(row.id),
|
||||||
const dateA = new Date(a.dateWorked || 0).getTime();
|
hoursWorked: row.hours_worked ? parseFloat(row.hours_worked) : 0,
|
||||||
const dateB = new Date(b.dateWorked || 0).getTime();
|
dateWorked: row.entry_date,
|
||||||
return dateB - dateA;
|
summaryNotes: row.notes,
|
||||||
});
|
resourceName: row.resource_name ?? null,
|
||||||
|
billable: row.billable,
|
||||||
|
}));
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({ timeEntries });
|
||||||
timeEntries: enrichedEntries,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching time entries:', error);
|
console.error('Error fetching time entries:', error);
|
||||||
return NextResponse.json(
|
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);
|
setTimelineOpen(false);
|
||||||
setShowSystem(false);
|
setShowSystem(false);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/tickets');
|
const response = await fetch(`/api/tickets/by-number/${encodeURIComponent(ticketNumber)}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const matchingTicket = data.tickets?.find((t: any) => t.ticketNumber === ticketNumber);
|
setTicket(data.ticket);
|
||||||
if (matchingTicket) {
|
} else if (response.status === 404) {
|
||||||
const detailResponse = await fetch(`/api/tickets/${matchingTicket.id}`);
|
setError(`Ticket ${ticketNumber} not found`);
|
||||||
if (detailResponse.ok) {
|
|
||||||
const detailData = await detailResponse.json();
|
|
||||||
setTicket(detailData.ticket);
|
|
||||||
} else {
|
|
||||||
setError('Failed to fetch ticket details');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(`Ticket ${ticketNumber} not found`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setError('Failed to fetch tickets list');
|
setError('Failed to fetch ticket details');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to fetch ticket');
|
setError('Failed to fetch ticket');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue