366 lines
15 KiB
TypeScript
366 lines
15 KiB
TypeScript
|
|
/**
|
|||
|
|
* update-workstation-desktop-category.ts
|
|||
|
|
*
|
|||
|
|
* Finds all active Configuration Items with type "Workstation – Desktop" (typeId=40)
|
|||
|
|
* and sets their category to "Workstations".
|
|||
|
|
*
|
|||
|
|
* Usage:
|
|||
|
|
* npx tsx scripts/update-workstation-desktop-category.ts [options]
|
|||
|
|
*
|
|||
|
|
* Options:
|
|||
|
|
* --dry-run Preview changes without writing to Autotask (default: true)
|
|||
|
|
* --commit Actually apply changes (disables dry-run)
|
|||
|
|
* --company <name|id> Filter to a single company (partial name match or numeric ID)
|
|||
|
|
* --concurrency <n> 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_DESKTOP = 40; // "Workstation – Desktop" picklist value
|
|||
|
|
const TARGET_CATEGORY_NAME = 'Workstations';
|
|||
|
|
|
|||
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
function authHeaders(): Record<string, string> {
|
|||
|
|
return {
|
|||
|
|
'Username': USERNAME,
|
|||
|
|
'Secret': SECRET,
|
|||
|
|
'APIIntegrationcode': INT_CODE,
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'Accept': 'application/json',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function apiGet<T>(path: string): Promise<T> {
|
|||
|
|
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<T>(path: string, body: object): Promise<T> {
|
|||
|
|
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<void> {
|
|||
|
|
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<T>(entity: string, filter: object[]): Promise<T[]> {
|
|||
|
|
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<T>(tasks: (() => Promise<T>)[], 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;
|
|||
|
|
|
|||
|
|
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-Desktop → 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<Category>('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<Company>('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-Desktop, 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<CIItem>('ConfigurationItems', ciFilter);
|
|||
|
|
const allCIs = rawCIs.filter(ci => ci.configurationItemType === CI_TYPE_WORKSTATION_DESKTOP);
|
|||
|
|
console.log(`found ${rawCIs.length} active items, ${allCIs.length} are type "Workstation – Desktop".`);
|
|||
|
|
|
|||
|
|
// ── 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<number, { companyID: number; items: CIItem[] }>();
|
|||
|
|
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<number, string>();
|
|||
|
|
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<Company>('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 – Desktop" 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);
|
|||
|
|
});
|