/** * diff-ticket-680282.ts * * One-shot investigation for the AI Ticket Analyzer fixture build. * * Pulls TicketNotes and TimeEntries for ticket 680282 (T20260424.0045) from * BOTH Autotask (live REST) and the local Postgres mirror, then prints a diff. * * Why: the spec for the analyzer feature asserts a "I'll take it from here" * ticket note from the requestor that is not present in our synced ticket_notes * table. This script answers the question "is the note missing from Autotask * too, or is our sync incomplete?". * * Output: * - dev/analyzer-fixture/T20260424.0045.live.json (live Autotask payload) * - dev/analyzer-fixture/T20260424.0045.db.json (local DB payload) * - dev/analyzer-fixture/T20260424.0045.diff.json (id-level diff summary) * * Usage: * npx tsx scripts/diff-ticket-680282.ts */ import { config } from 'dotenv'; import { resolve } from 'path'; import { mkdirSync, writeFileSync } from 'fs'; import { Client } from 'pg'; config({ path: resolve(__dirname, '../.env.local') }); const TICKET_ID = 680282; const TICKET_NUMBER = 'T20260424.0045'; const API_BASE = process.env.AUTOTASK_API_URL!; const USERNAME = process.env.AUTOTASK_USERNAME!; const SECRET = process.env.AUTOTASK_SECRET!; const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!; if (!API_BASE || !USERNAME || !SECRET || !INT_CODE) { console.error('Missing Autotask credentials in env'); process.exit(1); } function authHeaders(): Record { return { Username: USERNAME, Secret: SECRET, APIIntegrationcode: INT_CODE, 'Content-Type': 'application/json', Accept: 'application/json', }; } async function queryAll(entity: string, filter: object[]): Promise { const all: T[] = []; let nextUrl: string | null = null; const requestBody = JSON.stringify({ MaxRecords: 500, filter }); while (true) { const url = nextUrl ?? `${API_BASE}/${entity}/query`; const res = await fetch(url, { method: 'POST', headers: authHeaders(), body: requestBody, }); if (!res.ok) { throw new Error(`${entity} query failed: ${res.status} ${await res.text()}`); } const payload = (await res.json()) as { items: T[]; pageDetails?: { nextPageUrl?: string }; }; all.push(...(payload.items || [])); if (payload.pageDetails?.nextPageUrl) { nextUrl = payload.pageDetails.nextPageUrl; } else { break; } } return all; } interface ATTicketNote { id: number; ticketID: number; title?: string; description?: string; noteType?: number; publish?: number; creatorResourceID?: number | null; creatorType?: number | null; contactID?: number | null; lastActivityDate?: string; createDateTime?: string; } interface ATTimeEntry { id: number; ticketID: number; resourceID: number; hoursWorked: number; summaryNotes?: string; internalNotes?: string; dateWorked?: string; startDateTime?: string; endDateTime?: string; type?: number; } interface ATTicket { id: number; ticketNumber: string; title: string; status: number; priority: number; queueID: number | null; companyID: number; contactID: number | null; assignedResourceID: number | null; description?: string; createDate: string; lastActivityDate: string; resolvedDateTime?: string; } async function getDbConnection(): Promise { const client = new Client({ host: process.env.POSTGRES_HOST === 'postgres' ? 'localhost' : (process.env.POSTGRES_HOST || 'localhost'), port: parseInt(process.env.POSTGRES_PORT || '5432'), database: process.env.POSTGRES_DB!, user: process.env.POSTGRES_USER!, password: process.env.POSTGRES_PASSWORD!, }); await client.connect(); return client; } async function main() { const outDir = resolve(__dirname, '../dev/analyzer-fixture'); mkdirSync(outDir, { recursive: true }); console.log(`\n== Ticket ${TICKET_NUMBER} (id=${TICKET_ID}) ==`); // ── Live Autotask fetch ──────────────────────────────────────────────────── console.log('\n[live] fetching Autotask Tickets/TicketNotes/TimeEntries...'); const liveTickets = await queryAll('Tickets', [ { op: 'eq', field: 'id', value: TICKET_ID }, ]); const liveNotes = await queryAll('TicketNotes', [ { op: 'eq', field: 'ticketID', value: TICKET_ID }, ]); const liveEntries = await queryAll('TimeEntries', [ { op: 'eq', field: 'ticketID', value: TICKET_ID }, ]); console.log(` live: ticket=${liveTickets.length} notes=${liveNotes.length} time_entries=${liveEntries.length}`); // ── DB fetch ─────────────────────────────────────────────────────────────── console.log('\n[db] querying local Postgres...'); const db = await getDbConnection(); try { const dbTicket = await db.query( `SELECT id, ticket_number, title, status, priority, queue_id, company_id, contact_id, assigned_resource_id, description, create_date, last_activity_date, resolved_date_time FROM tickets WHERE id = $1`, [TICKET_ID] ); const dbNotes = await db.query( `SELECT id, ticket_id, title, description, note_type, publish, creator_resource_id, creator_type, last_activity_date, create_date_time, is_deleted FROM ticket_notes WHERE ticket_id = $1 ORDER BY create_date_time`, [TICKET_ID] ); const dbEntries = await db.query( `SELECT id, ticket_id, resource_id, hours_worked, notes, internal_notes, entry_date, start_date_time, end_date_time, type, is_deleted FROM time_entries WHERE ticket_id = $1 ORDER BY entry_date, id`, [TICKET_ID] ); console.log(` db: ticket=${dbTicket.rowCount} notes=${dbNotes.rowCount} time_entries=${dbEntries.rowCount}`); // ── Write raw payloads ──────────────────────────────────────────────────── writeFileSync( `${outDir}/T20260424.0045.live.json`, JSON.stringify( { ticket: liveTickets[0] ?? null, notes: liveNotes, time_entries: liveEntries }, null, 2 ) ); writeFileSync( `${outDir}/T20260424.0045.db.json`, JSON.stringify( { ticket: dbTicket.rows[0] ?? null, notes: dbNotes.rows, time_entries: dbEntries.rows, }, null, 2 ) ); // ── Diff ────────────────────────────────────────────────────────────────── const liveNoteIds = new Set(liveNotes.map((n) => n.id)); const dbNoteIds = new Set(dbNotes.rows.map((r) => Number(r.id))); const liveEntryIds = new Set(liveEntries.map((e) => e.id)); const dbEntryIds = new Set(dbEntries.rows.map((r) => Number(r.id))); const notesOnlyInLive = [...liveNoteIds].filter((id) => !dbNoteIds.has(id)); const notesOnlyInDb = [...dbNoteIds].filter((id) => !liveNoteIds.has(id)); const entriesOnlyInLive = [...liveEntryIds].filter((id) => !dbEntryIds.has(id)); const entriesOnlyInDb = [...dbEntryIds].filter((id) => !liveEntryIds.has(id)); const diff = { ticket_id: TICKET_ID, ticket_number: TICKET_NUMBER, counts: { live_notes: liveNotes.length, db_notes: dbNotes.rowCount, live_time_entries: liveEntries.length, db_time_entries: dbEntries.rowCount, }, notes_only_in_live: notesOnlyInLive.map((id) => { const n = liveNotes.find((x) => x.id === id)!; return { id: n.id, title: n.title, noteType: n.noteType, publish: n.publish, creatorResourceID: n.creatorResourceID, creatorType: n.creatorType, createDateTime: n.createDateTime, description_preview: (n.description || '').slice(0, 240), }; }), notes_only_in_db: notesOnlyInDb, entries_only_in_live: entriesOnlyInLive.map((id) => { const e = liveEntries.find((x) => x.id === id)!; return { id: e.id, dateWorked: e.dateWorked, hoursWorked: e.hoursWorked, summary_preview: (e.summaryNotes || '').slice(0, 240), }; }), entries_only_in_db: entriesOnlyInDb, }; writeFileSync(`${outDir}/T20260424.0045.diff.json`, JSON.stringify(diff, null, 2)); console.log('\n== DIFF =='); console.log(JSON.stringify(diff, null, 2)); console.log(`\nWrote: ${outDir}/T20260424.0045.{live,db,diff}.json`); } finally { await db.end(); } } main().catch((err) => { console.error(err); process.exit(1); });