- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
8.3 KiB
TypeScript
230 lines
8.3 KiB
TypeScript
/**
|
|
* 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;
|
|
problem_ticket_id: 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,
|
|
t.problem_ticket_id::text AS problem_ticket_id
|
|
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),
|
|
problem_ticket_id: t.problem_ticket_id ? Number(t.problem_ticket_id) : null,
|
|
},
|
|
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,
|
|
})),
|
|
};
|
|
}
|