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
+}