collabSupport/scripts/findEmptyLocations.js
Joseph McQueen f58628e4a8 Add scripts/findEmptyLocations.js — closed-store cleanup discovery
Finds Webex Calling locations with zero PEOPLE-owned phone numbers
(the "we lost the store users, no one told us" pattern) by diffing
two paginated pulls:
  GET /v1/telephony/config/locations       → every location
  GET /v1/telephony/config/numbers         → every provisioned number
                                             with owner + location

Verdicts per location:
  - has-users           → ≥1 PEOPLE owner (excluded from report)
  - needs-cleanup-first → 0 PEOPLE but workspaces / AA / HG / etc
                          still present; needs Control Hub attention
                          before delete
  - safe-to-delete      → 0 of everything, ghost location shell

Console prints a per-location inventory table (top 20) plus a
summary. --report writes the full set to CSV with location id,
name, address, timezone, and per-owner-type counts.

--execute deletes safe-to-delete locations via
DELETE /v1/telephony/config/locations/{id}. Guarded by a mandatory
--i-am-sure flag and run serial (concurrency=1) with the shared
callWithRetry so 429/503 gets a Retry-After-aware backoff.
--limit / --offset let the operator pilot on a subset. Every
delete produces an audit line under `webex:emptyloc:audit`.

Also added a generic fetchAllPaginated helper to
scripts/lib/webexBulk.js (Link-header cursor pagination, configurable
array key) so subsequent bulk scripts can reuse it.

.gitignore: whitelisted findEmptyLocations.js; added
empty-locations-*.csv to the report-artifact ignore list.
2026-07-07 16:21:46 -04:00

