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