/** * Data-access layer for the AI Ticket Analyzer. * * Loads a ticket from the local Postgres mirror in the exact shape the * pre-processor consumes (header + ticket_notes + time_entries with creator * names/emails resolved by JOIN). * * After the ticket-notes sync fix landed (lib/services/entity-sync syncTicketNotes * + scheduled incremental sync), the local DB is the canonical source for an * analyzer run. */ import postgresClient from '@/lib/services/postgres-client'; import type { RawTicketBundle } from './preprocessor'; export class TicketNotFoundError extends Error { constructor(public ticketNumber: string) { super(`Ticket ${ticketNumber} not found in local mirror`); this.name = 'TicketNotFoundError'; } } interface TicketRow { id: string; ticket_number: string; title: string; description: string | null; status: number; status_label: string | null; priority: number; priority_label: string | null; queue_id: number | null; queue_label: string | null; company_id: string; company_name: string | null; contact_id: string | null; contact_name: string | null; contact_email: string | null; assigned_resource_id: string | null; assignee_name: string | null; assignee_email: string | null; create_date: Date | string; last_activity_date: Date | string; resolved_date_time: Date | string | null; } interface NoteRow { id: string; title: string | null; description: string; note_type: number | null; publish: number | null; creator_resource_id: string | null; creator_name: string | null; creator_email: string | null; creator_type: number | null; create_date_time: Date | string | null; } interface TimeEntryRow { id: string; resource_id: string; resource_name: string | null; resource_email: string | null; hours_worked: string; notes: string | null; internal_notes: string | null; entry_date: Date | string | null; start_date_time: Date | string | null; end_date_time: Date | string | null; type: number | null; } function toIso(d: Date | string | null): string | null { if (d === null || d === undefined) return null; if (d instanceof Date) return d.toISOString(); return new Date(d).toISOString(); } function toIsoRequired(d: Date | string): string { if (d instanceof Date) return d.toISOString(); return new Date(d).toISOString(); } export async function loadTicketBundle( ticketNumber: string ): Promise { const ticketRes = await postgresClient.query( ` SELECT t.id::text AS id, t.ticket_number AS ticket_number, t.title AS title, t.description AS description, t.status AS status, (SELECT label FROM statuses WHERE value = t.status) AS status_label, t.priority AS priority, (SELECT label FROM priorities WHERE value = t.priority) AS priority_label, t.queue_id AS queue_id, (SELECT label FROM queues WHERE value = t.queue_id) AS queue_label, t.company_id::text AS company_id, (SELECT company_name FROM companies WHERE id = t.company_id) AS company_name, t.contact_id::text AS contact_id, (SELECT TRIM(first_name || ' ' || last_name) FROM contacts WHERE id = t.contact_id) AS contact_name, (SELECT email_address FROM contacts WHERE id = t.contact_id) AS contact_email, t.assigned_resource_id::text AS assigned_resource_id, (SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = t.assigned_resource_id) AS assignee_name, (SELECT email FROM resources WHERE id = t.assigned_resource_id) AS assignee_email, t.create_date AS create_date, t.last_activity_date AS last_activity_date, t.resolved_date_time AS resolved_date_time FROM tickets t WHERE t.ticket_number = $1 AND COALESCE(t.is_deleted, false) = false LIMIT 1 `, [ticketNumber] ); if (ticketRes.rowCount === 0) { throw new TicketNotFoundError(ticketNumber); } const t = ticketRes.rows[0]; const ticketIdNum = Number(t.id); const notesRes = await postgresClient.query( ` SELECT n.id::text AS id, n.title AS title, n.description AS description, n.note_type AS note_type, n.publish AS publish, n.creator_resource_id::text AS creator_resource_id, (SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = n.creator_resource_id) AS creator_name, (SELECT email FROM resources WHERE id = n.creator_resource_id) AS creator_email, n.creator_type AS creator_type, n.create_date_time AS create_date_time FROM ticket_notes n WHERE n.ticket_id = $1 AND COALESCE(n.is_deleted, false) = false ORDER BY n.create_date_time `, [ticketIdNum] ); const entriesRes = await postgresClient.query( ` SELECT te.id::text AS id, te.resource_id::text AS resource_id, (SELECT TRIM(first_name || ' ' || last_name) FROM resources WHERE id = te.resource_id) AS resource_name, (SELECT email FROM resources WHERE id = te.resource_id) AS resource_email, te.hours_worked::text AS hours_worked, te.notes AS notes, te.internal_notes AS internal_notes, te.entry_date AS entry_date, te.start_date_time AS start_date_time, te.end_date_time AS end_date_time, te.type AS type FROM time_entries te WHERE te.ticket_id = $1 AND COALESCE(te.is_deleted, false) = false ORDER BY te.entry_date, te.id `, [ticketIdNum] ); return { ticket: { id: ticketIdNum, ticket_number: t.ticket_number, title: t.title, description: t.description, status: t.status, status_label: t.status_label, priority: t.priority, priority_label: t.priority_label, queue_id: t.queue_id, queue_label: t.queue_label, company_id: Number(t.company_id), company_name: t.company_name, contact_id: t.contact_id ? Number(t.contact_id) : null, contact_name: t.contact_name, contact_email: t.contact_email, assigned_resource_id: t.assigned_resource_id ? Number(t.assigned_resource_id) : null, assignee_name: t.assignee_name, assignee_email: t.assignee_email, create_date: toIsoRequired(t.create_date), last_activity_date: toIsoRequired(t.last_activity_date), resolved_date_time: toIso(t.resolved_date_time), }, notes: notesRes.rows.map((n) => ({ id: Number(n.id), title: n.title, description: n.description ?? '', note_type: n.note_type, publish: n.publish, creator_resource_id: n.creator_resource_id ? Number(n.creator_resource_id) : null, creator_name: n.creator_name, creator_email: n.creator_email, creator_type: n.creator_type, create_date_time: toIso(n.create_date_time), })), time_entries: entriesRes.rows.map((e) => ({ id: Number(e.id), resource_id: Number(e.resource_id), resource_name: e.resource_name, resource_email: e.resource_email, hours_worked: Number(e.hours_worked), notes: e.notes, internal_notes: e.internal_notes, entry_date: toIso(e.entry_date), start_date_time: toIso(e.start_date_time), end_date_time: toIso(e.end_date_time), type: e.type, })), }; }