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.
634 lines
24 KiB
JavaScript
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);
|
|
});
|