267 lines
8.8 KiB
TypeScript
267 lines
8.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, string> {
|
||
|
|
return {
|
||
|
|
Username: USERNAME,
|
||
|
|
Secret: SECRET,
|
||
|
|
APIIntegrationcode: INT_CODE,
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
Accept: 'application/json',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function queryAll<T>(entity: string, filter: object[]): Promise<T[]> {
|
||
|
|
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<Client> {
|
||
|
|
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<ATTicket>('Tickets', [
|
||
|
|
{ op: 'eq', field: 'id', value: TICKET_ID },
|
||
|
|
]);
|
||
|
|
const liveNotes = await queryAll<ATTicketNote>('TicketNotes', [
|
||
|
|
{ op: 'eq', field: 'ticketID', value: TICKET_ID },
|
||
|
|
]);
|
||
|
|
const liveEntries = await queryAll<ATTimeEntry>('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);
|
||
|
|
});
|