/** * update-workstation-laptop-category.ts * * Finds all active Configuration Items with type "Workstation – Laptop" (typeId=40) * and sets their category to "Workstations". * * Usage: * npx tsx scripts/update-workstation-laptop-category.ts [options] * * Options: * --dry-run Preview changes without writing to Autotask (default: true) * --commit Actually apply changes (disables dry-run) * --company Filter to a single company (partial name match or numeric ID) * --concurrency Parallel API update calls (default: 5) */ import { config } from 'dotenv'; import { resolve } from 'path'; config({ path: resolve(__dirname, '../.env') }); // ─── Constants ─────────────────────────────────────────────────────────────── const API_BASE = process.env.AUTOTASK_API_URL!; // e.g. https://webservices1.autotask.net/atservicesrest/v1.0 const USERNAME = process.env.AUTOTASK_USERNAME!; const SECRET = process.env.AUTOTASK_SECRET!; const INT_CODE = process.env.AUTOTASK_API_INTEGRATION_CODE!; const CI_TYPE_WORKSTATION_LAPTOP = 39; // "Workstation – Laptop" picklist value const TARGET_CATEGORY_NAME = 'Workstations'; // ─── Helpers ───────────────────────────────────────────────────────────────── function authHeaders(): Record { return { 'Username': USERNAME, 'Secret': SECRET, 'APIIntegrationcode': INT_CODE, 'Content-Type': 'application/json', 'Accept': 'application/json', }; } async function apiGet(path: string): Promise { const res = await fetch(`${API_BASE}/${path}`, { method: 'GET', headers: authHeaders(), }); if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${await res.text()}`); return res.json(); } async function apiPost(path: string, body: object): Promise { const res = await fetch(`${API_BASE}/${path}`, { method: 'POST', headers: authHeaders(), body: JSON.stringify(body), }); if (!res.ok) throw new Error(`POST ${path} → ${res.status}: ${await res.text()}`); return res.json(); } async function apiPatch(path: string, body: object, retries = 5): Promise { for (let attempt = 0; attempt <= retries; attempt++) { const res = await fetch(`${API_BASE}/${path}`, { method: 'PATCH', headers: authHeaders(), body: JSON.stringify(body), }); if (res.ok) return; if (res.status === 429 && attempt < retries) { await new Promise(r => setTimeout(r, 2000 * (attempt + 1))); continue; } throw new Error(`PATCH ${path} → ${res.status}: ${await res.text()}`); } } /** 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; // Autotask requires a body on every POST page request — send same body for all pages 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; } /** Run N promises with a concurrency cap */ async function pLimit(tasks: (() => Promise)[], concurrency: number): Promise<{ result?: T; error?: Error; index: number }[]> { const results: { result?: T; error?: Error; index: number }[] = new Array(tasks.length); let next = 0; async function worker() { while (next < tasks.length) { const i = next++; try { results[i] = { result: await tasks[i](), index: i }; } catch (e) { results[i] = { error: e instanceof Error ? e : new Error(String(e)), index: i }; } } } 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 companyArg = ''; 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] === '--company' && args[i + 1]) { companyArg = args[++i]; } if (args[i] === '--concurrency' && args[i+1]) { concurrency = parseInt(args[++i], 10); } } return { dryRun, companyArg, concurrency }; } // ─── Main ───────────────────────────────────────────────────────────────────── interface CIItem { id: number; referenceTitle: string; companyID: number; configurationItemType: number; configurationItemCategoryID: number | null; isActive: boolean; } interface Company { id: number; companyName: string; } interface Category { id: number; name: string; isActive: boolean; } async function main() { const { dryRun, companyArg, concurrency } = parseArgs(); console.log(''); console.log('╔══════════════════════════════════════════════════════════════╗'); console.log('║ Workstation-Laptop → Workstations Category Remediation ║'); console.log('╚══════════════════════════════════════════════════════════════╝'); console.log(` Mode : ${dryRun ? '🔍 DRY RUN (no changes written)' : '✏️ COMMIT (changes will be applied)'}`); if (companyArg) console.log(` Client filter: "${companyArg}"`); console.log(''); // ── 1. Resolve target category ID ───────────────────────────────────────── process.stdout.write('Fetching ConfigurationItemCategories … '); const categories = await queryAll('ConfigurationItemCategories', [ { field: 'isActive', op: 'eq', value: true }, ]); const targetCategory = categories.find(c => c.name.toLowerCase() === TARGET_CATEGORY_NAME.toLowerCase() ); if (!targetCategory) { console.error(`\n✗ Category "${TARGET_CATEGORY_NAME}" not found in Autotask. Available:\n`); categories.forEach(c => console.error(` [${c.id}] ${c.name}`)); process.exit(1); } console.log(`found ${categories.length} categories.`); console.log(` → Target category: [${targetCategory.id}] "${targetCategory.name}"`); // ── 2. Resolve company filter ────────────────────────────────────────────── let companyIdFilter: number | null = null; let companyName = ''; if (companyArg) { process.stdout.write(`Resolving company "${companyArg}" … `); const numericId = parseInt(companyArg, 10); if (!isNaN(numericId)) { // Direct ID const resp: any = await apiGet(`Companies/${numericId}`); const co: Company = resp.item; if (!co) { console.error(`\n✗ Company ID ${numericId} not found.`); process.exit(1); } companyIdFilter = co.id; companyName = co.companyName; } else { // Name search — fetch all, filter locally (Autotask doesn't support contains on companyName efficiently) const companies = await queryAll('Companies', [ { field: 'isActive', op: 'eq', value: true }, ]); const matches = companies.filter(c => c.companyName.toLowerCase().includes(companyArg.toLowerCase()) ); if (matches.length === 0) { console.error(`\n✗ No active company matching "${companyArg}".`); process.exit(1); } if (matches.length > 1) { console.error(`\n✗ "${companyArg}" matched ${matches.length} companies — be more specific:`); matches.forEach(c => console.error(` [${c.id}] ${c.companyName}`)); process.exit(1); } companyIdFilter = matches[0].id; companyName = matches[0].companyName; } console.log(`resolved → [${companyIdFilter}] ${companyName}`); } // ── 3. Fetch matching CIs from Autotask ──────────────────────────────────── process.stdout.write('Querying ConfigurationItems (type=Workstation-Laptop, isActive=true) … '); // Note: configurationItemType is not queryable via API filter — fetch by isActive // (+ optional companyID) then filter by type client-side. const ciFilter: object[] = [ { field: 'isActive', op: 'eq', value: true }, ]; if (companyIdFilter !== null) { ciFilter.push({ field: 'companyID', op: 'eq', value: companyIdFilter }); } const rawCIs = await queryAll('ConfigurationItems', ciFilter); const allCIs = rawCIs.filter(ci => ci.configurationItemType === CI_TYPE_WORKSTATION_LAPTOP); console.log(`found ${rawCIs.length} active items, ${allCIs.length} are type "Workstation – Laptop".`); // ── 4. Identify items that actually need updating ────────────────────────── const toUpdate = allCIs.filter(ci => ci.configurationItemCategoryID !== targetCategory.id); const alreadyCorrect = allCIs.length - toUpdate.length; // ── 5. Build per-company summary ────────────────────────────────────────── const byCompany = new Map(); for (const ci of toUpdate) { if (!byCompany.has(ci.companyID)) byCompany.set(ci.companyID, { companyID: ci.companyID, items: [] }); byCompany.get(ci.companyID)!.items.push(ci); } // Fetch company names for the affected companies const companyNames = new Map(); if (byCompany.size > 0) { process.stdout.write(`Fetching company names for ${byCompany.size} affected companies … `); const companyIds = [...byCompany.keys()]; // Batch in groups of 50 (Autotask OR filter limit) const chunkSize = 50; for (let i = 0; i < companyIds.length; i += chunkSize) { const chunk = companyIds.slice(i, i + chunkSize); const filter = chunk.map(id => ({ field: 'id', op: 'eq', value: id })); const cos = await queryAll('Companies', filter); for (const co of cos) companyNames.set(co.id, co.companyName); } console.log('done.'); } // ── 6. Report ────────────────────────────────────────────────────────────── console.log(''); console.log('─── Summary ──────────────────────────────────────────────────'); console.log(` Total "Workstation – Laptop" CIs found : ${allCIs.length}`); console.log(` Already categorised as "Workstations" : ${alreadyCorrect}`); console.log(` Require update : ${toUpdate.length}`); console.log(''); if (toUpdate.length === 0) { console.log('✓ Nothing to do — all items already have the correct category.'); return; } console.log('─── Breakdown by client ──────────────────────────────────────'); const sortedCompanies = [...byCompany.values()].sort((a, b) => (companyNames.get(a.companyID) ?? '').localeCompare(companyNames.get(b.companyID) ?? '') ); for (const { companyID, items } of sortedCompanies) { const name = companyNames.get(companyID) ?? `Company ${companyID}`; console.log(` ${name.padEnd(45)} ${items.length} item(s)`); for (const ci of items) { const oldCat = ci.configurationItemCategoryID ?? 'none'; console.log(` [${ci.id}] ${(ci.referenceTitle ?? '(no title)').substring(0, 60)} (cat: ${oldCat} → ${targetCategory.id})`); } } console.log(''); if (dryRun) { console.log('─── Dry run complete ─────────────────────────────────────────'); console.log(` ${toUpdate.length} item(s) would be updated.`); console.log(' Run with --commit to apply changes.'); return; } // ── 7. Apply updates ─────────────────────────────────────────────────────── console.log(`─── Applying ${toUpdate.length} updates (concurrency=${concurrency}) ────────`); let succeeded = 0; let failed = 0; const errors: { id: number; title: string; error: string }[] = []; const tasks = toUpdate.map(ci => async () => { await apiPatch('ConfigurationItems', { id: ci.id, configurationItemCategoryID: targetCategory.id, }); return ci.id; }); const results = await pLimit(tasks, concurrency); for (let i = 0; i < results.length; i++) { const ci = toUpdate[i]; const r = results[i]; if (r.error) { failed++; errors.push({ id: ci.id, title: ci.referenceTitle ?? '', error: r.error.message }); process.stdout.write('✗'); } else { succeeded++; process.stdout.write('.'); } if ((i + 1) % 80 === 0) process.stdout.write('\n'); } console.log('\n'); // ── 8. Final report ──────────────────────────────────────────────────────── console.log('─── Results ──────────────────────────────────────────────────'); console.log(` ✓ Updated successfully : ${succeeded}`); console.log(` ✗ Failed : ${failed}`); if (errors.length > 0) { console.log(''); console.log(' Failures:'); for (const e of errors) { console.log(` [${e.id}] ${e.title}`); console.log(` ${e.error}`); } } console.log(''); if (failed === 0) { console.log('✓ All done.'); } else { console.log('⚠ Completed with errors — review failures above.'); process.exit(1); } } main().catch(err => { console.error('\nFatal error:', err.message); process.exit(1); });