wulf-pulse/scripts/backfill-ticket-notes-gap.ts

147 lines
4.9 KiB
TypeScript
Raw Normal View History

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