collabSupport/scripts/reclaimWebexHosts.js
Joseph McQueen 8777e51d10 Filter users-export by User Status (Inactive or Verified), not days
For the "Users Export" CSV, the target population is any account
Webex has flagged as not currently in use — that's status=Inactive
(previously active, now idle) or status=Verified (never signed in).
"Days since Last Service Accessed" is dropped as a filter criterion
because a Verified user has never signed in and therefore has a
blank days value. --min-days is documented as ignored for this
format.

The candidate record still carries days (nullable) so the sample
line and --report CSV can show it as informational context. Added
a "status" column to the report and to the audit-friendly console
sample.

Also prints every distinct User Status seen with counts, so the
operator can spot surprise values (e.g. the one "FALSE" row in the
current export) before hitting --execute.
2026-07-07 15:37:08 -04:00

634 lines
24 KiB
JavaScript

#!/usr/bin/env node
/**
* Reclaim Webex Meetings host licenses from long-inactive users.
*
* Accepts either of two Control Hub exports:
*
* 1. "Meetings Inactive Users" (Analyzer → Meetings → Inactive Users)
* Columns include EMAIL, IS_HOST, DAYS_SINCE_LAST_ACTIVE.
* Candidate rule: IS_HOST=Y AND DAYS_SINCE_LAST_ACTIVE > --min-days.
*
* 2. "Users Export" (Users → Manage users → Export)
* Columns include "User ID/Email (Required)", "User Status",
* "Days since Last Service Accessed", one TRUE/FALSE column per
* license (e.g. "aeo2go.webex.com - WebEx Meetings Free [SubXXX]").
* Candidate rule: User Status ∈ {Inactive, Verified}.
* - "Inactive" = active user that Webex has flagged idle
* - "Verified" = never signed in
* "Days since Last Service Accessed" is NOT used for this format
* (a Verified user has never logged in, so days is blank), and
* --min-days is therefore ignored.
*
* Format is auto-detected from the header. There's no host flag in
* format #2, but we don't need one: detection of "currently holds the
* host license" is authoritative, done by fetching the host license's
* assignee roster once up-front and cross-referencing the CSV emails.
* Anyone in the CSV who isn't currently a holder is silently skipped,
* and the personId comes straight off the assignee record (no per-user
* /people search needed).
*
* The mutation is one PATCH `/v1/licenses/users` per user:
* 1. remove the configured host license on the target site, and
* 2. atomically add either a specific "free tier" license, or an
* attendee-only siteUrl on the site (accountType=attendee).
* Users are never briefly license-less.
*
* DRY-RUN by default. Nothing mutates without `--execute`. In dry-run,
* the script lists every license on the site so you can pick the
* `--free-license-id` (or decide to use `--free-attendee` instead).
*
* Usage:
* node scripts/reclaimWebexHosts.js \
* --csv "/path/to/<report>.csv" \
* [--site aeo2go.webex.com] \
* [--min-days 120] \
* [--host-license-id <id>] \
* [--free-license-id <id> | --free-attendee] \
* [--concurrency 5] \
* [--limit N] [--offset N] \
* [--report reclaim-report.csv] \
* [--execute]
*
* Environment defaults (read from .env):
* WEBEX_HOST_SITE_URL → --site (default aeo2go.webex.com)
* WEBEX_HOST_LICENSE_ID → --host-license-id
* WEBEX_FREE_LICENSE_ID → --free-license-id (optional)
*
* 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';
// ─────────────────────────────────────────────────────────────────────────────
// CLI parsing
// ─────────────────────────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = {
csv: null,
site: process.env.WEBEX_HOST_SITE_URL || 'aeo2go.webex.com',
minDays: 120,
hostLicenseId: process.env.WEBEX_HOST_LICENSE_ID || null,
freeLicenseId: process.env.WEBEX_FREE_LICENSE_ID || null,
freeAttendee: false,
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 '--site': out.site = next().toLowerCase(); break;
case '--min-days': out.minDays = Number(next()); break;
case '--host-license-id': out.hostLicenseId = next(); break;
case '--free-license-id': out.freeLicenseId = next(); break;
case '--free-attendee': out.freeAttendee = true; 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() {
// Print the file's top comment block so `--help` matches source docs.
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 parsing (RFC 4180-ish; handles quoted fields, escaped "")
// ─────────────────────────────────────────────────────────────────────────────
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']);
function extractCandidates(format, rows, minDays) {
const candidates = [];
const skip = {
notHost: 0,
recent: 0,
blankDays: 0,
blankEmail: 0,
activeStatus: 0,
unknownStatus: 0,
byStatus: {},
};
if (format === FORMAT_MEETINGS_INACTIVE) {
for (const r of rows) {
const email = (r.EMAIL || '').trim().toLowerCase();
if (!email) { skip.blankEmail++; continue; }
const isHost = (r.IS_HOST || '').trim().toUpperCase() === 'Y';
if (!isHost) { skip.notHost++; continue; }
const daysRaw = (r.DAYS_SINCE_LAST_ACTIVE || '').trim();
if (daysRaw === '') { skip.blankDays++; continue; }
const days = Number(daysRaw);
if (!(days > minDays)) { skip.recent++; continue; }
candidates.push({
email,
days,
status: 'Host',
firstName: r.FIRST_NAME || '',
lastName: r.LAST_NAME || '',
lastActive: r.LAST_ACTIVE_DATE || '',
});
}
return { candidates, skip };
}
if (format === FORMAT_USERS_EXPORT) {
for (const r of rows) {
const email = (r['User ID/Email (Required)'] || '').trim().toLowerCase();
if (!email) { skip.blankEmail++; continue; }
const status = (r['User Status'] || '').trim();
skip.byStatus[status || '(blank)'] = (skip.byStatus[status || '(blank)'] || 0) + 1;
if (!USERS_EXPORT_ELIGIBLE_STATUSES.has(status)) {
if (status === 'Active') skip.activeStatus++;
else skip.unknownStatus++;
continue;
}
// Days is informational only for this format — a Verified user
// has never signed in, so days is blank. Keep it for the audit
// record / --report CSV.
const daysRaw = (r['Days since Last Service Accessed'] || '').trim();
const days = daysRaw === '' ? null : Number(daysRaw);
candidates.push({
email,
days: Number.isFinite(days) ? days : null,
status,
firstName: r['First Name'] || '',
lastName: r['Last Name'] || '',
lastActive: r['Last Active Time'] || r['Last Service Accessed Time'] || '',
});
}
return { candidates, skip };
}
throw new Error(`Unsupported CSV format: ${format}`);
}
// ─────────────────────────────────────────────────────────────────────────────
// Bounded-concurrency worker pool
// ─────────────────────────────────────────────────────────────────────────────
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<lowercased-email, { id, displayName, email }>. If an
// assignee record has no email (shouldn't happen for internal users)
// it's dropped — the CSV keys on email so we couldn't match anyway.
const users = await webex.listLicenseAssignees(licenseId);
const byEmail = new Map();
for (const u of users) {
const email = (u?.email || '').toLowerCase();
if (!email || !u?.id) continue;
byEmail.set(email, { id: u.id, displayName: u.displayName || email, email });
}
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
// ─────────────────────────────────────────────────────────────────────────────
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) {
console.error(
`❌ Could not detect CSV format. Supported reports:\n` +
` • "Meetings Inactive Users" (columns: EMAIL, IS_HOST, DAYS_SINCE_LAST_ACTIVE)\n` +
` • "Users Export" (columns: User ID/Email (Required), Days since Last Service Accessed)\n\n` +
`Header seen: ${header.join(', ')}`,
);
process.exit(2);
}
console.log(` → detected format: ${format}`);
const { candidates, skip } = extractCandidates(format, rows, args.minDays);
let filterLabel;
let skipSummary;
if (format === FORMAT_MEETINGS_INACTIVE) {
filterLabel = `host + >${args.minDays}d inactive`;
skipSummary =
`skipped ${skip.notHost} non-host, ${skip.recent} recent, ` +
`${skip.blankDays} blank-days, ${skip.blankEmail} blank-email`;
} else {
filterLabel = `status ∈ {Inactive, Verified}`;
const seen = Object.entries(skip.byStatus)
.sort((a, b) => b[1] - a[1])
.map(([s, n]) => `${s}=${n}`).join(', ');
skipSummary =
`skipped ${skip.activeStatus} Active, ${skip.unknownStatus} other-status, ` +
`${skip.blankEmail} blank-email (all statuses seen: ${seen})`;
}
console.log(`${candidates.length} candidates (${filterLabel}); ${skipSummary}`);
// Enumerate the site's licenses so we can (a) verify the host license
// id, (b) let the operator pick the free license id in dry-run, and
// (c) show seat headroom (relevant if --free-license-id is finite).
console.log(`\n🔎 Fetching Webex Meetings licenses on \`${args.site}\``);
let siteLicenses;
try {
siteLicenses = await fetchSiteLicenses(args.site);
} catch (err) {
console.error(`❌ Failed to list licenses: ${explainWebexError(err)}`);
process.exit(1);
}
if (siteLicenses.length === 0) {
console.error(`❌ No licenses found on \`${args.site}\`. Wrong site URL?`);
process.exit(1);
}
console.log(` Licenses on \`${args.site}\`:`);
for (const l of siteLicenses) {
const free = seatsFree(l);
const markers = [];
if (l.id === args.hostLicenseId) markers.push('HOST (to reclaim)');
if (l.id === args.freeLicenseId) markers.push('FREE (to assign)');
const mark = markers.length ? `${markers.join(', ')}` : '';
console.log(`${l.name}${free}/${l.totalUnits} free — id=${l.id}${mark}`);
}
if (!args.hostLicenseId) {
console.error(
`\n❌ --host-license-id not set (and WEBEX_HOST_LICENSE_ID env is empty).\n` +
` Pick the "host" license id from the list above and re-run with\n` +
` --host-license-id <id>.`,
);
process.exit(2);
}
const hostLic = siteLicenses.find((l) => l.id === args.hostLicenseId);
if (!hostLic) {
console.error(
`\n❌ --host-license-id ${args.hostLicenseId} does not match any license\n` +
` on \`${args.site}\`. Double-check the id.`,
);
process.exit(2);
}
let freeLic = null;
if (args.freeLicenseId) {
freeLic = siteLicenses.find((l) => l.id === args.freeLicenseId);
if (!freeLic) {
console.error(
`\n❌ --free-license-id ${args.freeLicenseId} does not match any license\n` +
` on \`${args.site}\`. Double-check the id.`,
);
process.exit(2);
}
}
// Cross-reference: pull the host license's current assignee roster
// and keep only the CSV candidates who actually still hold it.
console.log(`\n📥 Fetching current holders of \`${hostLic.name}\` (paginated)…`);
let holders;
try {
holders = await fetchHostAssignees(hostLic.id);
} catch (err) {
console.error(`❌ Failed to fetch assignees: ${explainWebexError(err)}`);
process.exit(1);
}
console.log(`${holders.size} current holders`);
const toReclaim = [];
let skippedNotHolder = 0;
for (const c of candidates) {
const holder = holders.get(c.email);
if (!holder) { skippedNotHolder++; continue; }
toReclaim.push({ ...c, personId: holder.id, displayName: holder.displayName });
}
console.log(
`${toReclaim.length} to reclaim ` +
`(${skippedNotHolder} CSV candidates no longer hold the license)`,
);
// Optional slicing for staged rollouts / restart-after-failure.
const sliced = toReclaim.slice(args.offset, args.limit ? args.offset + args.limit : undefined);
if (sliced.length !== toReclaim.length) {
console.log(` → sliced to ${sliced.length} (offset=${args.offset}, limit=${args.limit ?? 'none'})`);
}
// Plan sanity: if a specific free license is provided, ensure it has
// enough seats for the run. (If it doesn't we still let --execute
// proceed, but Webex will start rejecting after seats run out — flag
// it up top so the operator can pick a different license or split.)
if (freeLic) {
const free = seatsFree(freeLic);
if (free < sliced.length) {
console.warn(
`\n⚠️ Free license \`${freeLic.name}\` has ${free} free seats but ` +
`${sliced.length} assignments are planned. Extras will fail.`,
);
}
}
// Decide what mutation each user will get.
const mutation = describeMutation(args, hostLic, freeLic);
console.log(`\n🛠 Planned mutation per user: ${mutation.human}`);
// Dry-run bail-out.
if (!args.execute) {
console.log(`\n🚦 DRY-RUN (no changes made). Re-run with --execute to commit.`);
if (!freeLic && !args.freeAttendee) {
console.log(
` Note: neither --free-license-id nor --free-attendee provided.\n` +
` Pick one before --execute:\n` +
` --free-license-id <id> (assigns a specific meetings license)\n` +
` --free-attendee (attendee-only on ${args.site}, no license)`,
);
}
if (sliced.length > 0) {
console.log(`\n Sample of first 5 candidates:`);
for (const s of sliced.slice(0, 5)) {
const age = s.days == null ? 'never-signed-in' : `${s.days}d inactive`;
const st = s.status && s.status !== 'Host' ? ` status=${s.status}` : '';
console.log(` - ${s.displayName} <${s.email}> (${age}${st}, personId=${s.personId})`);
}
}
process.exit(0);
}
if (!freeLic && !args.freeAttendee) {
console.error(
`\n❌ --execute requires one of:\n` +
` --free-license-id <id> (assign a specific meetings license)\n` +
` --free-attendee (attendee-only on ${args.site}, no license)`,
);
process.exit(2);
}
// Execute with bounded concurrency + 429 retry.
console.log(
`\n🚀 EXECUTING against ${sliced.length} users ` +
`(concurrency=${args.concurrency}). Ctrl-C to abort.\n`,
);
logger(
'webex:reclaim:audit',
`START reclaim: site=${args.site} host=${hostLic.id} ` +
`free=${freeLic ? freeLic.id : args.freeAttendee ? 'ATTENDEE' : 'NONE'} ` +
`count=${sliced.length} csv=${path.basename(csvPath)}`,
);
let processed = 0;
const results = await runPool(sliced, args.concurrency, async (user) => {
const body = {
personId: user.personId,
licenses: [{ id: hostLic.id, operation: 'remove' }],
};
if (freeLic) body.licenses.push({ id: freeLic.id, operation: 'add' });
if (args.freeAttendee) {
body.siteUrls = [{ siteUrl: args.site, accountType: 'attendee', operation: 'add' }];
}
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 grantedIds = new Set(r.value?.licenses || []);
const removedOk = !new Set(r.value?.licenses || []).has(hostLic.id);
const freeOk = !freeLic || grantedIds.has(freeLic.id);
const outcome = removedOk && freeOk ? 'granted' : 'partial';
logger(
'webex:reclaim:audit',
`OK ${u.email} personId=${u.personId} outcome=${outcome}`,
);
perUser.push({
email: u.email,
displayName: u.displayName,
status: u.status || '',
days_inactive: u.days == null ? '' : u.days,
personId: u.personId,
outcome,
error: '',
});
} else {
failed++;
const msg = explainWebexError(r.error);
failures.push({ user: u, msg });
logger(
'webex:reclaim:audit',
`FAIL ${u.email} personId=${u.personId}: ${msg}`,
'error',
);
perUser.push({
email: u.email,
displayName: u.displayName,
status: u.status || '',
days_inactive: u.days == null ? '' : u.days,
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', 'days_inactive', '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:reclaim:audit',
`END reclaim: ok=${ok} failed=${failed} total=${sliced.length}`,
);
process.exit(failed === 0 ? 0 : 1);
}
function describeMutation(args, hostLic, freeLic) {
const parts = [`remove \`${hostLic.name}\``];
if (freeLic) parts.push(`add \`${freeLic.name}\``);
else if (args.freeAttendee) parts.push(`add attendee-only on \`${args.site}\``);
else parts.push(`(no replacement — TBD)`);
return { human: parts.join(', ') };
}
main().catch((err) => {
console.error(`\n💥 Unhandled: ${err?.stack || err}`);
process.exit(1);
});