diff --git a/.gitignore b/.gitignore index 3494995..612cde7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ characterization-runs/ scripts/* # Tracked operational scripts (whitelisted; keep local dev helpers ignored above) !scripts/reclaimWebexHosts.js +!scripts/removeAdvancedMessaging.js +!scripts/lib/ +!scripts/lib/** characterize-*.js # Backup & temp files @@ -75,3 +78,8 @@ config/config.bak # Local spike samples pulled from lab DBS-210 (never commit) .dect-samples/ + +# Per-user report CSVs written by the bulk admin scripts. +# These contain emails, personIds, and per-user outcome — PII, never commit. +reclaim-*.csv +remove-*.csv diff --git a/scripts/lib/webexBulk.js b/scripts/lib/webexBulk.js new file mode 100644 index 0000000..8243b3c --- /dev/null +++ b/scripts/lib/webexBulk.js @@ -0,0 +1,168 @@ +// scripts/lib/webexBulk.js +// +// Shared utilities for bulk Webex admin scripts driven off Control Hub +// CSV exports (reclaimWebexHosts.js, removeAdvancedMessaging.js, etc.). +// Kept intentionally dependency-free — everything the operator needs is +// already in the repo (WebexClient, logger). No dev deps to install. +// +// Contents: +// CSV +// parseCsvLine(line) → string[] +// readCsv(path) → { header, rows } +// detectFormat(header) → 'meetings-inactive' | 'users-export' | null +// FORMAT_* constants +// +// Concurrency + retry +// runPool(items, limit, worker) → results[] with { ok, value? , error? } +// callWithRetry(fn, opts) → retries 429/503 with Retry-After +// +// Webex helpers +// fetchAllLicenses() → all org licenses +// fetchSiteLicenses(siteUrl) → subset with siteUrl matching (case-insensitive) +// seatsFree(license) → number +// explainWebexError(err) → concise `${apiMsg} (HTTP ${status})` +// +// All Webex calls go through the shared WebexClient singleton which +// handles service-app token refresh; nothing to configure per-script. + +import fs from 'node:fs'; +import webex from '../../integrations/webex/WebexClient.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// CSV parsing (RFC 4180-ish; handles quoted fields, escaped "") +// ───────────────────────────────────────────────────────────────────────────── + +export function parseCsvLine(line) { + const cells = []; + let cur = ''; + let inQ = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (inQ) { + if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; } + else if (c === '"') inQ = false; + else cur += c; + } else { + if (c === '"') inQ = true; + else if (c === ',') { cells.push(cur); cur = ''; } + else cur += c; + } + } + cells.push(cur); + return cells; +} + +export function readCsv(filePath) { + const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); + const lines = raw.split(/\r?\n/).filter((l) => l.length > 0); + if (lines.length === 0) return { header: [], rows: [] }; + // Preserve original header text — Control Hub exports vary between + // UPPER_SNAKE and Title Case With Punctuation, and Users Export + // license columns are literally "aeo2go.webex.com - WebEx Meetings + // Free [Sub601269]". Case-preserving avoids ambiguity. + const header = parseCsvLine(lines[0]).map((h) => h.trim()); + const rows = lines.slice(1).map((l) => { + const cells = parseCsvLine(l); + const row = {}; + for (let i = 0; i < header.length; i++) row[header[i]] = cells[i] ?? ''; + return row; + }); + return { header, rows }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Format detection +// ───────────────────────────────────────────────────────────────────────────── + +export const FORMAT_MEETINGS_INACTIVE = 'meetings-inactive'; +export const FORMAT_USERS_EXPORT = 'users-export'; + +export function detectFormat(header) { + const set = new Set(header); + if (set.has('EMAIL') && set.has('IS_HOST') && set.has('DAYS_SINCE_LAST_ACTIVE')) { + return FORMAT_MEETINGS_INACTIVE; + } + if (set.has('User ID/Email (Required)') && set.has('Days since Last Service Accessed')) { + return FORMAT_USERS_EXPORT; + } + return null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Bounded-concurrency worker pool +// ───────────────────────────────────────────────────────────────────────────── +// Runs `worker(item, idx)` across `items` with at most `limit` in flight. +// Never throws — each slot in the result array is either `{ok: true, value}` +// or `{ok: false, error}` so the caller can accumulate a per-item report. + +export async function runPool(items, limit, worker) { + const results = new Array(items.length); + let idx = 0; + const workers = new Array(Math.min(limit, items.length)).fill(null).map(async () => { + while (true) { + const i = idx++; + if (i >= items.length) return; + try { + results[i] = { ok: true, value: await worker(items[i], i) }; + } catch (err) { + results[i] = { ok: false, error: err }; + } + } + }); + await Promise.all(workers); + return results; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 429/503-aware retry helper. Honors Retry-After (seconds). +// ───────────────────────────────────────────────────────────────────────────── + +export async function callWithRetry(fn, { tries = 4, baseDelayMs = 500 } = {}) { + let lastErr; + for (let attempt = 0; attempt < tries; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + const status = err?.response?.status; + if (status !== 429 && status !== 503) throw err; + const retryAfter = Number(err?.response?.headers?.['retry-after']); + const wait = Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter * 1000 + : baseDelayMs * Math.pow(2, attempt); + await new Promise((r) => setTimeout(r, wait)); + } + } + throw lastErr; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Webex license helpers +// ───────────────────────────────────────────────────────────────────────────── + +export async function fetchAllLicenses() { + const data = await webex.listLicenses(); + return Array.isArray(data?.items) ? data.items : []; +} + +export async function fetchSiteLicenses(siteUrl) { + const items = await fetchAllLicenses(); + const want = (siteUrl || '').toLowerCase(); + return items.filter((l) => (l.siteUrl || '').toLowerCase() === want); +} + +export function seatsFree(l) { + const total = Number(l.totalUnits ?? 0); + const used = Number(l.consumedUnits ?? 0); + return Math.max(0, total - used); +} + +export function explainWebexError(err) { + const status = err?.response?.status; + const apiMsg = + err?.response?.data?.message || + err?.response?.data?.errors?.[0]?.description || + err?.message || + String(err); + return status ? `${apiMsg} (HTTP ${status})` : apiMsg; +} diff --git a/scripts/reclaimWebexHosts.js b/scripts/reclaimWebexHosts.js index fc29c8d..679d3e6 100644 --- a/scripts/reclaimWebexHosts.js +++ b/scripts/reclaimWebexHosts.js @@ -65,6 +65,17 @@ import fs from 'node:fs'; import path from 'node:path'; import { logger } from '../utils/logger.js'; import webex from '../integrations/webex/WebexClient.js'; +import { + readCsv, + detectFormat, + FORMAT_MEETINGS_INACTIVE, + FORMAT_USERS_EXPORT, + runPool, + callWithRetry, + fetchSiteLicenses, + seatsFree, + explainWebexError, +} from './lib/webexBulk.js'; // ───────────────────────────────────────────────────────────────────────────── // CLI parsing @@ -119,64 +130,10 @@ function printHelp() { } // ───────────────────────────────────────────────────────────────────────────── -// CSV parsing (RFC 4180-ish; handles quoted fields, escaped "") +// Candidate extraction (CSV parsing, format detection, pool/retry helpers, +// and Webex license/error helpers live in ./lib/webexBulk.js) // ───────────────────────────────────────────────────────────────────────────── -function parseCsvLine(line) { - const cells = []; - let cur = ''; - let inQ = false; - for (let i = 0; i < line.length; i++) { - const c = line[i]; - if (inQ) { - if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; } - else if (c === '"') inQ = false; - else cur += c; - } else { - if (c === '"') inQ = true; - else if (c === ',') { cells.push(cur); cur = ''; } - else cur += c; - } - } - cells.push(cur); - return cells; -} - -function readCsv(filePath) { - const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); - const lines = raw.split(/\r?\n/).filter((l) => l.length > 0); - if (lines.length === 0) return { header: [], rows: [] }; - // Preserve original header text (both reports we accept use different - // casings and one uses punctuation like "User ID/Email (Required)"). - // Per-format extractors know the exact column names they need. - const header = parseCsvLine(lines[0]).map((h) => h.trim()); - const rows = lines.slice(1).map((l) => { - const cells = parseCsvLine(l); - const row = {}; - for (let i = 0; i < header.length; i++) row[header[i]] = cells[i] ?? ''; - return row; - }); - return { header, rows }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// CSV format detection + candidate extraction -// ───────────────────────────────────────────────────────────────────────────── - -const FORMAT_MEETINGS_INACTIVE = 'meetings-inactive'; -const FORMAT_USERS_EXPORT = 'users-export'; - -function detectFormat(header) { - const set = new Set(header); - if (set.has('EMAIL') && set.has('IS_HOST') && set.has('DAYS_SINCE_LAST_ACTIVE')) { - return FORMAT_MEETINGS_INACTIVE; - } - if (set.has('User ID/Email (Required)') && set.has('Days since Last Service Accessed')) { - return FORMAT_USERS_EXPORT; - } - return null; -} - // Users Export status values Webex considers "not currently in use". // "Active" users are excluded regardless of last-access date. const USERS_EXPORT_ELIGIBLE_STATUSES = new Set(['Inactive', 'Verified']); @@ -247,43 +204,10 @@ function extractCandidates(format, rows, minDays) { } // ───────────────────────────────────────────────────────────────────────────── -// Bounded-concurrency worker pool +// Host license assignee lookup (this one stays local because reclaim is the +// only script that needs to map "email → personId" via the assignee roster) // ───────────────────────────────────────────────────────────────────────────── -async function runPool(items, limit, worker) { - const results = new Array(items.length); - let idx = 0; - const workers = new Array(Math.min(limit, items.length)).fill(null).map(async () => { - while (true) { - const i = idx++; - if (i >= items.length) return; - try { - results[i] = { ok: true, value: await worker(items[i], i) }; - } catch (err) { - results[i] = { ok: false, error: err }; - } - } - }); - await Promise.all(workers); - return results; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Webex helpers -// ───────────────────────────────────────────────────────────────────────────── - -async function fetchSiteLicenses(siteUrl) { - const data = await webex.listLicenses(); - const items = Array.isArray(data?.items) ? data.items : []; - return items.filter((l) => (l.siteUrl || '').toLowerCase() === siteUrl.toLowerCase()); -} - -function seatsFree(l) { - const total = Number(l.totalUnits ?? 0); - const used = Number(l.consumedUnits ?? 0); - return Math.max(0, total - used); -} - async function fetchHostAssignees(licenseId) { // Returns Map. If an // assignee record has no email (shouldn't happen for internal users) @@ -298,36 +222,6 @@ async function fetchHostAssignees(licenseId) { return byEmail; } -function explainWebexError(err) { - const status = err?.response?.status; - const apiMsg = - err?.response?.data?.message || - err?.response?.data?.errors?.[0]?.description || - err?.message || - String(err); - return status ? `${apiMsg} (HTTP ${status})` : apiMsg; -} - -// Simple 429-aware retry. Webex returns Retry-After (seconds). -async function callWithRetry(fn, { tries = 4, baseDelayMs = 500 } = {}) { - let lastErr; - for (let attempt = 0; attempt < tries; attempt++) { - try { - return await fn(); - } catch (err) { - lastErr = err; - const status = err?.response?.status; - if (status !== 429 && status !== 503) throw err; - const retryAfter = Number(err?.response?.headers?.['retry-after']); - const wait = Number.isFinite(retryAfter) && retryAfter > 0 - ? retryAfter * 1000 - : baseDelayMs * Math.pow(2, attempt); - await new Promise((r) => setTimeout(r, wait)); - } - } - throw lastErr; -} - // ───────────────────────────────────────────────────────────────────────────── // Main // ───────────────────────────────────────────────────────────────────────────── diff --git a/scripts/removeAdvancedMessaging.js b/scripts/removeAdvancedMessaging.js new file mode 100644 index 0000000..64b88d3 --- /dev/null +++ b/scripts/removeAdvancedMessaging.js @@ -0,0 +1,474 @@ +#!/usr/bin/env node +/** + * Bulk-remove Advanced Messaging + Advanced Space Meetings licenses. + * + * Reads a Control Hub "Users Export" CSV (Users → Manage users → + * Export). For every row that currently has either "Advanced Messaging + * [SubXXX]" = TRUE or "Advanced Space Meetings [SubXXX]" = TRUE, we + * PATCH `/v1/licenses/users` to atomically: + * 1. remove the Advanced Messaging license (if user has it), and + * 2. remove the Advanced Space Meetings license (if user has it), and + * 3. optionally add a Basic Messaging license if `--basic-messaging- + * license-id` is provided. Note: in most Webex orgs, "Basic + * Messaging" is a derived entitlement that's on automatically for + * any user with a base license — you probably do NOT need to add + * it explicitly. Removing the Advanced overlay leaves the user + * with the basic tier. Use dry-run to see what licenses your org + * actually has (the enumeration below filters on names matching + * /message|advanced|space|basic/i). + * + * Detection is authoritative: we fetch the assignee rosters of both + * Advanced licenses once up-front (paginated) and cross-reference the + * CSV emails. Anyone in the CSV who no longer holds either license is + * silently skipped, and personIds come straight off the assignee + * records — no per-user /people lookup. + * + * DRY-RUN by default. Nothing mutates without `--execute`. In dry-run + * we enumerate org licenses whose names look messaging-relevant so you + * can pick the right IDs. + * + * Usage: + * node scripts/removeAdvancedMessaging.js \ + * --csv "/path/to/AdvanceMessaging.csv" \ + * [--advanced-messaging-license-id ] \ + * [--advanced-space-meetings-license-id ] \ + * [--basic-messaging-license-id ] \ + * [--concurrency 5] \ + * [--limit N] [--offset N] \ + * [--report remove-advmsg-report.csv] \ + * [--execute] + * + * Environment defaults (read from .env): + * WEBEX_ADV_MSG_LICENSE_ID → --advanced-messaging-license-id + * WEBEX_ADV_SPACE_MTG_LICENSE_ID → --advanced-space-meetings-license-id + * WEBEX_BASIC_MSG_LICENSE_ID → --basic-messaging-license-id + * + * Required Webex service-app scopes: + * spark-admin:licenses_read + * spark-admin:people_read + * spark-admin:people_write + */ + +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 { + readCsv, + detectFormat, + FORMAT_USERS_EXPORT, + runPool, + callWithRetry, + fetchAllLicenses, + seatsFree, + explainWebexError, +} from './lib/webexBulk.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// CLI parsing +// ───────────────────────────────────────────────────────────────────────────── + +function parseArgs(argv) { + const out = { + csv: null, + advancedMessagingLicenseId: process.env.WEBEX_ADV_MSG_LICENSE_ID || null, + advancedSpaceMeetingsLicenseId: process.env.WEBEX_ADV_SPACE_MTG_LICENSE_ID || null, + basicMessagingLicenseId: process.env.WEBEX_BASIC_MSG_LICENSE_ID || null, + concurrency: 5, + limit: null, + offset: 0, + report: null, + execute: false, + help: false, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = () => argv[++i]; + switch (a) { + case '--csv': out.csv = next(); break; + case '--advanced-messaging-license-id': out.advancedMessagingLicenseId = next(); break; + case '--advanced-space-meetings-license-id': out.advancedSpaceMeetingsLicenseId = next(); break; + case '--basic-messaging-license-id': out.basicMessagingLicenseId = next(); break; + case '--concurrency': out.concurrency = Math.max(1, Number(next())); break; + case '--limit': out.limit = Number(next()); break; + case '--offset': out.offset = Number(next()); break; + case '--report': out.report = next(); break; + case '--execute': out.execute = true; 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, '')); +} + +// ───────────────────────────────────────────────────────────────────────────── +// CSV column resolution +// ───────────────────────────────────────────────────────────────────────────── +// The subscription suffix `[Sub601269]` is org-specific. Match by +// prefix so orgs with different subscription ids still resolve. + +function findColumn(header, prefix) { + const p = prefix.toLowerCase(); + return header.find((h) => h.toLowerCase().startsWith(p)) || null; +} + +function isTrueCell(v) { + return (v || '').trim().toUpperCase() === 'TRUE'; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Assignee roster union +// ───────────────────────────────────────────────────────────────────────────── +// For each provided license id, fetch its assignee roster and build a +// combined Map. +// Anyone in either roster ends up here; the `holds` flags tell us +// which licenses to actually remove per user. + +async function buildAssigneeUnion({ advMsgId, advSpaceId }) { + const union = new Map(); + + async function fold(licenseId, holdKey) { + if (!licenseId) return; + const users = await webex.listLicenseAssignees(licenseId); + for (const u of users) { + const email = (u?.email || '').toLowerCase(); + if (!email || !u?.id) continue; + const existing = union.get(email); + if (existing) { + existing.holds[holdKey] = true; + } else { + union.set(email, { + personId: u.id, + displayName: u.displayName || email, + email, + holds: { advMsg: false, advSpace: false, [holdKey]: true }, + }); + } + } + } + + await fold(advMsgId, 'advMsg'); + await fold(advSpaceId, 'advSpace'); + return union; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main +// ───────────────────────────────────────────────────────────────────────────── + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { printHelp(); process.exit(0); } + + if (!args.csv) { + console.error('❌ --csv is required. Use --help for usage.'); + process.exit(2); + } + const csvPath = path.resolve(args.csv); + if (!fs.existsSync(csvPath)) { + console.error(`❌ CSV not found: ${csvPath}`); + process.exit(2); + } + + console.log(`📄 Reading ${csvPath}`); + const { header, rows } = readCsv(csvPath); + console.log(` → ${rows.length} rows`); + + const format = detectFormat(header); + if (format !== FORMAT_USERS_EXPORT) { + console.error( + `❌ This script requires a "Users Export" CSV (needs the per-license\n` + + ` TRUE/FALSE columns). Detected format: ${format ?? 'unknown'}.`, + ); + process.exit(2); + } + console.log(` → detected format: ${format}`); + + const advMsgCol = findColumn(header, 'Advanced Messaging ['); + const advSpaceCol = findColumn(header, 'Advanced Space Meetings ['); + if (!advMsgCol || !advSpaceCol) { + console.error( + `❌ CSV missing expected license columns:\n` + + ` Advanced Messaging → ${advMsgCol || '(not found)'}\n` + + ` Advanced Space Meetings → ${advSpaceCol || '(not found)'}`, + ); + process.exit(2); + } + console.log(` → license columns: "${advMsgCol}", "${advSpaceCol}"`); + + // Filter to rows that actually need one of the removals. Emails + // lowercased for the assignee cross-reference below. + const candidates = []; + let skipNoLicense = 0; + let skipBlankEmail = 0; + for (const r of rows) { + const email = (r['User ID/Email (Required)'] || '').trim().toLowerCase(); + if (!email) { skipBlankEmail++; continue; } + const hasAdvMsg = isTrueCell(r[advMsgCol]); + const hasAdvSpace = isTrueCell(r[advSpaceCol]); + if (!hasAdvMsg && !hasAdvSpace) { skipNoLicense++; continue; } + candidates.push({ + email, + displayName: r['Display Name'] || `${r['First Name'] || ''} ${r['Last Name'] || ''}`.trim() || email, + status: (r['User Status'] || '').trim(), + csvHasAdvMsg: hasAdvMsg, + csvHasAdvSpace: hasAdvSpace, + }); + } + console.log( + ` → ${candidates.length} candidates (rows with Adv Messaging=TRUE OR Adv Space Meetings=TRUE); ` + + `skipped ${skipNoLicense} rows with neither, ${skipBlankEmail} blank-email`, + ); + + // Enumerate org licenses that look messaging/space-relevant. The + // operator uses this list to pick the three ids for --execute. + console.log(`\n🔎 Fetching org licenses…`); + let allLicenses; + try { + allLicenses = await fetchAllLicenses(); + } catch (err) { + console.error(`❌ Failed to list licenses: ${explainWebexError(err)}`); + process.exit(1); + } + const relevantRe = /message|advanced|space meeting|basic/i; + const relevant = allLicenses.filter((l) => relevantRe.test(l.name || '')); + console.log(` Relevant org licenses (${relevant.length} of ${allLicenses.length}):`); + for (const l of relevant) { + const free = seatsFree(l); + const markers = []; + if (l.id === args.advancedMessagingLicenseId) markers.push('ADV_MSG (to remove)'); + if (l.id === args.advancedSpaceMeetingsLicenseId) markers.push('ADV_SPACE (to remove)'); + if (l.id === args.basicMessagingLicenseId) markers.push('BASIC_MSG (to add)'); + const mark = markers.length ? ` ← ${markers.join(', ')}` : ''; + const site = l.siteUrl ? ` site=${l.siteUrl}` : ''; + console.log(` • ${l.name} — ${free}/${l.totalUnits} free${site} — id=${l.id}${mark}`); + } + + if (!args.advancedMessagingLicenseId && !args.advancedSpaceMeetingsLicenseId) { + console.error( + `\n❌ Need at least one of the following ids to proceed:\n` + + ` --advanced-messaging-license-id (env: WEBEX_ADV_MSG_LICENSE_ID)\n` + + ` --advanced-space-meetings-license-id (env: WEBEX_ADV_SPACE_MTG_LICENSE_ID)\n` + + ` Pick from the list above.`, + ); + process.exit(2); + } + + // Validate provided ids resolve to real licenses. + const licById = new Map(allLicenses.map((l) => [l.id, l])); + const advMsgLic = args.advancedMessagingLicenseId ? licById.get(args.advancedMessagingLicenseId) : null; + const advSpaceLic = args.advancedSpaceMeetingsLicenseId ? licById.get(args.advancedSpaceMeetingsLicenseId) : null; + const basicMsgLic = args.basicMessagingLicenseId ? licById.get(args.basicMessagingLicenseId) : null; + + const badIds = []; + if (args.advancedMessagingLicenseId && !advMsgLic) badIds.push(['--advanced-messaging-license-id', args.advancedMessagingLicenseId]); + if (args.advancedSpaceMeetingsLicenseId && !advSpaceLic) badIds.push(['--advanced-space-meetings-license-id', args.advancedSpaceMeetingsLicenseId]); + if (args.basicMessagingLicenseId && !basicMsgLic) badIds.push(['--basic-messaging-license-id', args.basicMessagingLicenseId]); + if (badIds.length > 0) { + console.error(`\n❌ Invalid license ids:`); + for (const [flag, id] of badIds) console.error(` ${flag} ${id}`); + process.exit(2); + } + + // Cross-reference: fetch the assignee union (up to two paginated + // sweeps) so we can (a) resolve personId per email and (b) only + // send the remove ops for licenses the user actually still holds. + console.log(`\n📥 Fetching current assignees…`); + let assignees; + try { + assignees = await buildAssigneeUnion({ + advMsgId: advMsgLic?.id, + advSpaceId: advSpaceLic?.id, + }); + } catch (err) { + console.error(`❌ Failed to fetch assignees: ${explainWebexError(err)}`); + process.exit(1); + } + console.log(` → ${assignees.size} distinct users hold at least one of the target licenses`); + + const toProcess = []; + let skipNotHolder = 0; + for (const c of candidates) { + const a = assignees.get(c.email); + if (!a) { skipNotHolder++; continue; } + toProcess.push({ + ...c, + personId: a.personId, + displayName: a.displayName || c.displayName, + holdsAdvMsg: a.holds.advMsg, + holdsAdvSpace: a.holds.advSpace, + }); + } + console.log( + ` → ${toProcess.length} to process ` + + `(${skipNotHolder} CSV candidates no longer hold either license)`, + ); + + const sliced = toProcess.slice(args.offset, args.limit ? args.offset + args.limit : undefined); + if (sliced.length !== toProcess.length) { + console.log(` → sliced to ${sliced.length} (offset=${args.offset}, limit=${args.limit ?? 'none'})`); + } + + // Basic-messaging capacity check (if the operator supplied one and + // it's a finite-seat license — some orgs meter Basic Messaging). + if (basicMsgLic && seatsFree(basicMsgLic) < sliced.length) { + console.warn( + `\n⚠️ Basic Messaging license \`${basicMsgLic.name}\` has ` + + `${seatsFree(basicMsgLic)} free seats but ${sliced.length} adds are planned. ` + + `Extras will fail.`, + ); + } + + const mutation = describeMutation({ advMsgLic, advSpaceLic, basicMsgLic }); + console.log(`\n🛠 Planned mutation per user: ${mutation}`); + + if (!args.execute) { + console.log(`\n🚦 DRY-RUN (no changes made). Re-run with --execute to commit.`); + if (sliced.length > 0) { + console.log(`\n Sample of first 5 candidates:`); + for (const s of sliced.slice(0, 5)) { + const ops = []; + if (advMsgLic && s.holdsAdvMsg) ops.push('-adv-msg'); + if (advSpaceLic && s.holdsAdvSpace) ops.push('-adv-space'); + if (basicMsgLic) ops.push('+basic-msg'); + console.log(` - ${s.displayName} <${s.email}> status=${s.status || '?'} ops=[${ops.join(', ')}] personId=${s.personId}`); + } + } + process.exit(0); + } + + // Execute with bounded concurrency + 429 retry. + console.log( + `\n🚀 EXECUTING against ${sliced.length} users ` + + `(concurrency=${args.concurrency}). Ctrl-C to abort.\n`, + ); + logger( + 'webex:advmsg:audit', + `START remove-advmsg: adv-msg=${advMsgLic?.id || 'skip'} ` + + `adv-space=${advSpaceLic?.id || 'skip'} basic-msg=${basicMsgLic?.id || 'skip'} ` + + `count=${sliced.length} csv=${path.basename(csvPath)}`, + ); + + let processed = 0; + const results = await runPool(sliced, args.concurrency, async (user) => { + const licenses = []; + if (advMsgLic && user.holdsAdvMsg) licenses.push({ id: advMsgLic.id, operation: 'remove' }); + if (advSpaceLic && user.holdsAdvSpace) licenses.push({ id: advSpaceLic.id, operation: 'remove' }); + if (basicMsgLic) licenses.push({ id: basicMsgLic.id, operation: 'add' }); + + // Shouldn't happen — every entry in `sliced` holds at least one + // of the two Advanced licenses. Defensive skip anyway so we don't + // send an empty PATCH body. + if (licenses.length === 0) { + return { skipped: 'no-op' }; + } + + const body = { personId: user.personId, licenses }; + const resp = await callWithRetry(() => webex.assignLicensesToUser(body)); + + processed++; + if (processed % 25 === 0 || processed === sliced.length) { + console.log(` … ${processed}/${sliced.length}`); + } + return resp; + }); + + // Summarise + audit. + let ok = 0; + let failed = 0; + const failures = []; + const perUser = []; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const u = sliced[i]; + if (r.ok) { + ok++; + const currentLicenses = new Set(r.value?.licenses || []); + const advMsgGone = !advMsgLic || !currentLicenses.has(advMsgLic.id); + const advSpaceGone = !advSpaceLic || !currentLicenses.has(advSpaceLic.id); + const basicOk = !basicMsgLic || currentLicenses.has(basicMsgLic.id); + const outcome = advMsgGone && advSpaceGone && basicOk ? 'ok' : 'partial'; + logger('webex:advmsg:audit', `OK ${u.email} personId=${u.personId} outcome=${outcome}`); + perUser.push({ + email: u.email, + displayName: u.displayName, + status: u.status || '', + removed_adv_msg: advMsgLic && u.holdsAdvMsg ? 'yes' : 'no', + removed_adv_space: advSpaceLic && u.holdsAdvSpace ? 'yes' : 'no', + added_basic_msg: basicMsgLic ? 'yes' : 'no', + personId: u.personId, + outcome, + error: '', + }); + } else { + failed++; + const msg = explainWebexError(r.error); + failures.push({ user: u, msg }); + logger('webex:advmsg:audit', `FAIL ${u.email} personId=${u.personId}: ${msg}`, 'error'); + perUser.push({ + email: u.email, + displayName: u.displayName, + status: u.status || '', + removed_adv_msg: advMsgLic && u.holdsAdvMsg ? 'attempted' : 'no', + removed_adv_space: advSpaceLic && u.holdsAdvSpace ? 'attempted' : 'no', + added_basic_msg: basicMsgLic ? 'attempted' : 'no', + personId: u.personId, + outcome: 'error', + error: msg, + }); + } + } + + console.log(`\n✅ Done. ${ok} succeeded, ${failed} failed, ${sliced.length} total.`); + if (failures.length > 0) { + console.log(`\nFirst up to 10 failures:`); + for (const f of failures.slice(0, 10)) { + console.log(` - ${f.user.email}: ${f.msg}`); + } + } + + if (args.report) { + const reportPath = path.resolve(args.report); + const cols = [ + 'email', 'displayName', 'status', + 'removed_adv_msg', 'removed_adv_space', 'added_basic_msg', + 'personId', 'outcome', 'error', + ]; + const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; + const lines = [cols.join(',')]; + for (const p of perUser) lines.push(cols.map((c) => escape(p[c])).join(',')); + fs.writeFileSync(reportPath, lines.join('\n') + '\n', 'utf8'); + console.log(`\n📝 Report written to ${reportPath}`); + } + + logger( + 'webex:advmsg:audit', + `END remove-advmsg: ok=${ok} failed=${failed} total=${sliced.length}`, + ); + process.exit(failed === 0 ? 0 : 1); +} + +function describeMutation({ advMsgLic, advSpaceLic, basicMsgLic }) { + const parts = []; + if (advMsgLic) parts.push(`remove \`${advMsgLic.name}\` (if held)`); + if (advSpaceLic) parts.push(`remove \`${advSpaceLic.name}\` (if held)`); + if (basicMsgLic) parts.push(`add \`${basicMsgLic.name}\``); + if (parts.length === 0) return '(nothing — no ids provided)'; + return parts.join(', '); +} + +main().catch((err) => { + console.error(`\n💥 Unhandled: ${err?.stack || err}`); + process.exit(1); +});