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 <cursoragent@cursor.com>
This commit is contained in:
parent
28451daca1
commit
f18f024fc7
5 changed files with 490 additions and 0 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
187
scripts/prune-stale-spaces.ts
Normal file
187
scripts/prune-stale-spaces.ts
Normal file
|
|
@ -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<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);
|
||||
});
|
||||
171
scripts/webex-rooms-report.ts
Normal file
171
scripts/webex-rooms-report.ts
Normal file
|
|
@ -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: <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);
|
||||
});
|
||||
91
src/lib/staleSpaces.ts
Normal file
91
src/lib/staleSpaces.ts
Normal file
|
|
@ -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<StaleSpace[]> {
|
||||
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;
|
||||
}
|
||||
39
tests/unit/staleSpaces.test.ts
Normal file
39
tests/unit/staleSpaces.test.ts
Normal file
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue