#!/usr/bin/env node // scripts/prune-stale-spaces.ts // // Report on (and optionally delete) contact Spaces that haven't seen any SMS // activity for a long time. // // Usage: // node --require ./scripts/navigator-patch.cjs scripts/prune-stale-spaces.ts // Dry run: prints the stale-space table, changes nothing. // // node ... scripts/prune-stale-spaces.ts --apply // Deletes the Webex room and the Space row (cascade removes Messages, // BulkJobs, Surveys, PendingMessages). // // --months=N Age threshold in calendar months (default 24). // --apply Perform deletions. Without this, it's a dry run. // --no-webex Skip the Webex room deletion, only delete the DB row. // --limit=N Cap how many spaces get processed (safety net for large lists). // // The script never touches Space rows created *inside* the age window — a new // empty contact that hasn't been messaged yet is safe. import env from '../src/config/env.ts'; import logger from '../src/services/Logger.ts'; import prisma from '../src/services/PrismaService.ts'; import { findStaleSpaces, type StaleSpace } from '../src/lib/staleSpaces.ts'; interface Args { months: number; apply: boolean; deleteWebex: boolean; limit: number | undefined; } function parseArgs(argv: string[]): Args { const args: Args = { months: 24, apply: false, deleteWebex: true, limit: undefined }; for (const raw of argv) { if (raw === '--apply') { args.apply = true; continue; } if (raw === '--no-webex') { args.deleteWebex = false; continue; } const m = /^--months=(\d+)$/.exec(raw); if (m) { args.months = parseInt(m[1]!, 10); continue; } const l = /^--limit=(\d+)$/.exec(raw); if (l) { args.limit = parseInt(l[1]!, 10); continue; } if (raw === '--help' || raw === '-h') { console.log( 'Usage: prune-stale-spaces [--months=N] [--limit=N] [--no-webex] [--apply]\n' + ' Default is dry-run. Add --apply to actually delete.', ); process.exit(0); } } if (!Number.isFinite(args.months) || args.months <= 0) { console.error(`Invalid --months=${args.months}`); process.exit(2); } return args; } function fmt(d: Date): string { return d.toISOString().slice(0, 10); } function ageInDays(d: Date, now: Date = new Date()): number { return Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000)); } function printTable(rows: StaleSpace[]): void { if (rows.length === 0) { console.log('No stale spaces found.'); return; } const cols = { title: Math.max(5, ...rows.map((r) => r.title.length)), smsPhone: Math.max(8, ...rows.map((r) => r.smsPhone.length)), }; const hdr = 'title'.padEnd(cols.title) + ' ' + 'phone'.padEnd(cols.smsPhone) + ' ' + 'last_activity ' + 'age(d) ' + 'msgs ' + 'roomId'; console.log(hdr); console.log('-'.repeat(hdr.length)); for (const r of rows) { console.log( r.title.padEnd(cols.title) + ' ' + r.smsPhone.padEnd(cols.smsPhone) + ' ' + fmt(r.lastActivity) + ' ' + String(ageInDays(r.lastActivity)).padStart(4) + ' ' + String(r.messageCount).padStart(4) + ' ' + r.roomId, ); } } /** * Remove a Webex room via the REST API. Returns: * 'deleted' — 200/204 from Webex * 'gone' — 404: room already gone, treat as success * 'failed' — anything else; caller decides whether to continue. */ async function deleteWebexRoom(roomId: string): Promise<'deleted' | 'gone' | 'failed'> { try { const res = await fetch(`https://webexapis.com/v1/rooms/${encodeURIComponent(roomId)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${env.WEBEX_TOKEN}` }, }); if (res.status === 204 || res.status === 200) return 'deleted'; if (res.status === 404) return 'gone'; logger.warn({ status: res.status, roomId }, 'Webex room delete non-2xx'); return 'failed'; } catch (err) { logger.warn({ err, roomId }, 'Webex room delete threw'); return 'failed'; } } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); console.log( `Scanning for spaces inactive > ${args.months} months ` + `(mode=${args.apply ? 'APPLY' : 'dry-run'}, webex=${args.deleteWebex ? 'yes' : 'no'}` + `${args.limit != null ? `, limit=${args.limit}` : ''})`, ); const all = await findStaleSpaces(prisma, args.months); const rows = args.limit != null ? all.slice(0, args.limit) : all; console.log(''); printTable(rows); console.log(''); console.log(`Found ${all.length} stale space(s)${args.limit != null && all.length > rows.length ? `, processing ${rows.length}` : ''}.`); if (!args.apply) { console.log('Dry run complete. Re-run with --apply to actually delete.'); return; } if (rows.length === 0) return; let webexDeleted = 0; let webexGone = 0; let webexFailed = 0; let dbDeleted = 0; let dbFailed = 0; for (const space of rows) { console.log(`\n→ ${space.title} (${space.roomId})`); if (args.deleteWebex) { const result = await deleteWebexRoom(space.roomId); if (result === 'deleted') { webexDeleted++; console.log(' webex: deleted'); } else if (result === 'gone') { webexGone++; console.log(' webex: already gone'); } else { webexFailed++; console.log(' webex: FAILED (continuing with DB delete)'); } } try { await prisma.space.delete({ where: { id: space.id } }); dbDeleted++; console.log(' db: deleted (cascade)'); } catch (err) { dbFailed++; logger.error({ err, roomId: space.roomId }, 'DB delete failed'); console.log(' db: FAILED'); } } console.log(''); console.log('Summary:'); if (args.deleteWebex) { console.log(` Webex: ${webexDeleted} deleted, ${webexGone} already gone, ${webexFailed} failed`); } console.log(` DB: ${dbDeleted} deleted, ${dbFailed} failed`); } let exitCode = 0; main() .catch((err) => { logger.error({ err }, 'prune-stale-spaces crashed'); exitCode = 1; }) .finally(async () => { try { await prisma.$disconnect(); } catch { /* ignore */ } process.exit(exitCode); });