Refine space cleanup rules and add daily close-out reminders.
Auto-remove only terminal COMPLETED statuses (Confirmed, Cancelled, No Charge), post daily reminders for other COMPLETED variants until they reach a terminal state, and schedule reminder cron with dedup tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c2e98d105e
commit
7ea64b5f24
4 changed files with 299 additions and 45 deletions
|
|
@ -38,6 +38,14 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport
|
||||||
# ATLAS_AUTH_KEY=...
|
# ATLAS_AUTH_KEY=...
|
||||||
# OPTISIGN_API_KEY=...
|
# OPTISIGN_API_KEY=...
|
||||||
|
|
||||||
|
# --- Space cleanup (optional) ---
|
||||||
|
# Days after any non-terminal COMPLETED status before posting a daily
|
||||||
|
# close-out reminder in the Webex room. Auto-remove still applies only to
|
||||||
|
# COMPLETED/CONFIRMED, COMPLETED/CANCELLED, and COMPLETED/NO CHARGE.
|
||||||
|
# SPACE_CLEANUP_REMINDER_DAYS=60
|
||||||
|
# Cron for daily close-out reminders (default 14:00 UTC). Reminders only — no auto-delete.
|
||||||
|
# SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * *
|
||||||
|
|
||||||
# --- Admin endpoints (/cleanup-test, /stale-workorders) ---
|
# --- Admin endpoints (/cleanup-test, /stale-workorders) ---
|
||||||
# Required in production. If unset in NODE_ENV=production the endpoints refuse
|
# Required in production. If unset in NODE_ENV=production the endpoints refuse
|
||||||
# requests with 503. In dev (NODE_ENV!=production) unset means "allow" with a
|
# requests with 503. In dev (NODE_ENV!=production) unset means "allow" with a
|
||||||
|
|
|
||||||
8
index.js
8
index.js
|
|
@ -85,6 +85,12 @@ db.serialize(() => {
|
||||||
postedAt TEXT NOT NULL
|
postedAt TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS space_cleanup_reminders (
|
||||||
|
workOrderId INTEGER PRIMARY KEY,
|
||||||
|
lastReminderAt TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
console.log(`[DB] Connected to ${DB_PATH}`);
|
console.log(`[DB] Connected to ${DB_PATH}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -131,7 +137,7 @@ const Framework = initializeBot({
|
||||||
// ────────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
// Cron + Express App (extracted)
|
// Cron + Express App (extracted)
|
||||||
// ────────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────────
|
||||||
setupCron();
|
setupCron({ db, runSpaceCleanup });
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ export function createApp({
|
||||||
th { background-color: #f0f0f0; }
|
th { background-color: #f0f0f0; }
|
||||||
.archive { background-color: #fff3cd; }
|
.archive { background-color: #fff3cd; }
|
||||||
.delete { background-color: #f8d7da; }
|
.delete { background-color: #f8d7da; }
|
||||||
|
.remind { background-color: #d1ecf1; }
|
||||||
.skipped { color: #666; }
|
.skipped { color: #666; }
|
||||||
h1 { color: #333; }
|
h1 { color: #333; }
|
||||||
.banner { padding: 10px; border-radius: 4px; margin: 10px 0; }
|
.banner { padding: 10px; border-radius: 4px; margin: 10px 0; }
|
||||||
|
|
@ -193,6 +194,12 @@ export function createApp({
|
||||||
${dryRun
|
${dryRun
|
||||||
? 'No changes were made. Add <code>?dryRun=false&live=true</code> to actually run.'
|
? 'No changes were made. Add <code>?dryRun=false&live=true</code> to actually run.'
|
||||||
: 'Destructive actions were performed against Webex and the mappings DB.'}
|
: 'Destructive actions were performed against Webex and the mappings DB.'}
|
||||||
|
<br><br>
|
||||||
|
<strong>Rules:</strong>
|
||||||
|
Auto-remove (prune ≥14d / delete ≥60d) for <code>COMPLETED / CONFIRMED</code>,
|
||||||
|
<code>COMPLETED / CANCELLED</code>, and <code>COMPLETED / NO CHARGE</code>.
|
||||||
|
All other <code>COMPLETED</code> variants are held until one of those terminal statuses;
|
||||||
|
close-out reminders post in-room after ${esc(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60')} days (daily cron).
|
||||||
</div>
|
</div>
|
||||||
<h2>Summary</h2>
|
<h2>Summary</h2>
|
||||||
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>
|
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>
|
||||||
|
|
@ -213,8 +220,9 @@ export function createApp({
|
||||||
`;
|
`;
|
||||||
|
|
||||||
results.forEach(r => {
|
results.forEach(r => {
|
||||||
const rowClass = r.action.includes('archive') ? 'archive' :
|
const rowClass = r.action.includes('archive') || r.action.includes('remove') ? 'archive' :
|
||||||
r.action.includes('delete') ? 'delete' : 'skipped';
|
r.action.includes('delete') ? 'delete' :
|
||||||
|
r.action.includes('remind') ? 'remind' : 'skipped';
|
||||||
html += `
|
html += `
|
||||||
<tr class="${rowClass}">
|
<tr class="${rowClass}">
|
||||||
<td>${esc(r.woId)}</td>
|
<td>${esc(r.woId)}</td>
|
||||||
|
|
@ -414,13 +422,24 @@ async function cleanupOldLogs({ maxAgeDays = 7 } = {}) {
|
||||||
* Convenience helper to set up the daily log cleanup cron.
|
* Convenience helper to set up the daily log cleanup cron.
|
||||||
* Can be called from the thin bootstrap.
|
* Can be called from the thin bootstrap.
|
||||||
*/
|
*/
|
||||||
export function setupCron() {
|
export function setupCron({ db, runSpaceCleanup } = {}) {
|
||||||
cron.schedule('0 15 0,8,16 * * *', () => {
|
cron.schedule('0 15 0,8,16 * * *', () => {
|
||||||
cleanupOldLogs().catch(err => {
|
cleanupOldLogs().catch(err => {
|
||||||
logger('cron:cleanupOldLogs', `Unhandled: ${err.message}`, 'error');
|
logger('cron:cleanupOldLogs', `Unhandled: ${err.message}`, 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
logger('cron', 'Log cleanup scheduled (0 15 0,8,16 * * *)');
|
logger('cron', 'Log cleanup scheduled (0 15 0,8,16 * * *)');
|
||||||
|
|
||||||
|
if (db && runSpaceCleanup) {
|
||||||
|
const reminderCron = process.env.SPACE_CLEANUP_REMINDER_CRON || '0 0 14 * * *';
|
||||||
|
cron.schedule(reminderCron, () => {
|
||||||
|
logger('cron:spaceCleanupReminders', 'Starting daily close-out reminders');
|
||||||
|
runSpaceCleanup(false, { db, remindersOnly: true }).catch(err => {
|
||||||
|
logger('cron:spaceCleanupReminders', `Unhandled: ${err.message}`, 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger('cron', `Space cleanup close-out reminders scheduled (${reminderCron})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { cleanupOldLogs };
|
export { cleanupOldLogs };
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,117 @@ import botClient from '../integrations/webex/botClient.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import defaultDb from '../db/mappings.js';
|
import defaultDb from '../db/mappings.js';
|
||||||
|
|
||||||
|
const REMOVE_OTHERS_AFTER_DAYS = 14;
|
||||||
|
const DELETE_AFTER_DAYS = 60;
|
||||||
|
const REMINDER_AFTER_DAYS = parseInt(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60', 10);
|
||||||
|
|
||||||
|
/** COMPLETED + these extended values → auto-remove eligible. */
|
||||||
|
const AUTO_REMOVE_EXTENDED = new Set(['CONFIRMED', 'CANCELLED']);
|
||||||
|
|
||||||
|
function normalizePrimary(value) {
|
||||||
|
return (value || '').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtended(value) {
|
||||||
|
return (value || '').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNoChargeExtended(extendedRaw) {
|
||||||
|
const extended = normalizeExtended(extendedRaw);
|
||||||
|
return extended === 'NO CHARGE' || extended.includes('NO CHARGE');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a work order for space cleanup.
|
||||||
|
* @returns {{ category: 'auto_remove'|'remind'|'skip', reason: string }}
|
||||||
|
*/
|
||||||
|
export function classifyWorkOrderCleanup(primaryRaw, extendedRaw) {
|
||||||
|
const primary = normalizePrimary(primaryRaw);
|
||||||
|
const extended = normalizeExtended(extendedRaw);
|
||||||
|
|
||||||
|
if (primary !== 'COMPLETED') {
|
||||||
|
return { category: 'skip', reason: 'not a COMPLETED work order' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (AUTO_REMOVE_EXTENDED.has(extended) || isNoChargeExtended(extendedRaw)) {
|
||||||
|
return { category: 'auto_remove', reason: 'terminal COMPLETED extended status' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = extended ? `COMPLETED / ${extended}` : 'COMPLETED';
|
||||||
|
return {
|
||||||
|
category: 'remind',
|
||||||
|
reason: `${statusLabel} — awaiting Confirmed, Cancelled, or No Charge before removal`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysSince(dateStr) {
|
||||||
|
return Math.floor((Date.now() - new Date(dateStr)) / (1000 * 3600 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
function alreadyRemindedToday(lastReminderAt) {
|
||||||
|
if (!lastReminderAt) return false;
|
||||||
|
return new Date(lastReminderAt).toDateString() === new Date().toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCloseoutReminderMessage({ woNumber, workOrderId, daysSinceUpdate, statusDisplay }) {
|
||||||
|
const label = woNumber || workOrderId;
|
||||||
|
const statusText = statusDisplay || 'COMPLETED';
|
||||||
|
return (
|
||||||
|
`**Close-out reminder:** Work order **${label}** is **${statusText}** in ServiceChannel ` +
|
||||||
|
`and has been in that state for **${daysSinceUpdate} days**.\n\n` +
|
||||||
|
`This space will not be removed until the work order reaches **Confirmed**, **Cancelled**, or **No Charge**. ` +
|
||||||
|
`Please review in ServiceChannel and close out this Webex space when appropriate.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLastReminder(db, workOrderId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT lastReminderAt FROM space_cleanup_reminders WHERE workOrderId = ?',
|
||||||
|
[workOrderId],
|
||||||
|
(err, row) => (err ? reject(err) : resolve(row?.lastReminderAt || null))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordReminder(db, workOrderId) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO space_cleanup_reminders (workOrderId, lastReminderAt) VALUES (?, ?)
|
||||||
|
ON CONFLICT(workOrderId) DO UPDATE SET lastReminderAt = excluded.lastReminderAt`,
|
||||||
|
[workOrderId, now],
|
||||||
|
(err) => (err ? reject(err) : resolve())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs the space cleanup job.
|
* Runs the space cleanup job.
|
||||||
*
|
*
|
||||||
* @param {boolean} dryRun
|
* @param {boolean} dryRun
|
||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
* @param {object} [options.db] - Optional sqlite3 database instance.
|
* @param {object} [options.db] - Optional sqlite3 database instance.
|
||||||
* When provided, this allows the caller (e.g. the main app) to ensure we are
|
* @param {boolean} [options.remindersOnly] - When true, only post close-out reminders (no prune/delete).
|
||||||
* always using the exact same database connection that the production bot is using.
|
|
||||||
*/
|
*/
|
||||||
export async function runSpaceCleanup(dryRun = true, options = {}) {
|
export async function runSpaceCleanup(dryRun = true, options = {}) {
|
||||||
const { db = defaultDb } = options;
|
const { db = defaultDb, remindersOnly = false } = options;
|
||||||
|
|
||||||
const mode = dryRun ? '[DRY-RUN]' : '[LIVE]';
|
const mode = dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||||
|
const scope = remindersOnly ? 'reminders only' : 'full cleanup';
|
||||||
|
|
||||||
const REMOVE_OTHERS_AFTER_DAYS = 14; // "archive-like" step
|
console.log(
|
||||||
const DELETE_AFTER_DAYS = 60;
|
`[SPACE-CLEANUP] ${mode} Starting job (${scope} | auto-remove ≥${REMOVE_OTHERS_AFTER_DAYS}d prune / ≥${DELETE_AFTER_DAYS}d delete | reminders ≥${REMINDER_AFTER_DAYS}d)...`
|
||||||
|
);
|
||||||
console.log(`[SPACE-CLEANUP] ${mode} Starting job (Remove others ≥${REMOVE_OTHERS_AFTER_DAYS}d | Delete ≥${DELETE_AFTER_DAYS}d)...`);
|
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
let qualifying = 0, removedOthers = 0, deleted = 0, skipped = 0, failed = 0;
|
let autoRemoveEligible = 0;
|
||||||
|
let remindEligible = 0;
|
||||||
|
let removedOthers = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
let reminded = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mappings = await new Promise((resolve, reject) => {
|
const mappings = await new Promise((resolve, reject) => {
|
||||||
|
|
@ -38,78 +128,213 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
||||||
try {
|
try {
|
||||||
const statusInfo = await getWorkOrderStatus(mapping.workOrderId);
|
const statusInfo = await getWorkOrderStatus(mapping.workOrderId);
|
||||||
if (!statusInfo) {
|
if (!statusInfo) {
|
||||||
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'skipped', status: 'UNKNOWN', reason: 'status fetch failed' });
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'skipped',
|
||||||
|
status: 'UNKNOWN',
|
||||||
|
reason: 'status fetch failed',
|
||||||
|
});
|
||||||
failed++;
|
failed++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const primary = statusInfo.primaryStatus?.toUpperCase() || 'UNKNOWN';
|
const primary = normalizePrimary(statusInfo.primaryStatus);
|
||||||
const extended = statusInfo.extendedStatus?.toUpperCase() || '';
|
const extended = normalizeExtended(statusInfo.extendedStatus);
|
||||||
const statusDisplay = extended ? `${primary} (${extended})` : primary;
|
const statusDisplay = extended ? `${primary} / ${extended}` : primary;
|
||||||
|
const classification = classifyWorkOrderCleanup(primary, extended);
|
||||||
|
|
||||||
if (primary !== 'INVOICED' && primary !== 'COMPLETED') {
|
if (classification.category === 'skip') {
|
||||||
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'skipped', status: statusDisplay, reason: 'not qualifying' });
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'skipped',
|
||||||
|
status: statusDisplay,
|
||||||
|
reason: classification.reason,
|
||||||
|
});
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
qualifying++;
|
const ageDays = daysSince(statusInfo.updatedDate);
|
||||||
|
|
||||||
const daysSinceUpdate = Math.floor((Date.now() - new Date(statusInfo.updatedDate)) / (1000 * 3600 * 24));
|
if (classification.category === 'remind') {
|
||||||
|
remindEligible++;
|
||||||
|
|
||||||
|
if (ageDays < REMINDER_AFTER_DAYS) {
|
||||||
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'skipped',
|
||||||
|
days: ageDays,
|
||||||
|
status: statusDisplay,
|
||||||
|
reason: `${statusDisplay} — ${ageDays} days (reminder at ${REMINDER_AFTER_DAYS})`,
|
||||||
|
});
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastReminderAt = await getLastReminder(db, mapping.workOrderId);
|
||||||
|
if (alreadyRemindedToday(lastReminderAt)) {
|
||||||
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'skipped',
|
||||||
|
days: ageDays,
|
||||||
|
status: statusDisplay,
|
||||||
|
reason: 'reminder already posted today',
|
||||||
|
});
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reminderText = buildCloseoutReminderMessage({
|
||||||
|
woNumber: statusInfo.woNumber,
|
||||||
|
workOrderId: mapping.workOrderId,
|
||||||
|
daysSinceUpdate: ageDays,
|
||||||
|
statusDisplay,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'would_remind',
|
||||||
|
days: ageDays,
|
||||||
|
status: statusDisplay,
|
||||||
|
reason: `${ageDays} days → post close-out reminder`,
|
||||||
|
});
|
||||||
|
reminded++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await botClient.sendMarkdown(mapping.roomId, reminderText);
|
||||||
|
await recordReminder(db, mapping.workOrderId);
|
||||||
|
logger(
|
||||||
|
'SPACE-CLEANUP',
|
||||||
|
`Posted close-out reminder in room ${mapping.roomId} for WO ${mapping.workOrderId} [${statusDisplay}]`
|
||||||
|
);
|
||||||
|
reminded++;
|
||||||
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'remind',
|
||||||
|
days: ageDays,
|
||||||
|
status: statusDisplay,
|
||||||
|
reason: `${ageDays} days → posted close-out reminder`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto_remove
|
||||||
|
if (remindersOnly) {
|
||||||
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'skipped',
|
||||||
|
days: ageDays,
|
||||||
|
status: statusDisplay,
|
||||||
|
reason: 'auto-remove skipped (reminders-only run)',
|
||||||
|
});
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoRemoveEligible++;
|
||||||
|
|
||||||
let action = 'skipped';
|
let action = 'skipped';
|
||||||
let reason = '';
|
let reason = '';
|
||||||
|
|
||||||
if (daysSinceUpdate >= DELETE_AFTER_DAYS) {
|
if (ageDays >= DELETE_AFTER_DAYS) {
|
||||||
action = 'delete';
|
action = 'delete';
|
||||||
reason = `${daysSinceUpdate} days → delete room`;
|
reason = `${ageDays} days → delete room`;
|
||||||
} else if (daysSinceUpdate >= REMOVE_OTHERS_AFTER_DAYS) {
|
} else if (ageDays >= REMOVE_OTHERS_AFTER_DAYS) {
|
||||||
action = 'remove_others';
|
action = 'remove_others';
|
||||||
reason = `${daysSinceUpdate} days → remove other members`;
|
reason = `${ageDays} days → remove other members`;
|
||||||
} else {
|
} else {
|
||||||
reason = `only ${daysSinceUpdate} days old`;
|
reason = `auto-remove eligible but only ${ageDays} days old`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'skipped') {
|
if (action === 'skipped') {
|
||||||
skipped++;
|
skipped++;
|
||||||
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'skipped', days: daysSinceUpdate, status: statusDisplay, reason });
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'skipped',
|
||||||
|
days: ageDays,
|
||||||
|
status: statusDisplay,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dryRun) {
|
if (dryRun) {
|
||||||
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: `would_${action}`, days: daysSinceUpdate, status: statusDisplay, reason });
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: `would_${action}`,
|
||||||
|
days: ageDays,
|
||||||
|
status: statusDisplay,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (action === 'remove_others') {
|
if (action === 'remove_others') {
|
||||||
await removeAllOtherMembers(mapping.roomId);
|
await removeAllOtherMembers(mapping.roomId);
|
||||||
logger('SPACE-CLEANUP', `Removed all other members from room ${mapping.roomId} for WO ${mapping.workOrderId} [${statusDisplay}]`);
|
logger(
|
||||||
|
'SPACE-CLEANUP',
|
||||||
|
`Removed all other members from room ${mapping.roomId} for WO ${mapping.workOrderId} [${statusDisplay}]`
|
||||||
|
);
|
||||||
removedOthers++;
|
removedOthers++;
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
await botClient.deleteRoom(mapping.roomId);
|
await botClient.deleteRoom(mapping.roomId);
|
||||||
deleted++;
|
deleted++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up mapping
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
db.run('DELETE FROM mappings WHERE workOrderId = ?', [mapping.workOrderId], (err) => {
|
db.run('DELETE FROM mappings WHERE workOrderId = ?', [mapping.workOrderId], (err) => {
|
||||||
err ? reject(err) : resolve();
|
err ? reject(err) : resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action, days: daysSinceUpdate, status: statusDisplay, reason });
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action,
|
||||||
|
days: ageDays,
|
||||||
|
status: statusDisplay,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'failed', status: 'ERROR', reason: err.message });
|
results.push({
|
||||||
|
woId: mapping.workOrderId,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
action: 'failed',
|
||||||
|
status: 'ERROR',
|
||||||
|
reason: err.message,
|
||||||
|
});
|
||||||
failed++;
|
failed++;
|
||||||
logger('SPACE-CLEANUP', `Failed for WO ${mapping.workOrderId}: ${err.message}`);
|
logger('SPACE-CLEANUP', `Failed for WO ${mapping.workOrderId}: ${err.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = { totalChecked: mappings.length, qualifying, removedOthers, deleted, skipped, failed, dryRun };
|
const summary = {
|
||||||
|
totalChecked: mappings.length,
|
||||||
|
autoRemoveEligible,
|
||||||
|
remindEligible,
|
||||||
|
removedOthers,
|
||||||
|
deleted,
|
||||||
|
reminded,
|
||||||
|
skipped,
|
||||||
|
failed,
|
||||||
|
dryRun,
|
||||||
|
remindersOnly,
|
||||||
|
reminderAfterDays: REMINDER_AFTER_DAYS,
|
||||||
|
};
|
||||||
console.log(`[SPACE-CLEANUP] ${mode} Job completed:`, summary);
|
console.log(`[SPACE-CLEANUP] ${mode} Job completed:`, summary);
|
||||||
logger('SPACE-CLEANUP', `${mode} Job completed: ${JSON.stringify(summary)}`);
|
logger('SPACE-CLEANUP', `${mode} Job completed: ${JSON.stringify(summary)}`);
|
||||||
|
|
||||||
return { summary, results, mode };
|
return { summary, results, mode };
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[SPACE-CLEANUP] Critical error:`, err.message);
|
console.error(`[SPACE-CLEANUP] Critical error:`, err.message);
|
||||||
return { error: err.message };
|
return { error: err.message };
|
||||||
|
|
@ -123,35 +348,32 @@ let _cachedBotPerson = null;
|
||||||
async function getBotIdentity(axiosInstance) {
|
async function getBotIdentity(axiosInstance) {
|
||||||
if (_cachedBotPerson) return _cachedBotPerson;
|
if (_cachedBotPerson) return _cachedBotPerson;
|
||||||
|
|
||||||
// Prefer env-provided ID if set; still fetch email so the email-based skip
|
|
||||||
// works too even when the person ID is stale.
|
|
||||||
const envPersonId = process.env.WEBEX_BOT_PERSON_ID || null;
|
const envPersonId = process.env.WEBEX_BOT_PERSON_ID || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await axiosInstance.get('/people/me');
|
const { data } = await axiosInstance.get('/people/me');
|
||||||
_cachedBotPerson = {
|
_cachedBotPerson = {
|
||||||
id: data?.id || envPersonId,
|
id: data?.id || envPersonId,
|
||||||
emails: (data?.emails || []).map(e => e.toLowerCase()),
|
emails: (data?.emails || []).map((e) => e.toLowerCase()),
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger('SPACE-CLEANUP', `Failed to resolve bot identity via /people/me: ${err.message}`, 'warn');
|
logger('SPACE-CLEANUP', `Failed to resolve bot identity via /people/me: ${err.message}`, 'warn');
|
||||||
// Fall back to env-only. Emails list stays empty — the caller will still
|
|
||||||
// skip anything matching the well-known bot email suffix below.
|
|
||||||
_cachedBotPerson = { id: envPersonId, emails: [] };
|
_cachedBotPerson = { id: envPersonId, emails: [] };
|
||||||
}
|
}
|
||||||
return _cachedBotPerson;
|
return _cachedBotPerson;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Remove everyone except the bot itself (best-effort)
|
|
||||||
async function removeAllOtherMembers(roomId) {
|
async function removeAllOtherMembers(roomId) {
|
||||||
try {
|
try {
|
||||||
const axiosInstance = botClient.axios;
|
const axiosInstance = botClient.axios;
|
||||||
const bot = await getBotIdentity(axiosInstance);
|
const bot = await getBotIdentity(axiosInstance);
|
||||||
|
|
||||||
if (!bot.id && bot.emails.length === 0) {
|
if (!bot.id && bot.emails.length === 0) {
|
||||||
// We couldn't figure out who "we" are. Removing everyone in this state
|
logger(
|
||||||
// would evict the bot from its own room, orphaning it. Refuse.
|
'SPACE-CLEANUP',
|
||||||
logger('SPACE-CLEANUP', `Refusing to remove members from ${roomId}: bot identity unresolved (set WEBEX_BOT_PERSON_ID or verify /people/me works)`, 'error');
|
`Refusing to remove members from ${roomId}: bot identity unresolved (set WEBEX_BOT_PERSON_ID or verify /people/me works)`,
|
||||||
|
'error'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,10 +383,9 @@ async function removeAllOtherMembers(roomId) {
|
||||||
for (const m of memberships) {
|
for (const m of memberships) {
|
||||||
const emailLc = (m.personEmail || '').toLowerCase();
|
const emailLc = (m.personEmail || '').toLowerCase();
|
||||||
|
|
||||||
// Skip the bot's own membership by any signal we can get.
|
|
||||||
if (bot.id && m.personId === bot.id) continue;
|
if (bot.id && m.personId === bot.id) continue;
|
||||||
if (emailLc && bot.emails.includes(emailLc)) continue;
|
if (emailLc && bot.emails.includes(emailLc)) continue;
|
||||||
if (emailLc.endsWith('@webex.bot')) continue; // catches bot mail regardless of local part
|
if (emailLc.endsWith('@webex.bot')) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axiosInstance.delete(`/memberships/${m.id}`);
|
await axiosInstance.delete(`/memberships/${m.id}`);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue