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>
146 lines
4.9 KiB
TypeScript
146 lines
4.9 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|