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:
jmcqueen 2026-08-24 16:05:39 -04:00
parent c2e98d105e
commit 7ea64b5f24
4 changed files with 299 additions and 45 deletions

View file

@ -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

View file

@ -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,

View file

@ -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 <code>?dryRun=false&amp;live=true</code> to actually run.'
: '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>
<h2>Summary</h2>
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>
@ -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 += `
<tr class="${rowClass}">
<td>${esc(r.woId)}</td>
@ -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 };

View file

@ -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');
}
}
}