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:
parent
c996d5d32e
commit
8c6de65bb0
1 changed files with 119 additions and 46 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue