From 7ea64b5f24b10eb3537b5a01e8a4e33296d3ddc3 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Mon, 24 Aug 2026 16:05:39 -0400 Subject: [PATCH] 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 --- .env.example | 8 + index.js | 8 +- src/server/app.js | 25 ++- src/services/spaceCleanupService.js | 303 ++++++++++++++++++++++++---- 4 files changed, 299 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index de08148..60e7436 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,14 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport # ATLAS_AUTH_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) --- # 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 diff --git a/index.js b/index.js index 44e70a5..509fe1e 100644 --- a/index.js +++ b/index.js @@ -85,6 +85,12 @@ db.serialize(() => { 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}`); }); @@ -131,7 +137,7 @@ const Framework = initializeBot({ // ──────────────────────────────────────────────────────────────────────────────── // Cron + Express App (extracted) // ──────────────────────────────────────────────────────────────────────────────── -setupCron(); +setupCron({ db, runSpaceCleanup }); const app = createApp({ db, diff --git a/src/server/app.js b/src/server/app.js index e6d8829..42b82dc 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -179,6 +179,7 @@ export function createApp({ th { background-color: #f0f0f0; } .archive { background-color: #fff3cd; } .delete { background-color: #f8d7da; } + .remind { background-color: #d1ecf1; } .skipped { color: #666; } h1 { color: #333; } .banner { padding: 10px; border-radius: 4px; margin: 10px 0; } @@ -193,6 +194,12 @@ export function createApp({ ${dryRun ? 'No changes were made. Add ?dryRun=false&live=true to actually run.' : 'Destructive actions were performed against Webex and the mappings DB.'} +

+ Rules: + Auto-remove (prune ≥14d / delete ≥60d) for COMPLETED / CONFIRMED, + COMPLETED / CANCELLED, and COMPLETED / NO CHARGE. + All other COMPLETED 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).

Summary

