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:
parent
8c6de65bb0
commit
8777e51d10
1 changed files with 58 additions and 24 deletions
|
|
@ -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(','));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue