Support Control Hub "Users Export" CSV in reclaimWebexHosts

Auto-detect CSV format from the header:
  • meetings-inactive: EMAIL / IS_HOST / DAYS_SINCE_LAST_ACTIVE
    (Analyzer → Meetings → Inactive Users)
  • users-export: "User ID/Email (Required)" /
    "Days since Last Service Accessed"
    (Users → Manage users → Export)

The users-export report has no host flag, but we don't need one —
the authoritative host-holder set comes from the live assignee
roster fetched from the Webex API. Format-B rows with blank
"Days since Last Service Accessed" (never-signed-in accounts,
often generic mailroom/store logins) are intentionally skipped so
they aren't silently reclaimed.

Also stopped upper-casing the header so we can preserve the
punctuation-rich column names Users Export uses verbatim.
This commit is contained in:
Joseph McQueen 2026-07-07 15:34:11 -04:00
parent c996d5d32e
commit 8c6de65bb0

View file

@ -2,21 +2,33 @@
/** /**
* Reclaim Webex Meetings host licenses from long-inactive users. * Reclaim Webex Meetings host licenses from long-inactive users.
* *
* Feeds off a Control Hub "Meetings Inactive Users" report CSV * Accepts either of two Control Hub exports:
* (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 * 1. "Meetings Inactive Users" (Analyzer Meetings Inactive Users)
* license once up-front (paginated) and cross-reference the CSV emails * Columns include EMAIL, IS_HOST, DAYS_SINCE_LAST_ACTIVE.
* against it. Anyone in the CSV who isn't currently a holder is skipped * Candidate rule: IS_HOST=Y AND DAYS_SINCE_LAST_ACTIVE > --min-days.
* silently no per-user /people search needed, and the personId comes *
* straight off the assignee record. * 2. "Users Export" (Users Manage users Export)
* Columns include "User ID/Email (Required)",
* "Days since Last Service Accessed", one TRUE/FALSE column per
* license (e.g. "aeo2go.webex.com - WebEx Meetings Free [SubXXX]").
* Candidate rule: Days since Last Service Accessed > --min-days.
* Rows with blank days are skipped (never-signed-in accounts
* handle those manually if needed).
*
* 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, * DRY-RUN by default. Nothing mutates without `--execute`. In dry-run,
* the script lists every license on the site so you can pick the * the script lists every license on the site so you can pick the
@ -24,7 +36,7 @@
* *
* Usage: * Usage:
* node scripts/reclaimWebexHosts.js \ * node scripts/reclaimWebexHosts.js \
* --csv "/path/to/Meetings Inactive Users_....csv" \ * --csv "/path/to/<report>.csv" \
* [--site aeo2go.webex.com] \ * [--site aeo2go.webex.com] \
* [--min-days 120] \ * [--min-days 120] \
* [--host-license-id <id>] \ * [--host-license-id <id>] \
@ -131,7 +143,10 @@ function readCsv(filePath) {
const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0); const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
if (lines.length === 0) return { header: [], rows: [] }; if (lines.length === 0) return { header: [], rows: [] };
const header = parseCsvLine(lines[0]).map((h) => h.trim().toUpperCase()); // 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 rows = lines.slice(1).map((l) => {
const cells = parseCsvLine(l); const cells = parseCsvLine(l);
const row = {}; const row = {};
@ -141,6 +156,76 @@ function readCsv(filePath) {
return { header, rows }; 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;
}
function extractCandidates(format, rows, minDays) {
const candidates = [];
const skip = { notHost: 0, recent: 0, blankDays: 0, blankEmail: 0 };
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,
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 daysRaw = (r['Days since Last Service Accessed'] || '').trim();
// Never-signed-in accounts (blank days) are intentionally skipped:
// they don't have a "last active" signal we can reason about here,
// and they include generic mailroom / store accounts that shouldn't
// be silently reclaimed. Handle those separately if needed.
if (daysRaw === '') { skip.blankDays++; continue; }
const days = Number(daysRaw);
if (!Number.isFinite(days)) { skip.blankDays++; continue; }
if (!(days > minDays)) { skip.recent++; continue; }
candidates.push({
email,
days,
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 // Bounded-concurrency worker pool
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -244,40 +329,28 @@ async function main() {
console.log(`📄 Reading ${csvPath}`); console.log(`📄 Reading ${csvPath}`);
const { header, rows } = readCsv(csvPath); const { header, rows } = readCsv(csvPath);
console.log(`${rows.length} rows, columns: ${header.join(', ')}`); console.log(`${rows.length} rows`);
const required = ['EMAIL', 'IS_HOST', 'DAYS_SINCE_LAST_ACTIVE']; const format = detectFormat(header);
const missing = required.filter((c) => !header.includes(c)); if (!format) {
if (missing.length > 0) { console.error(
console.error(`❌ CSV missing required columns: ${missing.join(', ')}`); `❌ Could not detect CSV format. Supported reports:\n` +
console.error(` Expected a Control Hub "Meetings Inactive Users" export.`); ` • "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); process.exit(2);
} }
console.log(` → detected format: ${format}`);
// Filter: hosts inactive over --min-days. Emails are lowercased for const { candidates, skip } = extractCandidates(format, rows, args.minDays);
// the assignee cross-reference below. const skipSummary = format === FORMAT_MEETINGS_INACTIVE
const candidates = []; ? `skipped ${skip.notHost} non-host, ${skip.recent} recent, ${skip.blankDays} blank-days, ${skip.blankEmail} blank-email`
let skippedNotHost = 0; : `skipped ${skip.recent} recent, ${skip.blankDays} blank/never-active, ${skip.blankEmail} blank-email`;
let skippedRecent = 0; const filterLabel = format === FORMAT_MEETINGS_INACTIVE
for (const r of rows) { ? `host + >${args.minDays}d inactive`
const isHost = (r.IS_HOST || '').trim().toUpperCase() === 'Y'; : `>${args.minDays}d since last service access`;
const days = Number(r.DAYS_SINCE_LAST_ACTIVE || 0); console.log(`${candidates.length} candidates (${filterLabel}); ${skipSummary}`);
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 // 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 // id, (b) let the operator pick the free license id in dry-run, and