wulf-pulse/scripts/backfill-ticket-notes-gap.ts
lorentz 8f8b5ab7be 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>
2026-04-29 10:59:40 -04:00

146 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
});