#!/usr/bin/env node /** * Reclaim Webex Meetings host licenses from long-inactive users. * * Feeds off a Control Hub "Meetings Inactive Users" report CSV * (Analyzer → Meetings → Inactive Users, Export CSV). For every row * that is currently marked as a host (IS_HOST=Y) AND has been inactive * for more than `--min-days` days, we PATCH `/v1/licenses/users` to: * 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). * Both operations go in one PATCH so the user is never briefly * license-less. * * Detection is authoritative: we fetch the assignee roster of the host * license once up-front (paginated) and cross-reference the CSV emails * against it. Anyone in the CSV who isn't currently a holder is skipped * silently — no per-user /people search needed, and the personId comes * straight off the assignee record. * * 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/Meetings Inactive Users_....csv" \ * [--site aeo2go.webex.com] \ * [--min-days 120] \ * [--host-license-id ] \ * [--free-license-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: [] }; const header = parseCsvLine(lines[0]).map((h) => h.trim().toUpperCase()); 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 }; } // ───────────────────────────────────────────────────────────────────────────── // 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. 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 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, columns: ${header.join(', ')}`); const required = ['EMAIL', 'IS_HOST', 'DAYS_SINCE_LAST_ACTIVE']; const missing = required.filter((c) => !header.includes(c)); if (missing.length > 0) { console.error(`❌ CSV missing required columns: ${missing.join(', ')}`); console.error(` Expected a Control Hub "Meetings Inactive Users" export.`); process.exit(2); } // Filter: hosts inactive over --min-days. Emails are lowercased for // the assignee cross-reference below. const candidates = []; let skippedNotHost = 0; let skippedRecent = 0; for (const r of rows) { const isHost = (r.IS_HOST || '').trim().toUpperCase() === 'Y'; const days = Number(r.DAYS_SINCE_LAST_ACTIVE || 0); const email = (r.EMAIL || '').trim().toLowerCase(); if (!email) continue; if (!isHost) { skippedNotHost++; continue; } if (!(days > args.minDays)) { skippedRecent++; continue; } candidates.push({ email, days, firstName: r.FIRST_NAME || '', lastName: r.LAST_NAME || '', lastActive: r.LAST_ACTIVE_DATE || '', }); } console.log( ` → ${candidates.length} candidates (host + >${args.minDays}d inactive); ` + `skipped ${skippedNotHost} non-host, ${skippedRecent} recent`, ); // 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 .`, ); 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 (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)) { console.log(` - ${s.displayName} <${s.email}> (${s.days}d inactive, personId=${s.personId})`); } } process.exit(0); } if (!freeLic && !args.freeAttendee) { console.error( `\n❌ --execute requires one of:\n` + ` --free-license-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, days_inactive: 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, days_inactive: 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', '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); });