diff --git a/scripts/reclaimWebexHosts.js b/scripts/reclaimWebexHosts.js index 255d2b4..fc29c8d 100644 --- a/scripts/reclaimWebexHosts.js +++ b/scripts/reclaimWebexHosts.js @@ -9,12 +9,15 @@ * 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)", + * Columns include "User ID/Email (Required)", "User Status", * "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). + * Candidate rule: User Status ∈ {Inactive, Verified}. + * - "Inactive" = active user that Webex has flagged idle + * - "Verified" = never signed in + * "Days since Last Service Accessed" is NOT used for this format + * (a Verified user has never logged in, so days is blank), and + * --min-days is therefore ignored. * * 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 @@ -174,9 +177,21 @@ function detectFormat(header) { return null; } +// Users Export status values Webex considers "not currently in use". +// "Active" users are excluded regardless of last-access date. +const USERS_EXPORT_ELIGIBLE_STATUSES = new Set(['Inactive', 'Verified']); + function extractCandidates(format, rows, minDays) { const candidates = []; - const skip = { notHost: 0, recent: 0, blankDays: 0, blankEmail: 0 }; + const skip = { + notHost: 0, + recent: 0, + blankDays: 0, + blankEmail: 0, + activeStatus: 0, + unknownStatus: 0, + byStatus: {}, + }; if (format === FORMAT_MEETINGS_INACTIVE) { for (const r of rows) { @@ -191,6 +206,7 @@ function extractCandidates(format, rows, minDays) { candidates.push({ email, days, + status: 'Host', firstName: r.FIRST_NAME || '', lastName: r.LAST_NAME || '', lastActive: r.LAST_ACTIVE_DATE || '', @@ -203,18 +219,22 @@ function extractCandidates(format, rows, minDays) { for (const r of rows) { const email = (r['User ID/Email (Required)'] || '').trim().toLowerCase(); if (!email) { skip.blankEmail++; continue; } + const status = (r['User Status'] || '').trim(); + skip.byStatus[status || '(blank)'] = (skip.byStatus[status || '(blank)'] || 0) + 1; + if (!USERS_EXPORT_ELIGIBLE_STATUSES.has(status)) { + if (status === 'Active') skip.activeStatus++; + else skip.unknownStatus++; + continue; + } + // Days is informational only for this format — a Verified user + // has never signed in, so days is blank. Keep it for the audit + // record / --report CSV. 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; } + const days = daysRaw === '' ? null : Number(daysRaw); candidates.push({ email, - days, + days: Number.isFinite(days) ? days : null, + status, firstName: r['First Name'] || '', lastName: r['Last Name'] || '', lastActive: r['Last Active Time'] || r['Last Service Accessed Time'] || '', @@ -344,12 +364,22 @@ async function main() { console.log(` → detected format: ${format}`); 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`; + let filterLabel; + let skipSummary; + if (format === FORMAT_MEETINGS_INACTIVE) { + filterLabel = `host + >${args.minDays}d inactive`; + skipSummary = + `skipped ${skip.notHost} non-host, ${skip.recent} recent, ` + + `${skip.blankDays} blank-days, ${skip.blankEmail} blank-email`; + } else { + filterLabel = `status ∈ {Inactive, Verified}`; + const seen = Object.entries(skip.byStatus) + .sort((a, b) => b[1] - a[1]) + .map(([s, n]) => `${s}=${n}`).join(', '); + skipSummary = + `skipped ${skip.activeStatus} Active, ${skip.unknownStatus} other-status, ` + + `${skip.blankEmail} blank-email (all statuses seen: ${seen})`; + } console.log(` → ${candidates.length} candidates (${filterLabel}); ${skipSummary}`); // Enumerate the site's licenses so we can (a) verify the host license @@ -468,7 +498,9 @@ async function main() { if (sliced.length > 0) { console.log(`\n Sample of first 5 candidates:`); for (const s of sliced.slice(0, 5)) { - console.log(` - ${s.displayName} <${s.email}> (${s.days}d inactive, personId=${s.personId})`); + const age = s.days == null ? 'never-signed-in' : `${s.days}d inactive`; + const st = s.status && s.status !== 'Host' ? ` status=${s.status}` : ''; + console.log(` - ${s.displayName} <${s.email}> (${age}${st}, personId=${s.personId})`); } } process.exit(0); @@ -536,7 +568,8 @@ async function main() { perUser.push({ email: u.email, displayName: u.displayName, - days_inactive: u.days, + status: u.status || '', + days_inactive: u.days == null ? '' : u.days, personId: u.personId, outcome, error: '', @@ -553,7 +586,8 @@ async function main() { perUser.push({ email: u.email, displayName: u.displayName, - days_inactive: u.days, + status: u.status || '', + days_inactive: u.days == null ? '' : u.days, personId: u.personId, outcome: 'error', error: msg, @@ -571,7 +605,7 @@ async function main() { if (args.report) { const reportPath = path.resolve(args.report); - const cols = ['email', 'displayName', 'days_inactive', 'personId', 'outcome', 'error']; + const cols = ['email', 'displayName', 'status', 'days_inactive', 'personId', 'outcome', 'error']; const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; const lines = [cols.join(',')]; for (const p of perUser) lines.push(cols.map((c) => escape(p[c])).join(','));