/** * deactivate-cis-for-inactive-companies.ts * * Finds all active Configuration Items whose parent company is inactive in * Autotask and deactivates them (sets isActive = false). * * Usage: * npx tsx scripts/deactivate-cis-for-inactive-companies.ts [options] * * Options: * --dry-run Preview changes without writing (default) * --commit Apply changes * --concurrency Parallel PATCH calls (default: 5) */ import { config } from 'dotenv'; import { resolve } from 'path'; config({ path: resolve(__dirname, '../.env') }); // ─── Config ─────────────────────────────────────────────────────────────────── 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!; // ─── Helpers ────────────────────────────────────────────────────────────────── function authHeaders(): Record { return { 'Username': USERNAME, 'Secret': SECRET, 'APIIntegrationcode': INT_CODE, 'Content-Type': 'application/json', 'Accept': 'application/json', }; } /** Paginate through all results of a POST /query endpoint */ async function queryAll(entity: string, filter: object[]): Promise { const all: T[] = []; let nextUrl: string | null = null; const requestBody = JSON.stringify({ MaxRecords: 500, filter }); while (true) { const url = nextUrl ?? `${API_BASE}/${entity}/query`; const res = await fetch(url, { method: 'POST', headers: authHeaders(), body: requestBody, }); if (!res.ok) throw new Error(`POST ${entity}/query → ${res.status}: ${await res.text()}`); const data: any = await res.json(); all.push(...(data.items ?? [])); nextUrl = data.pageDetails?.nextPageUrl ?? null; if (!nextUrl) break; } return all; } async function patchCI(id: number, patch: object, retries = 5): Promise { for (let attempt = 0; attempt <= retries; attempt++) { const res = await fetch(`${API_BASE}/ConfigurationItems`, { method: 'PATCH', headers: authHeaders(), body: JSON.stringify({ id, ...patch }), }); if (res.ok) return; if (res.status === 429 && attempt < retries) { const delay = 2000 * (attempt + 1); // 2s, 4s, 6s, 8s, 10s await new Promise(r => setTimeout(r, delay)); continue; } throw new Error(`PATCH ConfigurationItems/${id} → ${res.status}: ${await res.text()}`); } } /** Run tasks with a concurrency cap, returns results in original order */ async function pLimit( tasks: (() => Promise)[], concurrency: number ): Promise<{ result?: T; error?: Error }[]> { const results: { result?: T; error?: Error }[] = new Array(tasks.length); let next = 0; async function worker() { while (next < tasks.length) { const i = next++; try { results[i] = { result: await tasks[i]() }; } catch (e) { results[i] = { error: e instanceof Error ? e : new Error(String(e)) }; } } } await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, worker)); return results; } // ─── Arg parsing ────────────────────────────────────────────────────────────── function parseArgs() { const args = process.argv.slice(2); let dryRun = true; let concurrency = 3; // Autotask hard limit is 3 concurrent API threads for (let i = 0; i < args.length; i++) { if (args[i] === '--commit') dryRun = false; if (args[i] === '--dry-run') dryRun = true; if (args[i] === '--concurrency' && args[i+1]) concurrency = parseInt(args[++i], 10); } return { dryRun, concurrency }; } // ─── Types ──────────────────────────────────────────────────────────────────── interface Company { id: number; companyName: string; isActive: boolean; } interface CIItem { id: number; companyID: number; referenceTitle: string; configurationItemType: number; configurationItemCategoryID: number | null; isActive: boolean; } // ─── Main ───────────────────────────────────────────────────────────────────── async function main() { const { dryRun, concurrency } = parseArgs(); console.log(''); console.log('╔══════════════════════════════════════════════════════════════╗'); console.log('║ Deactivate CIs for Inactive Companies ║'); console.log('╚══════════════════════════════════════════════════════════════╝'); console.log(` Mode : ${dryRun ? '🔍 DRY RUN (no changes written)' : '✏️ COMMIT (changes will be applied)'}`); console.log(''); // ── 1. Fetch all inactive companies ───────────────────────────────────────── process.stdout.write('Fetching inactive companies … '); const inactiveCompanies = await queryAll('Companies', [ { field: 'isActive', op: 'eq', value: false }, ]); console.log(`found ${inactiveCompanies.length}.`); if (inactiveCompanies.length === 0) { console.log('No inactive companies found.'); return; } const companyMap = new Map( inactiveCompanies.map(c => [c.id, c.companyName]) ); // ── 2. Fetch active CIs for each inactive company ──────────────────────────── console.log('Querying active CIs for each inactive company …'); const cisByCompany = new Map(); let totalFound = 0; for (const company of inactiveCompanies) { const cis = await queryAll('ConfigurationItems', [ { field: 'companyID', op: 'eq', value: company.id }, { field: 'isActive', op: 'eq', value: true }, ]); if (cis.length > 0) { cisByCompany.set(company.id, cis); totalFound += cis.length; process.stdout.write(` [${company.id}] ${company.companyName}: ${cis.length} active CI(s)\n`); } } // ── 3. Report ──────────────────────────────────────────────────────────────── console.log(''); console.log('─── Summary ──────────────────────────────────────────────────'); console.log(` Inactive companies checked : ${inactiveCompanies.length}`); console.log(` Companies with active CIs : ${cisByCompany.size}`); console.log(` Total active CIs to deactivate : ${totalFound}`); console.log(''); if (totalFound === 0) { console.log('✓ Nothing to do — no active CIs found for inactive companies.'); return; } console.log('─── Breakdown ────────────────────────────────────────────────'); const sorted = [...cisByCompany.entries()].sort((a, b) => (companyMap.get(a[0]) ?? '').localeCompare(companyMap.get(b[0]) ?? '') ); for (const [companyId, cis] of sorted) { const name = companyMap.get(companyId) ?? `Company ${companyId}`; console.log(`\n ${name} (${cis.length} CI${cis.length !== 1 ? 's' : ''})`); for (const ci of cis) { console.log(` [${ci.id}] ${(ci.referenceTitle ?? '(no title)').substring(0, 60)}`); } } console.log(''); if (dryRun) { console.log('─── Dry run complete ─────────────────────────────────────────'); console.log(` ${totalFound} CI(s) across ${cisByCompany.size} company/companies would be deactivated.`); console.log(' Run with --commit to apply.'); return; } // ── 4. Deactivate ──────────────────────────────────────────────────────────── const allCIs = [...cisByCompany.values()].flat(); console.log(`─── Deactivating ${allCIs.length} CIs (concurrency=${concurrency}) ──────`); const tasks = allCIs.map(ci => () => patchCI(ci.id, { isActive: false })); const results = await pLimit(tasks, concurrency); let succeeded = 0; let failed = 0; const errors: { ci: CIItem; error: string }[] = []; for (let i = 0; i < results.length; i++) { if (results[i].error) { failed++; errors.push({ ci: allCIs[i], error: results[i].error!.message }); process.stdout.write('✗'); } else { succeeded++; process.stdout.write('.'); } if ((i + 1) % 80 === 0) process.stdout.write('\n'); } console.log('\n'); // ── 5. Final report ─────────────────────────────────────────────────────────── console.log('─── Results ──────────────────────────────────────────────────'); console.log(` ✓ Deactivated : ${succeeded}`); console.log(` ✗ Failed : ${failed}`); if (errors.length > 0) { console.log('\n Failures:'); for (const { ci, error } of errors) { const name = companyMap.get(ci.companyID) ?? `Company ${ci.companyID}`; console.log(` [${ci.id}] ${ci.referenceTitle ?? '(no title)'} (${name})`); console.log(` ${error}`); } process.exit(1); } console.log('\n✓ All done.'); } main().catch(err => { console.error('\nFatal error:', err.message); process.exit(1); });