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>
171 lines
6.7 KiB
JavaScript
171 lines
6.7 KiB
JavaScript
#!/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: <https://webexapis.com/v1/rooms?...>; rel="next"
|
|
const match = /<([^>]+)>;\s*rel="next"/i.exec(link);
|
|
return match?.[1];
|
|
}
|
|
|
|
async function fetchAllRooms(): Promise<WebexRoom[]> {
|
|
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<string> {
|
|
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<string, unknown>;
|
|
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<void> {
|
|
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<string, WebexRoom[]> = {
|
|
'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);
|
|
});
|