collabSupport/scripts/lib/webexBulk.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

168 lines
7.5 KiB
JavaScript

// scripts/lib/webexBulk.js
//
// Shared utilities for bulk Webex admin scripts driven off Control Hub
// CSV exports (reclaimWebexHosts.js, removeAdvancedMessaging.js, etc.).
// Kept intentionally dependency-free — everything the operator needs is
// already in the repo (WebexClient, logger). No dev deps to install.
//
// Contents:
// CSV
// parseCsvLine(line) → string[]
// readCsv(path) → { header, rows }
// detectFormat(header) → 'meetings-inactive' | 'users-export' | null
// FORMAT_* constants
//
// Concurrency + retry
// runPool(items, limit, worker) → results[] with { ok, value? , error? }
// callWithRetry(fn, opts) → retries 429/503 with Retry-After
//
// Webex helpers
// fetchAllLicenses() → all org licenses
// fetchSiteLicenses(siteUrl) → subset with siteUrl matching (case-insensitive)
// seatsFree(license) → number
// explainWebexError(err) → concise `${apiMsg} (HTTP ${status})`
//
// All Webex calls go through the shared WebexClient singleton which
// handles service-app token refresh; nothing to configure per-script.
import fs from 'node:fs';
import webex from '../../integrations/webex/WebexClient.js';
// ─────────────────────────────────────────────────────────────────────────────
// CSV parsing (RFC 4180-ish; handles quoted fields, escaped "")
// ─────────────────────────────────────────────────────────────────────────────
export 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;
}
export 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 — Control Hub exports vary between
// UPPER_SNAKE and Title Case With Punctuation, and Users Export
// license columns are literally "aeo2go.webex.com - WebEx Meetings
// Free [Sub601269]". Case-preserving avoids ambiguity.
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 };
}
// ─────────────────────────────────────────────────────────────────────────────
// Format detection
// ─────────────────────────────────────────────────────────────────────────────
export const FORMAT_MEETINGS_INACTIVE = 'meetings-inactive';
export const FORMAT_USERS_EXPORT = 'users-export';
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;
}
// ─────────────────────────────────────────────────────────────────────────────
// Bounded-concurrency worker pool
// ─────────────────────────────────────────────────────────────────────────────
// Runs `worker(item, idx)` across `items` with at most `limit` in flight.
// Never throws — each slot in the result array is either `{ok: true, value}`
// or `{ok: false, error}` so the caller can accumulate a per-item report.
export 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;
}
// ─────────────────────────────────────────────────────────────────────────────
// 429/503-aware retry helper. Honors Retry-After (seconds).
// ─────────────────────────────────────────────────────────────────────────────
export 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;
}
// ─────────────────────────────────────────────────────────────────────────────
// Webex license helpers
// ─────────────────────────────────────────────────────────────────────────────
export async function fetchAllLicenses() {
const data = await webex.listLicenses();
return Array.isArray(data?.items) ? data.items : [];
}
export async function fetchSiteLicenses(siteUrl) {
const items = await fetchAllLicenses();
const want = (siteUrl || '').toLowerCase();
return items.filter((l) => (l.siteUrl || '').toLowerCase() === want);
}
export function seatsFree(l) {
const total = Number(l.totalUnits ?? 0);
const used = Number(l.consumedUnits ?? 0);
return Math.max(0, total - used);
}
export 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;
}