From f18f024fc735ae130258df6f4df19b44443e6fc9 Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Mon, 6 Jul 2026 12:19:17 -0400 Subject: [PATCH] Add stale-space tooling: Webex report + DB pruner 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 --- package.json | 2 + scripts/prune-stale-spaces.ts | 187 +++++++++++++++++++++++++++++++++ scripts/webex-rooms-report.ts | 171 ++++++++++++++++++++++++++++++ src/lib/staleSpaces.ts | 91 ++++++++++++++++ tests/unit/staleSpaces.test.ts | 39 +++++++ 5 files changed, 490 insertions(+) create mode 100644 scripts/prune-stale-spaces.ts create mode 100644 scripts/webex-rooms-report.ts create mode 100644 src/lib/staleSpaces.ts create mode 100644 tests/unit/staleSpaces.test.ts diff --git a/package.json b/package.json index 078dd8a..e88965b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", + "prune-spaces": "node --require ./scripts/navigator-patch.cjs scripts/prune-stale-spaces.ts", + "webex-rooms-report": "node --require ./scripts/navigator-patch.cjs scripts/webex-rooms-report.ts", "migrate-old": "node legacy/migrate-old-data.ts" }, "engines": { diff --git a/scripts/prune-stale-spaces.ts b/scripts/prune-stale-spaces.ts new file mode 100644 index 0000000..90f4a20 --- /dev/null +++ b/scripts/prune-stale-spaces.ts @@ -0,0 +1,187 @@ +#!/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); + }); diff --git a/scripts/webex-rooms-report.ts b/scripts/webex-rooms-report.ts new file mode 100644 index 0000000..1204dc5 --- /dev/null +++ b/scripts/webex-rooms-report.ts @@ -0,0 +1,171 @@ +#!/usr/bin/env node +// scripts/webex-rooms-report.ts +// +// READ-ONLY diagnostic. Lists every Webex room the bot token can see, buckets +// them by last-activity age, and cross-references against legacy/spaces.json +// so we can tell sendi-managed rooms apart from any others. +// +// Nothing is written. No DELETE calls are issued. Safe to run at any time. +// +// Usage: +// npm run webex-rooms-report # full report +// npm run webex-rooms-report -- --months=24 # highlight rooms stale > N months +// npm run webex-rooms-report -- --stale-only # print details only for stale rooms + +import fs from 'fs'; +import path from 'path'; +import env from '../src/config/env.ts'; + +interface WebexRoom { + id: string; + title: string; + type: 'group' | 'direct'; + isLocked?: boolean; + lastActivity?: string; + created?: string; +} + +interface Args { + months: number; + staleOnly: boolean; + sampleSize: number; +} + +function parseArgs(argv: string[]): Args { + const a: Args = { months: 24, staleOnly: false, sampleSize: 20 }; + for (const raw of argv) { + if (raw === '--stale-only') { a.staleOnly = true; continue; } + const m = /^--months=(\d+)$/.exec(raw); + if (m) { a.months = parseInt(m[1]!, 10); continue; } + const s = /^--sample=(\d+)$/.exec(raw); + if (s) { a.sampleSize = parseInt(s[1]!, 10); continue; } + } + return a; +} + +function parseLinkHeader(link: string | null): string | undefined { + if (!link) return undefined; + // Format: ; rel="next" + const match = /<([^>]+)>;\s*rel="next"/i.exec(link); + return match?.[1]; +} + +async function fetchAllRooms(): Promise { + const rooms: WebexRoom[] = []; + let next: string | undefined = `https://webexapis.com/v1/rooms?max=1000&sortBy=lastactivity`; + let pageCount = 0; + while (next) { + pageCount++; + process.stdout.write(` fetching page ${pageCount}...\r`); + const res = await fetch(next, { + headers: { Authorization: `Bearer ${env.WEBEX_TOKEN}` }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Webex list rooms failed (${res.status}): ${body.slice(0, 300)}`); + } + const json = (await res.json()) as { items: WebexRoom[] }; + rooms.push(...(json.items ?? [])); + next = parseLinkHeader(res.headers.get('link')); + } + process.stdout.write('\n'); + return rooms; +} + +function loadLegacySpaceIds(): Set { + const jsonPath = path.resolve(process.cwd(), 'legacy', 'spaces.json'); + if (!fs.existsSync(jsonPath)) return new Set(); + const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as Record; + return new Set(Object.keys(data)); +} + +function ageInDays(d: Date, now: Date = new Date()): number { + return Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000)); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + + console.log('Fetching all Webex rooms the bot can see (paginated)...'); + const rooms = await fetchAllRooms(); + console.log(` → ${rooms.length} rooms total\n`); + + const legacyIds = loadLegacySpaceIds(); + console.log(`Legacy spaces.json known IDs: ${legacyIds.size}\n`); + + // ---- Bucket rooms by lastActivity age ---- + const now = new Date(); + const buckets: Record = { + 'no lastActivity': [], + '≤ 7 days': [], + '≤ 30 days': [], + '≤ 3 months': [], + '≤ 6 months': [], + '≤ 12 months': [], + '≤ 18 months': [], + '≤ 24 months': [], + '> 24 months': [], + }; + for (const r of rooms) { + if (!r.lastActivity) { buckets['no lastActivity']!.push(r); continue; } + const days = ageInDays(new Date(r.lastActivity), now); + if (days <= 7) buckets['≤ 7 days']!.push(r); + else if (days <= 30) buckets['≤ 30 days']!.push(r); + else if (days <= 90) buckets['≤ 3 months']!.push(r); + else if (days <= 180) buckets['≤ 6 months']!.push(r); + else if (days <= 365) buckets['≤ 12 months']!.push(r); + else if (days <= 547) buckets['≤ 18 months']!.push(r); + else if (days <= 730) buckets['≤ 24 months']!.push(r); + else buckets['> 24 months']!.push(r); + } + + console.log('Activity buckets (all rooms bot can see):'); + for (const [name, arr] of Object.entries(buckets)) { + const inLegacy = arr.filter((r) => legacyIds.has(r.id)).length; + console.log(` ${name.padEnd(18)} count=${String(arr.length).padStart(5)} in legacy=${inLegacy}`); + } + + // ---- Rooms stale beyond the threshold ---- + const cutoffMs = now.getTime() - args.months * 30.4375 * 24 * 60 * 60 * 1000; + const stale = rooms.filter((r) => { + if (!r.lastActivity) return true; + return new Date(r.lastActivity).getTime() < cutoffMs; + }); + const staleInLegacy = stale.filter((r) => legacyIds.has(r.id)); + const staleNotInLegacy = stale.filter((r) => !legacyIds.has(r.id)); + + console.log(`\nStale > ${args.months} months: ${stale.length}`); + console.log(` - in legacy spaces.json: ${staleInLegacy.length}`); + console.log(` - NOT in legacy spaces.json: ${staleNotInLegacy.length}`); + + // ---- Sanity: how many legacy rooms did we NOT see? (bot no longer a member) ---- + const seenIds = new Set(rooms.map((r) => r.id)); + let legacyMissing = 0; + for (const id of legacyIds) if (!seenIds.has(id)) legacyMissing++; + console.log(`\nLegacy spaces already gone from Webex (or bot removed): ${legacyMissing}`); + + // ---- Optional sample listing ---- + if (args.staleOnly || args.sampleSize > 0) { + const sampleList = args.staleOnly ? staleInLegacy : rooms.slice(0, args.sampleSize); + const label = args.staleOnly + ? `\nSample of stale + legacy rooms (up to ${args.sampleSize}):` + : `\nSample of first ${args.sampleSize} rooms (all):`; + console.log(label); + const header = 'lastActivity'.padEnd(22) + 'age(d) '.padStart(8) + ' ' + 'legacy? '.padEnd(9) + 'title'; + console.log(header); + console.log('-'.repeat(header.length)); + for (const r of sampleList.slice(0, args.sampleSize)) { + const la = r.lastActivity ?? '(none)'; + const days = r.lastActivity ? String(ageInDays(new Date(r.lastActivity), now)) : ' n/a'; + const isLegacy = legacyIds.has(r.id) ? 'yes' : 'no'; + console.log(`${la.padEnd(22)}${days.padStart(6)} ${isLegacy.padEnd(9)}${r.title}`); + } + } + + console.log('\nDone. No changes made.'); +} + +main().catch((err) => { + console.error(`\nReport failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/src/lib/staleSpaces.ts b/src/lib/staleSpaces.ts new file mode 100644 index 0000000..eeb780d --- /dev/null +++ b/src/lib/staleSpaces.ts @@ -0,0 +1,91 @@ +// src/lib/staleSpaces.ts +// +// Identify Spaces that haven't seen SMS traffic for a long time. +// +// "Used" = the most recent of (Space.createdAt, MAX(Message.createdAt for that room)). +// A brand-new space with no messages therefore uses its own createdAt — this keeps +// us from pruning rooms that were just created and haven't received their first +// message yet. + +import type { PrismaClient } from '@prisma/client'; + +export interface StaleSpace { + id: string; + roomId: string; + smsPhone: string; + smsName: string; + title: string; + createdAt: Date; + lastActivity: Date; + messageCount: number; +} + +/** + * Return a Date that is `months` calendar months before `now`. Month arithmetic + * follows JS's `setMonth` semantics (Feb 30 → Mar 2 etc.), which is fine for + * multi-month thresholds where a couple of days of drift is not meaningful. + */ +export function monthsAgo(months: number, now: Date = new Date()): Date { + const d = new Date(now.getTime()); + d.setMonth(d.getMonth() - months); + return d; +} + +/** Pick the more recent of two dates; the message wins if present and newer. */ +export function computeLastActivity(spaceCreatedAt: Date, lastMessageAt: Date | null | undefined): Date { + if (!lastMessageAt) return spaceCreatedAt; + return lastMessageAt > spaceCreatedAt ? lastMessageAt : spaceCreatedAt; +} + +/** + * Find Spaces whose most recent activity is older than `months` calendar months. + * Returns them sorted oldest-first for stable output. + */ +export async function findStaleSpaces( + prisma: PrismaClient, + months: number, + now: Date = new Date(), +): Promise { + if (!Number.isFinite(months) || months <= 0) { + throw new Error(`months must be a positive number, got: ${months}`); + } + const cutoff = monthsAgo(months, now); + + const spaces = await prisma.space.findMany({ + select: { + id: true, + roomId: true, + smsPhone: true, + smsName: true, + title: true, + createdAt: true, + _count: { select: { messages: true } }, + messages: { + select: { createdAt: true }, + orderBy: { createdAt: 'desc' }, + take: 1, + }, + }, + }); + + const stale: StaleSpace[] = []; + for (const s of spaces) { + const lastMessageAt = s.messages[0]?.createdAt ?? null; + const lastActivity = computeLastActivity(s.createdAt, lastMessageAt); + if (lastActivity < cutoff) { + stale.push({ + id: s.id, + roomId: s.roomId, + smsPhone: s.smsPhone, + smsName: s.smsName, + title: s.title, + createdAt: s.createdAt, + lastActivity, + messageCount: s._count.messages, + }); + } + } + + stale.sort((a, b) => a.lastActivity.getTime() - b.lastActivity.getTime()); + return stale; +} diff --git a/tests/unit/staleSpaces.test.ts b/tests/unit/staleSpaces.test.ts new file mode 100644 index 0000000..468426e --- /dev/null +++ b/tests/unit/staleSpaces.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { monthsAgo, computeLastActivity } from '../../src/lib/staleSpaces.ts'; + +describe('monthsAgo', () => { + it('subtracts calendar months', () => { + const now = new Date('2026-07-06T12:00:00Z'); + const back = monthsAgo(24, now); + expect(back.getUTCFullYear()).toBe(2024); + expect(back.getUTCMonth()).toBe(6); // July (0-indexed) + }); + + it('handles month-boundary edge without throwing (Mar 31 → Feb 28/Mar 3)', () => { + const now = new Date('2026-03-31T00:00:00Z'); + expect(() => monthsAgo(1, now)).not.toThrow(); + }); + + it('is idempotent for months=0', () => { + const now = new Date('2026-01-15T00:00:00Z'); + expect(monthsAgo(0, now).toISOString()).toBe(now.toISOString()); + }); +}); + +describe('computeLastActivity', () => { + const older = new Date('2024-01-01T00:00:00Z'); + const newer = new Date('2025-06-01T00:00:00Z'); + + it('returns spaceCreatedAt when there are no messages', () => { + expect(computeLastActivity(older, null).toISOString()).toBe(older.toISOString()); + expect(computeLastActivity(older, undefined).toISOString()).toBe(older.toISOString()); + }); + + it('returns the message date when it is newer', () => { + expect(computeLastActivity(older, newer).toISOString()).toBe(newer.toISOString()); + }); + + it('returns spaceCreatedAt when the message is older (defensive)', () => { + expect(computeLastActivity(newer, older).toISOString()).toBe(newer.toISOString()); + }); +});