Two operational scripts and their shared library. Both are read-only by default; the pruner requires an explicit --apply flag to touch anything. scripts/webex-rooms-report.ts - Fetches every room the current WEBEX_TOKEN can see (paginated /v1/rooms) - Buckets rooms by lastActivity age (7d, 30d, 3m, 6m, 12m, 18m, 24m, >24m) - Cross-references against legacy/spaces.json to distinguish sendi-managed rooms from others - Prints a summary table plus a sample listing; makes zero writes scripts/prune-stale-spaces.ts - Finds Space rows whose most recent Message.createdAt (falling back to Space.createdAt) is older than --months=N (default 24) - Dry-run by default; --apply performs deletions - Deletes the Webex room via REST (Bearer WEBEX_TOKEN) then removes the Space row (cascade removes Message, BulkJob, Survey, PendingMessage) - --no-webex skips the Webex delete for DB-only pruning - --limit=N caps how many spaces get processed per run src/lib/staleSpaces.ts - Pure `monthsAgo` and `computeLastActivity` helpers used by the pruner - `findStaleSpaces(prisma, months)` runs the query and sorts oldest-first package.json scripts: `webex-rooms-report`, `prune-spaces` Tests: 6 new unit tests for the pure helpers (51 total, all green) Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
6.5 KiB
JavaScript
187 lines
6.5 KiB
JavaScript
#!/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<void> {
|
|
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);
|
|
});
|