407 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Find Webex Calling locations with no user-owned phone numbers —
* candidates for closed-store cleanup.
*
* Approach: two paginated pulls, one diff.
* 1. GET /v1/telephony/config/locations → every location
* 2. GET /v1/telephony/config/numbers → every provisioned
* number with owner + location info
* Bucket numbers by (location.id, owner.type). A location with zero
* PEOPLE-owned numbers is the "empty" verdict the user asked for.
*
* Each empty location is further categorized:
* - `safe-to-delete` → zero PEOPLE AND zero of everything else
* (no workspaces, no auto-attendants, no
* hunt groups, no call queues, no virtual
* lines, no paging groups, no unassigned
* numbers). The location shell is a ghost.
* - `needs-cleanup-first` → zero PEOPLE but other resources still
* exist. These are the "someone drew a
* line under the users but forgot the
* phones" ghosts. Break-fix through
* Control Hub before delete.
* - `has-users` → at least one PEOPLE-owned number, still
* in active use. Excluded from the report.
*
* Output:
* Console table summarising counts, plus a per-location inventory
* for the empty candidates (top N in console, full set in --report
* CSV).
*
* --execute mode (MUST be paired with --i-am-sure) deletes locations
* verdicted `safe-to-delete`, one at a time, via
* `DELETE /v1/telephony/config/locations/{id}`. This removes Webex
* Calling from the location; the underlying org-wide location record
* (billing/address) may still need to be removed in Control Hub
* depending on your subscription. Anything not `safe-to-delete` is
* never touched by --execute.
*
* Usage:
* node scripts/findEmptyLocations.js \
* [--include-has-users] # include has-users rows in CSV too
* [--report empty-locations.csv]
* [--limit N] # cap for --execute (e.g. pilot 5)
* [--offset N]
* [--execute --i-am-sure] # delete safe-to-delete locations
*
* Required Webex service-app scopes:
* spark-admin:telephony_config_read
* spark-admin:telephony_config_write (only if using --execute)
*/
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import { logger } from '../utils/logger.js';
import webex from '../integrations/webex/WebexClient.js';
import {
fetchAllPaginated,
runPool,
callWithRetry,
explainWebexError,
} from './lib/webexBulk.js';
// Owner types the Webex Calling numbers endpoint returns. Categorized
// here so a stray future owner type doesn't get silently dropped.
const OWNER_BUCKET = {
PEOPLE: 'people',
PLACE: 'workspaces',
AUTO_ATTENDANT: 'autoAttendants',
HUNT_GROUP: 'huntGroups',
CALL_QUEUE: 'callQueues',
VIRTUAL_LINE: 'virtualLines',
PAGING_GROUP: 'pagingGroups',
VOICEMAIL_GROUP: 'voicemailGroups',
GROUP_PAGING: 'pagingGroups',
RECEPTIONIST_CLIENT: 'receptionist',
};
function emptyBucket() {
return {
people: 0,
workspaces: 0,
autoAttendants: 0,
huntGroups: 0,
callQueues: 0,
virtualLines: 0,
pagingGroups: 0,
voicemailGroups: 0,
receptionist: 0,
unassigned: 0,
other: 0,
totalNumbers: 0,
};
}
function bucketTotals(b) {
return b.people + b.workspaces + b.autoAttendants + b.huntGroups
+ b.callQueues + b.virtualLines + b.pagingGroups + b.voicemailGroups
+ b.receptionist + b.unassigned + b.other;
}
// ─────────────────────────────────────────────────────────────────────────────
// CLI parsing
// ─────────────────────────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = {
report: null,
execute: false,
iAmSure: false,
includeHasUsers: false,
limit: null,
offset: 0,
help: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
switch (a) {
case '--report': out.report = next(); break;
case '--execute': out.execute = true; break;
case '--i-am-sure': out.iAmSure = true; break;
case '--include-has-users': out.includeHasUsers = true; break;
case '--limit': out.limit = Number(next()); break;
case '--offset': out.offset = Number(next()); break;
case '-h': case '--help': out.help = true; break;
default:
if (a.startsWith('--')) {
console.error(`Unknown flag: ${a}`);
process.exit(2);
}
}
}
return out;
}
function printHelp() {
const src = fs.readFileSync(new URL(import.meta.url), 'utf8');
const m = src.match(/\/\*\*([\s\S]*?)\*\//);
if (m) console.log(m[1].replace(/^\s*\*\s?/gm, ''));
}
// ─────────────────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────────────────
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) { printHelp(); process.exit(0); }
console.log('📥 Fetching all Webex Calling locations…');
let locations;
try {
locations = await fetchAllPaginated('telephony/config/locations', {
arrayKey: 'locations',
});
} catch (err) {
console.error(`❌ Failed to list locations: ${explainWebexError(err)}`);
console.error(
` The service app needs \`spark-admin:telephony_config_read\` scope,\n` +
` authorized as a Full / User admin on the org.`,
);
process.exit(1);
}
console.log(`${locations.length} locations`);
console.log('📥 Fetching all provisioned phone numbers…');
let numbers;
try {
numbers = await fetchAllPaginated('telephony/config/numbers', {
arrayKey: 'phoneNumbers',
});
} catch (err) {
console.error(`❌ Failed to list numbers: ${explainWebexError(err)}`);
process.exit(1);
}
console.log(`${numbers.length} numbers`);
// Bucket by location + owner type. Numbers without an owner are
// counted as `unassigned` — a location with only unassigned numbers
// is still empty from a "people using it" standpoint, but the
// presence of provisioned DIDs means cleanup work remains before
// delete.
const byLocation = new Map();
for (const l of locations) byLocation.set(l.id, emptyBucket());
const unknownOwnerTypes = new Set();
for (const n of numbers) {
const locId = n.location?.id;
if (!locId) continue;
let b = byLocation.get(locId);
if (!b) {
// Number references a location the locations endpoint didn't
// return. Odd but possible during org drift. Track it so the
// location shows up in the report.
b = emptyBucket();
byLocation.set(locId, b);
locations.push({
id: locId,
name: n.location?.name || '(unknown location)',
_synthetic: true,
});
}
b.totalNumbers++;
const ownerType = n.owner?.type;
if (!ownerType) {
b.unassigned++;
continue;
}
const key = OWNER_BUCKET[ownerType];
if (key) b[key]++;
else {
b.other++;
unknownOwnerTypes.add(ownerType);
}
}
if (unknownOwnerTypes.size > 0) {
console.log(
` Saw owner types not in the known set (counted as \`other\`): ` +
`${[...unknownOwnerTypes].join(', ')}`,
);
}
// Verdict per location.
const verdicts = locations.map((l) => {
const b = byLocation.get(l.id) || emptyBucket();
const nonPeople = bucketTotals(b) - b.people;
let verdict;
if (b.people > 0) verdict = 'has-users';
else if (nonPeople === 0) verdict = 'safe-to-delete';
else verdict = 'needs-cleanup-first';
return { location: l, buckets: b, verdict };
});
// Summary counts.
const summary = { hasUsers: 0, needsCleanup: 0, safeToDelete: 0 };
for (const v of verdicts) {
if (v.verdict === 'has-users') summary.hasUsers++;
else if (v.verdict === 'safe-to-delete') summary.safeToDelete++;
else summary.needsCleanup++;
}
console.log(`\n📊 ${locations.length} total locations:`);
console.log(` ${summary.hasUsers.toString().padStart(4)} have users (excluded from report)`);
console.log(` ${summary.needsCleanup.toString().padStart(4)} zero users but other resources remain (workspace/AA/HG/etc)`);
console.log(` ${summary.safeToDelete.toString().padStart(4)} truly empty (safe to delete)`);
const empty = verdicts.filter((v) => v.verdict !== 'has-users');
if (empty.length > 0) {
console.log(`\n📋 Empty / needs-cleanup locations (showing up to 20):`);
console.log(
' ' +
'verdict people work aa hg cq vl pg other unassigned name',
);
for (const v of empty.slice(0, 20)) {
const b = v.buckets;
const row = [
v.verdict.padEnd(18),
String(b.people).padStart(6),
String(b.workspaces).padStart(5),
String(b.autoAttendants).padStart(3),
String(b.huntGroups).padStart(3),
String(b.callQueues).padStart(3),
String(b.virtualLines).padStart(3),
String(b.pagingGroups).padStart(3),
String(b.other).padStart(6),
String(b.unassigned).padStart(11),
v.location.name || '(unnamed)',
].join(' ');
console.log(` ${row}`);
}
if (empty.length > 20) {
console.log(` … and ${empty.length - 20} more (see --report CSV)`);
}
}
// Report CSV.
if (args.report) {
const reportPath = path.resolve(args.report);
const cols = [
'verdict', 'location_id', 'name',
'people', 'workspaces', 'auto_attendants', 'hunt_groups',
'call_queues', 'virtual_lines', 'paging_groups', 'voicemail_groups',
'receptionist', 'unassigned', 'other', 'total_numbers',
'address_city', 'address_state', 'address_country', 'timezone',
'time_zone_offset',
];
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const lines = [cols.join(',')];
const included = args.includeHasUsers ? verdicts : empty;
for (const v of included) {
const b = v.buckets;
const l = v.location;
const addr = l.address || {};
lines.push(cols.map((c) => {
switch (c) {
case 'verdict': return escape(v.verdict);
case 'location_id': return escape(l.id);
case 'name': return escape(l.name);
case 'people': return b.people;
case 'workspaces': return b.workspaces;
case 'auto_attendants': return b.autoAttendants;
case 'hunt_groups': return b.huntGroups;
case 'call_queues': return b.callQueues;
case 'virtual_lines': return b.virtualLines;
case 'paging_groups': return b.pagingGroups;
case 'voicemail_groups': return b.voicemailGroups;
case 'receptionist': return b.receptionist;
case 'unassigned': return b.unassigned;
case 'other': return b.other;
case 'total_numbers': return b.totalNumbers;
case 'address_city': return escape(addr.city);
case 'address_state': return escape(addr.state);
case 'address_country': return escape(l.address?.country || l.countryCode);
case 'timezone': return escape(l.timeZone);
case 'time_zone_offset': return escape(l.timezoneOffset);
default: return '';
}
}).join(','));
}
fs.writeFileSync(reportPath, lines.join('\n') + '\n', 'utf8');
console.log(`\n📝 Report written to ${reportPath} (${lines.length - 1} row${lines.length === 2 ? '' : 's'})`);
}
// Delete path (guarded).
if (!args.execute) {
if (summary.safeToDelete > 0) {
console.log(
`\n🚦 DRY-RUN. To delete the ${summary.safeToDelete} safe-to-delete\n` +
` location(s), re-run with: --execute --i-am-sure\n` +
` Only locations with zero of everything will be deleted.\n` +
` Use --limit N to pilot on a subset.`,
);
}
process.exit(0);
}
if (!args.iAmSure) {
console.error(
`\n❌ --execute requires --i-am-sure. Deleting Webex Calling from a\n` +
` location is irreversible via API — you'd rebuild the location\n` +
` from scratch in Control Hub if this is a mistake.\n\n` +
` Add \`--i-am-sure\` (and consider \`--limit N\` for a pilot) to proceed.`,
);
process.exit(2);
}
const targets = verdicts
.filter((v) => v.verdict === 'safe-to-delete')
.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
if (targets.length === 0) {
console.log(`\n✅ Nothing to delete (0 safe-to-delete locations after --offset/--limit).`);
process.exit(0);
}
console.log(
`\n🚀 DELETING ${targets.length} location(s), serial (concurrency=1). Ctrl-C to abort.\n`,
);
logger(
'webex:emptyloc:audit',
`START delete: count=${targets.length}`,
);
// Serial (concurrency=1) — destructive ops shouldn't parallelize.
// Each 429/503 gets a Retry-After-aware retry via callWithRetry.
let ok = 0;
let failed = 0;
const outcomes = await runPool(targets, 1, async (v) => {
const l = v.location;
console.log(` • DELETE ${l.name} (${l.id}) …`);
await callWithRetry(() =>
webex.request('DELETE', `telephony/config/locations/${l.id}`),
);
console.log(` ✅ removed`);
return { deleted: true };
});
for (let i = 0; i < outcomes.length; i++) {
const t = targets[i];
const r = outcomes[i];
if (r.ok) {
ok++;
logger('webex:emptyloc:audit', `OK delete ${t.location.id} name="${t.location.name}"`);
} else {
failed++;
const msg = explainWebexError(r.error);
console.log(`${t.location.name}: ${msg}`);
logger(
'webex:emptyloc:audit',
`FAIL delete ${t.location.id} name="${t.location.name}": ${msg}`,
'error',
);
}
}
console.log(`\n✅ Done. ${ok} deleted, ${failed} failed.`);
logger('webex:emptyloc:audit', `END delete: ok=${ok} failed=${failed}`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((err) => {
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
process.exit(1);
});