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.
407 lines
15 KiB
JavaScript
407 lines
15 KiB
JavaScript
#!/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);
|
||
});
|