Filter users-export by User Status (Inactive or Verified), not days

For the "Users Export" CSV, the target population is any account
Webex has flagged as not currently in use — that's status=Inactive
(previously active, now idle) or status=Verified (never signed in).
"Days since Last Service Accessed" is dropped as a filter criterion
because a Verified user has never signed in and therefore has a
blank days value. --min-days is documented as ignored for this
format.

The candidate record still carries days (nullable) so the sample
line and --report CSV can show it as informational context. Added
a "status" column to the report and to the audit-friendly console
sample.

Also prints every distinct User Status seen with counts, so the
operator can spot surprise values (e.g. the one "FALSE" row in the
current export) before hitting --execute.
This commit is contained in:
Joseph McQueen 2026-07-07 15:37:08 -04:00
parent 8c6de65bb0
commit 8777e51d10

View file

@ -9,12 +9,15 @@
* Candidate rule: IS_HOST=Y AND DAYS_SINCE_LAST_ACTIVE > --min-days. * Candidate rule: IS_HOST=Y AND DAYS_SINCE_LAST_ACTIVE > --min-days.
* *
* 2. "Users Export" (Users Manage users Export) * 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 * "Days since Last Service Accessed", one TRUE/FALSE column per
* license (e.g. "aeo2go.webex.com - WebEx Meetings Free [SubXXX]"). * license (e.g. "aeo2go.webex.com - WebEx Meetings Free [SubXXX]").
* Candidate rule: Days since Last Service Accessed > --min-days. * Candidate rule: User Status {Inactive, Verified}.
* Rows with blank days are skipped (never-signed-in accounts * - "Inactive" = active user that Webex has flagged idle
* handle those manually if needed). * - "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 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 * format #2, but we don't need one: detection of "currently holds the
@ -174,9 +177,21 @@ function detectFormat(header) {
return null; 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) { function extractCandidates(format, rows, minDays) {
const candidates = []; 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) { if (format === FORMAT_MEETINGS_INACTIVE) {
for (const r of rows) { for (const r of rows) {
@ -191,6 +206,7 @@ function extractCandidates(format, rows, minDays) {
candidates.push({ candidates.push({
email, email,
days, days,
status: 'Host',
firstName: r.FIRST_NAME || '', firstName: r.FIRST_NAME || '',
lastName: r.LAST_NAME || '', lastName: r.LAST_NAME || '',
lastActive: r.LAST_ACTIVE_DATE || '', lastActive: r.LAST_ACTIVE_DATE || '',
@ -203,18 +219,22 @@ function extractCandidates(format, rows, minDays) {
for (const r of rows) { for (const r of rows) {
const email = (r['User ID/Email (Required)'] || '').trim().toLowerCase(); const email = (r['User ID/Email (Required)'] || '').trim().toLowerCase();
if (!email) { skip.blankEmail++; continue; } 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(); const daysRaw = (r['Days since Last Service Accessed'] || '').trim();
// Never-signed-in accounts (blank days) are intentionally skipped: const days = daysRaw === '' ? null : Number(daysRaw);
// 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({ candidates.push({
email, email,
days, days: Number.isFinite(days) ? days : null,
status,
firstName: r['First Name'] || '', firstName: r['First Name'] || '',
lastName: r['Last Name'] || '', lastName: r['Last Name'] || '',
lastActive: r['Last Active Time'] || r['Last Service Accessed Time'] || '', lastActive: r['Last Active Time'] || r['Last Service Accessed Time'] || '',
@ -344,12 +364,22 @@ async function main() {
console.log(` → detected format: ${format}`); console.log(` → detected format: ${format}`);
const { candidates, skip } = extractCandidates(format, rows, args.minDays); const { candidates, skip } = extractCandidates(format, rows, args.minDays);
const skipSummary = format === FORMAT_MEETINGS_INACTIVE let filterLabel;
? `skipped ${skip.notHost} non-host, ${skip.recent} recent, ${skip.blankDays} blank-days, ${skip.blankEmail} blank-email` let skipSummary;
: `skipped ${skip.recent} recent, ${skip.blankDays} blank/never-active, ${skip.blankEmail} blank-email`; if (format === FORMAT_MEETINGS_INACTIVE) {
const filterLabel = format === FORMAT_MEETINGS_INACTIVE filterLabel = `host + >${args.minDays}d inactive`;
? `host + >${args.minDays}d inactive` skipSummary =
: `>${args.minDays}d since last service access`; `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}`); console.log(`${candidates.length} candidates (${filterLabel}); ${skipSummary}`);
// 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
@ -468,7 +498,9 @@ async function main() {
if (sliced.length > 0) { if (sliced.length > 0) {
console.log(`\n Sample of first 5 candidates:`); console.log(`\n Sample of first 5 candidates:`);
for (const s of sliced.slice(0, 5)) { 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); process.exit(0);
@ -536,7 +568,8 @@ async function main() {
perUser.push({ perUser.push({
email: u.email, email: u.email,
displayName: u.displayName, displayName: u.displayName,
days_inactive: u.days, status: u.status || '',
days_inactive: u.days == null ? '' : u.days,
personId: u.personId, personId: u.personId,
outcome, outcome,
error: '', error: '',
@ -553,7 +586,8 @@ async function main() {
perUser.push({ perUser.push({
email: u.email, email: u.email,
displayName: u.displayName, displayName: u.displayName,
days_inactive: u.days, status: u.status || '',
days_inactive: u.days == null ? '' : u.days,
personId: u.personId, personId: u.personId,
outcome: 'error', outcome: 'error',
error: msg, error: msg,
@ -571,7 +605,7 @@ async function main() {
if (args.report) { if (args.report) {
const reportPath = path.resolve(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 escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const lines = [cols.join(',')]; const lines = [cols.join(',')];
for (const p of perUser) lines.push(cols.map((c) => escape(p[c])).join(',')); for (const p of perUser) lines.push(cols.map((c) => escape(p[c])).join(','));