From 8b632d523443de3686bc26f195ef30e6a8b43794 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 8 Nov 2025 19:22:09 -0700 Subject: [PATCH] feat(db): track all room activity types for expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Room expiry now tracks ALL activity, not just creation timestamps: - Voting and unvoting - Resetting votes - Editing items - Deleting items - Setting room title - Breaking ties Added last_activity column to rooms table (schema v3→v4). All activity operations now update this timestamp. deleteInactiveRooms simplified to use last_activity directly instead of complex joins. Assisted-by: Claude Sonnet 4.5 via Crush --- db.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++----------- server.ts | 2 ++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/db.ts b/db.ts index 7fdb9e4b70e9a6223eee70fcb681716f3e952417..ac2b503741f95d1342e25956c1a20d4181466fe7 100644 --- a/db.ts +++ b/db.ts @@ -169,13 +169,33 @@ if (currentVersion < 1) { console.error('❌ Migration failed:', err); throw err; } +} else if (currentVersion === 3) { + console.log('🔄 Migrating database schema from version 3 to 4...'); + + try { + db.exec('BEGIN'); + + // Add last_activity column to rooms table, default to created_at + db.exec('ALTER TABLE rooms ADD COLUMN last_activity INTEGER'); + db.exec('UPDATE rooms SET last_activity = created_at'); + + db.exec('PRAGMA user_version = 4'); + db.exec('COMMIT'); + + console.log('✅ Migration from v3 to v4 complete'); + } catch (err) { + db.exec('ROLLBACK'); + console.error('❌ Migration failed:', err); + throw err; + } } else { // Plant the schema if soil is fresh (for new databases) db.exec(` CREATE TABLE IF NOT EXISTS rooms ( code TEXT PRIMARY KEY NOT NULL, created_at INTEGER DEFAULT (unixepoch()), - title TEXT + title TEXT, + last_activity INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS items ( @@ -247,6 +267,7 @@ export function addItems(roomCode: string, items: Array<{id: string, text: strin for (const item of items) { stmt.run(item.id, roomCode, item.text); } + db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode); db.exec('COMMIT'); } catch (err) { db.exec('ROLLBACK'); @@ -255,27 +276,43 @@ export function addItems(roomCode: string, items: Array<{id: string, text: strin } export function upsertVote(itemId: string, userId: string, voteType: string) { + const roomCode = db.prepare('SELECT room_code FROM items WHERE id = ?').get(itemId) as { room_code: string } | undefined; db.prepare(` INSERT INTO votes (item_id, user_id, vote_type) VALUES (?, ?, ?) ON CONFLICT (item_id, user_id) DO UPDATE SET vote_type = excluded.vote_type `).run(itemId, userId, voteType); + if (roomCode) { + db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode.room_code); + } } export function deleteVote(itemId: string, userId: string) { + const roomCode = db.prepare('SELECT room_code FROM items WHERE id = ?').get(itemId) as { room_code: string } | undefined; db.prepare('DELETE FROM votes WHERE item_id = ? AND user_id = ?').run(itemId, userId); + if (roomCode) { + db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode.room_code); + } } export function updateItemText(itemId: string, text: string) { + const roomCode = db.prepare('SELECT room_code FROM items WHERE id = ?').get(itemId) as { room_code: string } | undefined; db.prepare('UPDATE items SET text = ? WHERE id = ?').run(text, itemId); + if (roomCode) { + db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode.room_code); + } } export function deleteItem(itemId: string) { + const roomCode = db.prepare('SELECT room_code FROM items WHERE id = ?').get(itemId) as { room_code: string } | undefined; // Delete votes first (foreign key constraint) db.prepare('DELETE FROM votes WHERE item_id = ?').run(itemId); // Delete the item db.prepare('DELETE FROM items WHERE id = ?').run(itemId); + if (roomCode) { + db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode.room_code); + } } export function resetVotes(roomCode: string) { @@ -285,10 +322,15 @@ export function resetVotes(roomCode: string) { SELECT id FROM items WHERE room_code = ? ) `).run(roomCode); + db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode); } export function updateRoomTitle(roomCode: string, title: string | null) { - db.prepare('UPDATE rooms SET title = ? WHERE code = ?').run(title, roomCode); + db.prepare('UPDATE rooms SET title = ?, last_activity = unixepoch() WHERE code = ?').run(title, roomCode); +} + +export function touchRoomActivity(roomCode: string) { + db.prepare('UPDATE rooms SET last_activity = unixepoch() WHERE code = ?').run(roomCode); } export function getItemsWithVotes(roomCode: string) { @@ -305,17 +347,9 @@ export function getItemsWithVotes(roomCode: string) { export function deleteInactiveRooms(daysInactive: number = 30): number { const cutoffTimestamp = Math.floor(Date.now() / 1000) - (daysInactive * 24 * 60 * 60); - // Find rooms where the most recent activity (item or vote) is older than the cutoff const result = db.prepare(` DELETE FROM rooms - WHERE code IN ( - SELECT r.code - FROM rooms r - LEFT JOIN items i ON r.code = i.room_code - LEFT JOIN votes v ON i.id = v.item_id - GROUP BY r.code - HAVING COALESCE(MAX(i.created_at), MAX(v.created_at), r.created_at) < ? - ) + WHERE last_activity < ? `).run(cutoffTimestamp); return result.changes || 0; diff --git a/server.ts b/server.ts index 0cd4d8a5f0553d70c07f2c566ea7b479b5cfa7af..0613e93e704ac1e68e46a0ab872eba3af74de0ec 100644 --- a/server.ts +++ b/server.ts @@ -343,6 +343,8 @@ function handleWebSocket(ws: WebSocket, roomCode: string, userId: string) { const randomIndex = crypto.getRandomValues(new Uint32Array(1))[0] % tiedItems.length; const chosen = tiedItems[randomIndex]; + db.touchRoomActivity(roomCode); + broadcast(roomCode, { type: 'tie_broken', itemId: chosen.id,