From 8c6de65bb0e875a18e9fd85606cd396e142b3467 Mon Sep 17 00:00:00 2001 From: Joseph McQueen Date: Tue, 7 Jul 2026 15:34:11 -0400 Subject: [PATCH] Support Control Hub "Users Export" CSV in reclaimWebexHosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/reclaimWebexHosts.js | 165 +++++++++++++++++++++++++---------- 1 file changed, 119 insertions(+), 46 deletions(-) diff --git a/scripts/reclaimWebexHosts.js b/scripts/reclaimWebexHosts.js index c982f9d..255d2b4 100644 --- a/scripts/reclaimWebexHosts.js +++ b/scripts/reclaimWebexHosts.js @@ -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/.csv" \ * [--site aeo2go.webex.com] \ * [--min-days 120] \ * [--host-license-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