${esc(JSON.stringify(summary, null, 2))}
@@ -213,8 +220,9 @@ export function createApp({ `; results.forEach(r => { - const rowClass = r.action.includes('archive') ? 'archive' : - r.action.includes('delete') ? 'delete' : 'skipped'; + const rowClass = r.action.includes('archive') || r.action.includes('remove') ? 'archive' : + r.action.includes('delete') ? 'delete' : + r.action.includes('remind') ? 'remind' : 'skipped'; html += ` ${esc(r.woId)} @@ -414,13 +422,24 @@ async function cleanupOldLogs({ maxAgeDays = 7 } = {}) { * Convenience helper to set up the daily log cleanup cron. * Can be called from the thin bootstrap. */ -export function setupCron() { +export function setupCron({ db, runSpaceCleanup } = {}) { cron.schedule('0 15 0,8,16 * * *', () => { cleanupOldLogs().catch(err => { logger('cron:cleanupOldLogs', `Unhandled: ${err.message}`, 'error'); }); }); 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 }; diff --git a/src/services/spaceCleanupService.js b/src/services/spaceCleanupService.js index b9bed72..564f65c 100644 --- a/src/services/spaceCleanupService.js +++ b/src/services/spaceCleanupService.js @@ -4,27 +4,117 @@ import botClient from '../integrations/webex/botClient.js'; import { logger } from '../utils/logger.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. * * @param {boolean} dryRun * @param {object} [options] * @param {object} [options.db] - Optional sqlite3 database instance. - * When provided, this allows the caller (e.g. the main app) to ensure we are - * always using the exact same database connection that the production bot is using. + * @param {boolean} [options.remindersOnly] - When true, only post close-out reminders (no prune/delete). */ export async function runSpaceCleanup(dryRun = true, options = {}) { - const { db = defaultDb } = options; + const { db = defaultDb, remindersOnly = false } = options; const mode = dryRun ? '[DRY-RUN]' : '[LIVE]'; - - const REMOVE_OTHERS_AFTER_DAYS = 14; // "archive-like" step - const DELETE_AFTER_DAYS = 60; + const scope = remindersOnly ? 'reminders only' : 'full cleanup'; - console.log(`[SPACE-CLEANUP] ${mode} Starting job (Remove others ≥${REMOVE_OTHERS_AFTER_DAYS}d | Delete ≥${DELETE_AFTER_DAYS}d)...`); + console.log( + `[SPACE-CLEANUP] ${mode} Starting job (${scope} | auto-remove ≥${REMOVE_OTHERS_AFTER_DAYS}d prune / ≥${DELETE_AFTER_DAYS}d delete | reminders ≥${REMINDER_AFTER_DAYS}d)...` + ); 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 { const mappings = await new Promise((resolve, reject) => { @@ -38,78 +128,213 @@ export async function runSpaceCleanup(dryRun = true, options = {}) { try { const statusInfo = await getWorkOrderStatus(mapping.workOrderId); 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++; continue; } - const primary = statusInfo.primaryStatus?.toUpperCase() || 'UNKNOWN'; - const extended = statusInfo.extendedStatus?.toUpperCase() || ''; - const statusDisplay = extended ? `${primary} (${extended})` : primary; + const primary = normalizePrimary(statusInfo.primaryStatus); + const extended = normalizeExtended(statusInfo.extendedStatus); + const statusDisplay = extended ? `${primary} / ${extended}` : primary; + const classification = classifyWorkOrderCleanup(primary, extended); - if (primary !== 'INVOICED' && primary !== 'COMPLETED') { - results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, action: 'skipped', status: statusDisplay, reason: 'not qualifying' }); + if (classification.category === 'skip') { + results.push({ + woId: mapping.workOrderId, + roomId: mapping.roomId, + action: 'skipped', + status: statusDisplay, + reason: classification.reason, + }); skipped++; 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 reason = ''; - if (daysSinceUpdate >= DELETE_AFTER_DAYS) { + if (ageDays >= DELETE_AFTER_DAYS) { action = 'delete'; - reason = `${daysSinceUpdate} days → delete room`; - } else if (daysSinceUpdate >= REMOVE_OTHERS_AFTER_DAYS) { + reason = `${ageDays} days → delete room`; + } else if (ageDays >= REMOVE_OTHERS_AFTER_DAYS) { action = 'remove_others'; - reason = `${daysSinceUpdate} days → remove other members`; + reason = `${ageDays} days → remove other members`; } else { - reason = `only ${daysSinceUpdate} days old`; + reason = `auto-remove eligible but only ${ageDays} days old`; } if (action === '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; } 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 { if (action === 'remove_others') { 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++; } else if (action === 'delete') { await botClient.deleteRoom(mapping.roomId); deleted++; } - // Clean up mapping await new Promise((resolve, reject) => { db.run('DELETE FROM mappings WHERE workOrderId = ?', [mapping.workOrderId], (err) => { 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) { - 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++; 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); logger('SPACE-CLEANUP', `${mode} Job completed: ${JSON.stringify(summary)}`); return { summary, results, mode }; - } catch (err) { console.error(`[SPACE-CLEANUP] Critical error:`, err.message); return { error: err.message }; @@ -123,35 +348,32 @@ let _cachedBotPerson = null; async function getBotIdentity(axiosInstance) { 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; try { const { data } = await axiosInstance.get('/people/me'); _cachedBotPerson = { id: data?.id || envPersonId, - emails: (data?.emails || []).map(e => e.toLowerCase()), + emails: (data?.emails || []).map((e) => e.toLowerCase()), }; } catch (err) { 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: [] }; } return _cachedBotPerson; } -// Helper: Remove everyone except the bot itself (best-effort) async function removeAllOtherMembers(roomId) { try { const axiosInstance = botClient.axios; const bot = await getBotIdentity(axiosInstance); if (!bot.id && bot.emails.length === 0) { - // We couldn't figure out who "we" are. Removing everyone in this state - // would evict the bot from its own room, orphaning it. Refuse. - logger('SPACE-CLEANUP', `Refusing to remove members from ${roomId}: bot identity unresolved (set WEBEX_BOT_PERSON_ID or verify /people/me works)`, 'error'); + logger( + 'SPACE-CLEANUP', + `Refusing to remove members from ${roomId}: bot identity unresolved (set WEBEX_BOT_PERSON_ID or verify /people/me works)`, + 'error' + ); return; } @@ -161,10 +383,9 @@ async function removeAllOtherMembers(roomId) { for (const m of memberships) { 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 (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 { await axiosInstance.delete(`/memberships/${m.id}`); @@ -176,4 +397,4 @@ async function removeAllOtherMembers(roomId) { } catch (err) { logger('SPACE-CLEANUP', `removeAllOtherMembers failed for ${roomId}: ${err.message}`, 'error'); } -} \ No newline at end of file +}