feat: Veeam RPO analysis, comparison, ticket analysis + company teams table

- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison)
- Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis
- Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison)
- Add veeam-analysis-state.ts and rmm-device-resolver.ts services
- Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis
- Add backup-status page updates and nav links for new Veeam pages
- Add scripts: deactivate-cis-for-inactive-companies, workstation category updates
- Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt
- Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
This commit is contained in:
lorentz 2026-04-29 09:16:46 -04:00
parent 07067bef19
commit ea3471d38d
36 changed files with 5604 additions and 217 deletions

View file

@ -0,0 +1,253 @@
/**
* 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 <n> 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<string, string> {
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<T>(entity: string, filter: object[]): Promise<T[]> {
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<void> {
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<T>(
tasks: (() => Promise<T>)[],
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<Company>('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<number, string>(
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<number, CIItem[]>();
let totalFound = 0;
for (const company of inactiveCompanies) {
const cis = await queryAll<CIItem>('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);
});

View file

@ -0,0 +1,365 @@
/**
* 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);
});

View file

@ -0,0 +1,365 @@
/**
* 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 <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_LAPTOP = 39; // "Workstation Laptop" 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; // 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<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-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<CIItem>('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<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 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);
});