Compare commits
2 commits
07152a467b
...
1705c88ba2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1705c88ba2 | |||
| f58628e4a8 |
3 changed files with 452 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
407
scripts/findEmptyLocations.js
Normal file
407
scripts/findEmptyLocations.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Reference in a new issue