wulf-pulse/lib/services/analyzer/data-access.ts

228 lines
8.1 KiB
TypeScript
Raw Normal View History

/**
* 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<RawTicketBundle> {
const ticketRes = await postgresClient.query<TicketRow>(
`
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<NoteRow>(
`
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<TimeEntryRow>(
`
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,
})),
};
}