147 lines
4.9 KiB
TypeScript
147 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);
|
|||
|
|
});
|