#!/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); });