From f58628e4a844a2502cafd9ec2fc90975d57c3602 Mon Sep 17 00:00:00 2001 From: Joseph McQueen Date: Tue, 7 Jul 2026 16:21:46 -0400 Subject: [PATCH] =?UTF-8?q?Add=20scripts/findEmptyLocations.js=20=E2=80=94?= =?UTF-8?q?=20closed-store=20cleanup=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 2 + scripts/findEmptyLocations.js | 407 ++++++++++++++++++++++++++++++++++ scripts/lib/webexBulk.js | 43 ++++ 3 files changed, 452 insertions(+) create mode 100644 scripts/findEmptyLocations.js diff --git a/.gitignore b/.gitignore index 612cde7..b9d6377 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/scripts/findEmptyLocations.js b/scripts/findEmptyLocations.js new file mode 100644 index 0000000..7f983dd --- /dev/null +++ b/scripts/findEmptyLocations.js @@ -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); +}); diff --git a/scripts/lib/webexBulk.js b/scripts/lib/webexBulk.js index 8243b3c..197a7fc 100644 --- a/scripts/lib/webexBulk.js +++ b/scripts/lib/webexBulk.js @@ -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 =