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.
*
* 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.
* Accepts either of two Control Hub exports:
*
* 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.
* 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)",
* "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,
* the script lists every license on the site so you can pick the
@ -24,7 +36,7 @@
*
* Usage:
* node scripts/reclaimWebexHosts.js \
* --csv "/path/to/Meetings Inactive Users_....csv" \
* --csv "/path/to/<report>.csv" \
* [--site aeo2go.webex.com] \
* [--min-days 120] \
* [--host-license-id <id>] \
@ -131,7 +143,10 @@ 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());
// 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 = {};
@ -141,6 +156,76 @@ function readCsv(filePath) {
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
// ─────────────────────────────────────────────────────────────────────────────
@ -244,40 +329,28 @@ async function main() {
console.log(`📄 Reading ${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 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.`);
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}`);
// 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`,
);
const { candidates, skip } = extractCandidates(format, rows, args.minDays);
const skipSummary = format === FORMAT_MEETINGS_INACTIVE
? `skipped ${skip.notHost} non-host, ${skip.recent} recent, ${skip.blankDays} blank-days, ${skip.blankEmail} blank-email`
: `skipped ${skip.recent} recent, ${skip.blankDays} blank/never-active, ${skip.blankEmail} blank-email`;
const filterLabel = format === FORMAT_MEETINGS_INACTIVE
? `host + >${args.minDays}d inactive`
: `>${args.minDays}d since last service access`;
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