feat: AI ticket analyzer (phases 1-6)
Multi-stage LLM pipeline that produces structured analyses of Autotask tickets from local Postgres. Migration 069 + Zod schemas, Stage 0 preprocessor, IT Glue redaction + search, Anthropic SDK wrapper, Stages 1/3/4 (Haiku/Sonnet/Opus), pipeline + cost circuit breaker, job worker (opt-in autostart), 6 API routes, 3 frontend pages, share-row persistence (email send deferred to phase 7). 128 vitest tests, tsc clean. Build journal in docs/wulf-pulse-ticket-analyzer-build-notes.md. Sync: adds syncTicketNotes() + ticket_notes to ordered/date-filtered entities so the analyzer's local mirror stays current via scheduler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ea3471d38d
commit
8f8b5ab7be
53 changed files with 9377 additions and 33 deletions
146
scripts/backfill-ticket-notes-gap.ts
Normal file
146
scripts/backfill-ticket-notes-gap.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* backfill-ticket-notes-gap.ts
|
||||
*
|
||||
* Reconciles the ticket_notes table for the 2026-04-24 → 2026-04-26 webhook
|
||||
* outage. Pulls every Autotask TicketNote whose lastActivityDate falls inside
|
||||
* the window (with a small overlap on each side) and upserts via
|
||||
* postgresClient.bulkUpsert. Idempotent — rows already in the DB are refreshed,
|
||||
* not duplicated.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/backfill-ticket-notes-gap.ts # default window
|
||||
* npx tsx scripts/backfill-ticket-notes-gap.ts 2026-04-23 2026-04-28
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../.env.local') });
|
||||
|
||||
// Force the singleton postgres-client to use localhost rather than the docker
|
||||
// hostname `postgres`, which only resolves inside the compose network.
|
||||
if (process.env.POSTGRES_HOST === 'postgres') {
|
||||
process.env.POSTGRES_HOST = 'localhost';
|
||||
}
|
||||
|
||||
import postgresClient from '../lib/services/postgres-client';
|
||||
import { mapAutotaskBatch } from '../lib/utils/entity-mapper';
|
||||
import { EntityType } from '../lib/types/sync';
|
||||
|
||||
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 fetchAllNotesInWindow(startIso: string, endIso: string) {
|
||||
const all: any[] = [];
|
||||
const filter = [
|
||||
{ field: 'lastActivityDate', op: 'gte', value: startIso },
|
||||
{ field: 'lastActivityDate', op: 'lte', value: endIso },
|
||||
];
|
||||
|
||||
let nextUrl: string | null = null;
|
||||
let page = 0;
|
||||
while (true) {
|
||||
const url = nextUrl ?? `${API_BASE}/TicketNotes/query`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({ MaxRecords: 500, filter }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`TicketNotes query failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
const payload = (await res.json()) as {
|
||||
items?: any[];
|
||||
pageDetails?: { nextPageUrl?: string };
|
||||
};
|
||||
const items = payload.items ?? [];
|
||||
all.push(...items);
|
||||
page++;
|
||||
console.log(` page ${page}: +${items.length} (running total ${all.length})`);
|
||||
if (payload.pageDetails?.nextPageUrl) {
|
||||
nextUrl = payload.pageDetails.nextPageUrl;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , startArg, endArg] = process.argv;
|
||||
const startIso = startArg
|
||||
? new Date(startArg).toISOString()
|
||||
: '2026-04-23T00:00:00Z';
|
||||
const endIso = endArg
|
||||
? new Date(endArg).toISOString()
|
||||
: '2026-04-27T13:00:00Z';
|
||||
|
||||
console.log(`[backfill] window: ${startIso} -> ${endIso}`);
|
||||
const before = await postgresClient.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM ticket_notes WHERE last_activity_date >= $1 AND last_activity_date <= $2`,
|
||||
[startIso, endIso]
|
||||
);
|
||||
console.log(`[backfill] rows in DB before: ${before.rows[0].count}`);
|
||||
|
||||
console.log('[backfill] fetching from Autotask...');
|
||||
const liveNotes = await fetchAllNotesInWindow(startIso, endIso);
|
||||
console.log(`[backfill] fetched ${liveNotes.length} notes from Autotask`);
|
||||
|
||||
if (liveNotes.length === 0) {
|
||||
console.log('[backfill] nothing to upsert');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Map via the project's entity-mapper so the row shape matches what the
|
||||
// webhook handler / sync would produce.
|
||||
const mapped = mapAutotaskBatch(EntityType.TICKET_NOTES, liveNotes);
|
||||
console.log(`[backfill] mapped ${mapped.length} records`);
|
||||
|
||||
// Sample one record so the sanity-check is visible in the script log.
|
||||
console.log('[backfill] sample mapped row:', JSON.stringify(mapped[0], null, 2));
|
||||
|
||||
// Bulk upsert in chunks of 200 to stay well below the parameter limit
|
||||
// (~10 columns × 200 = 2,000 params per statement).
|
||||
const CHUNK = 200;
|
||||
let totalUpserted = 0;
|
||||
for (let i = 0; i < mapped.length; i += CHUNK) {
|
||||
const chunk = mapped.slice(i, i + CHUNK);
|
||||
const n = await postgresClient.bulkUpsert('ticket_notes', chunk, ['id']);
|
||||
totalUpserted += n;
|
||||
console.log(`[backfill] upserted ${i + chunk.length}/${mapped.length}`);
|
||||
}
|
||||
|
||||
const after = await postgresClient.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM ticket_notes WHERE last_activity_date >= $1 AND last_activity_date <= $2`,
|
||||
[startIso, endIso]
|
||||
);
|
||||
console.log(`[backfill] rows in DB after: ${after.rows[0].count}`);
|
||||
console.log(`[backfill] upsert ops: ${totalUpserted}`);
|
||||
console.log(
|
||||
`[backfill] net new rows: ${parseInt(after.rows[0].count) - parseInt(before.rows[0].count)}`
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
318
scripts/build-analyzer-fixture-T20260424.0045.ts
Normal file
318
scripts/build-analyzer-fixture-T20260424.0045.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* build-analyzer-fixture-T20260424.0045.ts
|
||||
*
|
||||
* Produces the canonical regression fixture for the AI Ticket Analyzer pipeline,
|
||||
* built from LIVE Autotask data (the DB sync is incomplete for this ticket — see
|
||||
* dev/analyzer-fixture/T20260424.0045.diff.json).
|
||||
*
|
||||
* Why live, not DB: only 2 of 9 ticket notes synced — including the spec-required
|
||||
* "I'll take it from here" note from the requestor. We need a regression fixture
|
||||
* with the full picture, otherwise the analyzer will appear to work while
|
||||
* silently missing the most important finding.
|
||||
*
|
||||
* Outputs:
|
||||
* - lib/services/analyzer/fixtures/T20260424.0045.input.json
|
||||
* - lib/services/analyzer/fixtures/T20260424.0045.expected.json
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/build-analyzer-fixture-T20260424.0045.ts
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { Client } from 'pg';
|
||||
|
||||
config({ path: resolve(__dirname, '../.env.local') });
|
||||
|
||||
const TICKET_ID = 680282;
|
||||
const LIVE_DUMP = resolve(__dirname, '../dev/analyzer-fixture/T20260424.0045.live.json');
|
||||
const OUT_DIR = resolve(__dirname, '../lib/services/analyzer/fixtures');
|
||||
|
||||
interface ATTicket {
|
||||
id: number;
|
||||
ticketNumber: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: number;
|
||||
priority: number;
|
||||
queueID: number | null;
|
||||
companyID: number;
|
||||
contactID: number | null;
|
||||
assignedResourceID: number | null;
|
||||
createDate: string;
|
||||
lastActivityDate: string;
|
||||
resolvedDateTime: string | null;
|
||||
}
|
||||
|
||||
interface ATTicketNote {
|
||||
id: number;
|
||||
ticketID: number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
noteType?: number;
|
||||
publish?: number;
|
||||
creatorResourceID?: number | null;
|
||||
creatorType?: number | null;
|
||||
contactID?: number | null;
|
||||
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 LiveDump {
|
||||
ticket: ATTicket;
|
||||
notes: ATTicketNote[];
|
||||
time_entries: ATTimeEntry[];
|
||||
}
|
||||
|
||||
async function getDb(): Promise<Client> {
|
||||
const c = 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 c.connect();
|
||||
return c;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const live = JSON.parse(readFileSync(LIVE_DUMP, 'utf8')) as LiveDump;
|
||||
if (!live.ticket || live.ticket.id !== TICKET_ID) {
|
||||
throw new Error(`Live dump missing or wrong ticket. Re-run scripts/diff-ticket-680282.ts first.`);
|
||||
}
|
||||
|
||||
// Resolve labels for the ticket header and actor names for notes/time entries.
|
||||
const db = await getDb();
|
||||
try {
|
||||
const labels = await db.query(
|
||||
`SELECT
|
||||
(SELECT label FROM statuses WHERE value=$1) AS status_label,
|
||||
(SELECT label FROM priorities WHERE value=$2) AS priority_label,
|
||||
(SELECT label FROM queues WHERE value=$3) AS queue_label,
|
||||
(SELECT company_name FROM companies WHERE id=$4) AS company_name,
|
||||
(SELECT first_name||' '||last_name FROM contacts WHERE id=$5) AS contact_name,
|
||||
(SELECT email_address FROM contacts WHERE id=$5) AS contact_email,
|
||||
(SELECT first_name||' '||last_name FROM resources WHERE id=$6) AS assignee_name,
|
||||
(SELECT email FROM resources WHERE id=$6) AS assignee_email`,
|
||||
[
|
||||
live.ticket.status,
|
||||
live.ticket.priority,
|
||||
live.ticket.queueID,
|
||||
live.ticket.companyID,
|
||||
live.ticket.contactID,
|
||||
live.ticket.assignedResourceID,
|
||||
]
|
||||
);
|
||||
const lbl = labels.rows[0];
|
||||
|
||||
const resourceIds = Array.from(
|
||||
new Set(
|
||||
[
|
||||
...live.notes.map((n) => n.creatorResourceID),
|
||||
...live.time_entries.map((e) => e.resourceID),
|
||||
].filter((x): x is number => typeof x === 'number')
|
||||
)
|
||||
);
|
||||
const resources = await db.query(
|
||||
`SELECT id, first_name, last_name, email FROM resources WHERE id = ANY($1::bigint[])`,
|
||||
[resourceIds]
|
||||
);
|
||||
const resourceMap = new Map<number, { name: string; email: string | null }>(
|
||||
resources.rows.map((r) => [
|
||||
Number(r.id),
|
||||
{ name: `${r.first_name ?? ''} ${r.last_name ?? ''}`.trim(), email: r.email ?? null },
|
||||
])
|
||||
);
|
||||
|
||||
// ── INPUT FIXTURE — shape matches what the data-access layer produces ────
|
||||
const input = {
|
||||
ticket: {
|
||||
id: live.ticket.id,
|
||||
ticket_number: live.ticket.ticketNumber,
|
||||
title: live.ticket.title,
|
||||
description: live.ticket.description ?? null,
|
||||
status: live.ticket.status,
|
||||
status_label: lbl.status_label ?? null,
|
||||
priority: live.ticket.priority,
|
||||
priority_label: lbl.priority_label ?? null,
|
||||
queue_id: live.ticket.queueID,
|
||||
queue_label: lbl.queue_label ?? null,
|
||||
company_id: live.ticket.companyID,
|
||||
company_name: lbl.company_name ?? null,
|
||||
contact_id: live.ticket.contactID,
|
||||
contact_name: lbl.contact_name ?? null,
|
||||
contact_email: lbl.contact_email ?? null,
|
||||
assigned_resource_id: live.ticket.assignedResourceID,
|
||||
assignee_name: lbl.assignee_name ?? null,
|
||||
assignee_email: lbl.assignee_email ?? null,
|
||||
create_date: live.ticket.createDate,
|
||||
last_activity_date: live.ticket.lastActivityDate,
|
||||
resolved_date_time: live.ticket.resolvedDateTime,
|
||||
},
|
||||
notes: live.notes
|
||||
.slice()
|
||||
.sort((a, b) => (a.createDateTime ?? '').localeCompare(b.createDateTime ?? ''))
|
||||
.map((n) => {
|
||||
const r = n.creatorResourceID ? resourceMap.get(n.creatorResourceID) : undefined;
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title ?? null,
|
||||
description: n.description ?? '',
|
||||
note_type: n.noteType ?? null,
|
||||
publish: n.publish ?? null,
|
||||
creator_resource_id: n.creatorResourceID ?? null,
|
||||
creator_name: r?.name ?? null,
|
||||
creator_email: r?.email ?? null,
|
||||
creator_type: n.creatorType ?? null,
|
||||
create_date_time: n.createDateTime ?? null,
|
||||
};
|
||||
}),
|
||||
time_entries: live.time_entries
|
||||
.slice()
|
||||
.sort((a, b) => (a.dateWorked ?? '').localeCompare(b.dateWorked ?? ''))
|
||||
.map((e) => {
|
||||
const r = resourceMap.get(e.resourceID);
|
||||
return {
|
||||
id: e.id,
|
||||
resource_id: e.resourceID,
|
||||
resource_name: r?.name ?? null,
|
||||
resource_email: r?.email ?? null,
|
||||
hours_worked: Number(e.hoursWorked),
|
||||
notes: e.summaryNotes ?? null, // Summary Notes — customer-visible
|
||||
internal_notes: e.internalNotes ?? null,
|
||||
entry_date: e.dateWorked ?? null,
|
||||
start_date_time: e.startDateTime ?? null,
|
||||
end_date_time: e.endDateTime ?? null,
|
||||
type: e.type ?? null,
|
||||
};
|
||||
}),
|
||||
provenance: {
|
||||
source: 'live_autotask_rest',
|
||||
captured_at: new Date().toISOString(),
|
||||
note:
|
||||
'DB sync was incomplete for this ticket (2/9 notes); fixture built from live Autotask to capture the spec-required "I\'ll take it from here" note (id=33738796) which was missing from ticket_notes.',
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(`${OUT_DIR}/T20260424.0045.input.json`, JSON.stringify(input, null, 2));
|
||||
|
||||
// ── EXPECTED FIXTURE — pre-processor + pipeline assertions ────────────────
|
||||
// Note IDs after Stage 0 filtering:
|
||||
// filter as workflow_noise: 33738631, 33738632, 33738633, 33738634
|
||||
// (creator_resource_id=4 "Autotask Administrator", title starts "Workflow Rule")
|
||||
// filter as email_notification: 33738776, 33738797, plus the two already-in-DB
|
||||
// Service Desk Notification entries (titles == "Service Desk Notification")
|
||||
// keep: 33738796 (Lorentz's "I'll take it from here" note)
|
||||
// plus: all 5 time entries (which contain the bulk of the real story)
|
||||
|
||||
const expected = {
|
||||
preprocessor: {
|
||||
filtered_workflow_noise_ids: [33738631, 33738632, 33738633, 33738634],
|
||||
filtered_email_notification_ids_in_input: live.notes
|
||||
.filter((n) => n.title === 'Service Desk Notification')
|
||||
.map((n) => n.id),
|
||||
retained_note_ids: live.notes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.creatorResourceID !== 4 &&
|
||||
n.title !== 'Service Desk Notification'
|
||||
)
|
||||
.map((n) => n.id),
|
||||
retained_time_entry_ids: live.time_entries.map((e) => e.id),
|
||||
// total_events expected = retained notes + time entries (mixed/internal/customer)
|
||||
},
|
||||
// Required findings the pipeline output MUST contain — each grounded in
|
||||
// real evidence in the input fixture. evidence_ids reference notes
|
||||
// (ticket_notes.id) and entries (time_entries.id) by primary key.
|
||||
required_findings: [
|
||||
{
|
||||
id: 'F1_original_ask_narrower',
|
||||
severity: 'low',
|
||||
description:
|
||||
'The original requestor email asked for a credential location for an existing integration (loss run pro / claims department), narrower than the broader vendor-integration scope the ticket pivoted to.',
|
||||
evidence: [
|
||||
{ kind: 'time_entry_internal_notes', id: 465933 },
|
||||
],
|
||||
grounded: true,
|
||||
},
|
||||
{
|
||||
id: 'F2_customer_said_stop',
|
||||
severity: 'high',
|
||||
description:
|
||||
'Requestor (Lorentz Hinrichsen) posted a ticket note on 2026-04-24 indicating he could proceed independently and that no further outreach to Vertafore was needed.',
|
||||
evidence: [
|
||||
{ kind: 'ticket_note', id: 33738796 },
|
||||
],
|
||||
grounded: true,
|
||||
notes_for_review:
|
||||
'This note is in Autotask but was NOT in the local ticket_notes table at fixture-build time. The analyzer must source notes either live or via a fixed sync.',
|
||||
},
|
||||
{
|
||||
id: 'F3_work_continued_after_stop',
|
||||
severity: 'high',
|
||||
description:
|
||||
'On 2026-04-27 (next business day after the requestor said "I\'ll take it from here"), the assigned tech took a call from Vertafore and logged ~20 minutes of additional work (entries on 04/27).',
|
||||
evidence: [
|
||||
{ kind: 'ticket_note', id: 33738796 }, // F2 — the stop signal
|
||||
{ kind: 'time_entry', id: 466134 }, // 04/27 call from Richard at Vertafore (0.17 hr)
|
||||
{ kind: 'time_entry', id: 466183 }, // 04/27 follow-up email captured (0.17 hr)
|
||||
],
|
||||
grounded: true,
|
||||
},
|
||||
{
|
||||
id: 'F4_status_does_not_match_reality',
|
||||
severity: 'medium',
|
||||
description:
|
||||
'Ticket status remains "Waiting Customer" though the requestor effectively closed the loop on 04/24. resolved_date_time is null three days later.',
|
||||
evidence: [
|
||||
{ kind: 'ticket_field', field: 'status_label', value: 'Waiting Customer' },
|
||||
{ kind: 'ticket_field', field: 'resolved_date_time', value: null },
|
||||
{ kind: 'ticket_note', id: 33738796 },
|
||||
],
|
||||
grounded: true,
|
||||
},
|
||||
],
|
||||
expected_next_step_keywords: [
|
||||
// The Sonnet-tier next_step should reference at least one of these.
|
||||
'confirm with requestor',
|
||||
'Vertafore',
|
||||
'close',
|
||||
],
|
||||
sync_gap_observed: {
|
||||
live_note_count: live.notes.length,
|
||||
synced_note_count: 2,
|
||||
missing_note_ids: live.notes.map((n) => n.id).filter((id) => ![33741514, 33741844].includes(id)),
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(`${OUT_DIR}/T20260424.0045.expected.json`, JSON.stringify(expected, null, 2));
|
||||
|
||||
console.log(`Wrote:\n ${OUT_DIR}/T20260424.0045.input.json\n ${OUT_DIR}/T20260424.0045.expected.json`);
|
||||
console.log(
|
||||
`\nFixture summary:\n ticket: ${input.ticket.ticket_number} (${input.ticket.status_label})\n notes: ${input.notes.length} time_entries: ${input.time_entries.length}\n retained_notes: ${expected.preprocessor.retained_note_ids.length} filtered_workflow: ${expected.preprocessor.filtered_workflow_noise_ids.length} filtered_email: ${expected.preprocessor.filtered_email_notification_ids_in_input.length}`
|
||||
);
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
266
scripts/diff-ticket-680282.ts
Normal file
266
scripts/diff-ticket-680282.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue