collabSupport/scripts/removeAdvancedMessaging.js
Joseph McQueen 07152a467b Add scripts/removeAdvancedMessaging.js + extract shared bulk lib
New scripts/removeAdvancedMessaging.js reads a Users Export CSV
and bulk-removes the Advanced Messaging and Advanced Space Meetings
licenses from every listed user (with optional add of a Basic
Messaging license, though in most Webex orgs Basic Messaging is a
derived entitlement and no explicit add is required).

Detection is authoritative like the reclaim script: the assignee
rosters of the two Advanced licenses are fetched once up-front,
unioned by email, and the CSV is cross-referenced. PersonIds come
straight off the roster (no per-user /people lookup). Only the
remove ops the user actually still needs are emitted — the PATCH
body is trimmed per user based on which licenses they hold.

Dry-run enumerates every org license whose name matches
/message|advanced|space|basic/i so the operator can discover the
three ids without prior knowledge. --advanced-messaging-license-id,
--advanced-space-meetings-license-id, and --basic-messaging-license-id
also read WEBEX_ADV_MSG_LICENSE_ID / WEBEX_ADV_SPACE_MTG_LICENSE_ID
/ WEBEX_BASIC_MSG_LICENSE_ID from .env if set.

Also extracted the CSV parsing, format detection, pool/retry
helpers, and Webex license helpers from reclaimWebexHosts.js into
a shared scripts/lib/webexBulk.js module. reclaimWebexHosts.js now
imports from it — no behavior change (verified against both CSV
formats: 1028 candidates on the Meetings Inactive Users report,
665 on the Users Export report). Net -106 lines from the reclaim
script.

.gitignore updates:
  - whitelist scripts/lib/ and the new removeAdvancedMessaging.js
    file so they get tracked
  - exclude reclaim-*.csv and remove-*.csv (per-user report CSVs
    generated by --report contain PII and must never be committed)
2026-07-07 15:54:37 -04:00

474 lines
19 KiB
JavaScript

#!/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 <id>] \
* [--advanced-space-meetings-license-id <id>] \
* [--basic-messaging-license-id <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<email, { personId, displayName, holds: {advMsg, advSpace} }>.
// 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 <path> 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 <id> (env: WEBEX_ADV_MSG_LICENSE_ID)\n` +
` --advanced-space-meetings-license-id <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);
});