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.
This commit is contained in:
Joseph McQueen 2026-07-07 16:21:46 -04:00
parent 07152a467b
commit f58628e4a8
3 changed files with 452 additions and 0 deletions

2
.gitignore vendored
View file

@ -30,6 +30,7 @@ scripts/*
# Tracked operational scripts (whitelisted; keep local dev helpers ignored above)
!scripts/reclaimWebexHosts.js
!scripts/removeAdvancedMessaging.js
!scripts/findEmptyLocations.js
!scripts/lib/
!scripts/lib/**
characterize-*.js
@ -83,3 +84,4 @@ config/config.bak
# These contain emails, personIds, and per-user outcome — PII, never commit.
reclaim-*.csv
remove-*.csv
empty-locations-*.csv

View file

@ -0,0 +1,407 @@
#!/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);
});

View file

@ -157,6 +157,49 @@ export function seatsFree(l) {
return Math.max(0, total - used);
}
// Generic paginated fetch that follows the Webex `Link: <…>; rel="next"`
// header cursor. Accumulates every item in the response's `arrayKey` field
// (defaults to 'items' — the shape most Webex list endpoints use). Some
// endpoints use a different key (e.g. /telephony/config/numbers returns
// `phoneNumbers`); pass `arrayKey` in that case.
//
// Uses WebexClient.requestRaw() directly so we can read headers. First
// call is relative (`endpoint`); subsequent calls follow the absolute
// URLs from the Link header, which carry the cursor query string.
export async function fetchAllPaginated(endpoint, {
params = null,
arrayKey = 'items',
pageSize = 1000,
} = {}) {
const firstParams = { max: pageSize, ...(params || {}) };
let { data, headers } = await webex.requestRaw('GET', endpoint, null, firstParams);
const all = [];
const pickArray = (d) => {
const arr = d?.[arrayKey];
return Array.isArray(arr) ? arr : [];
};
all.push(...pickArray(data));
let nextUrl = parseLinkNext(headers?.link || headers?.Link);
while (nextUrl) {
({ data, headers } = await webex.requestRaw('GET', nextUrl));
all.push(...pickArray(data));
nextUrl = parseLinkNext(headers?.link || headers?.Link);
}
return all;
}
function parseLinkNext(linkHeader) {
if (!linkHeader || typeof linkHeader !== 'string') return null;
// Tolerate multiple entries (comma-separated); grab the first `rel="next"`.
const entries = linkHeader.split(/,\s*(?=<)/);
for (const e of entries) {
const m = e.match(/^\s*<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i);
if (m) return m[1];
}
return null;
}
export function explainWebexError(err) {
const status = err?.response?.status;
const apiMsg =