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