import { and, eq, isNull, sql } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from './snowflake.js'; /** * Opens a session when someone joins a voice room. * * Never throws: statistics must not be able to break a call. Closes any * dangling session for the same user first — the one-room-per-user invariant * means a second open row would be a bookkeeping error, not two real calls. */ export function openVoiceSession(input: { spaceId: string | null; channelId: string; userId: string; }): void { try { const db = getDb(); closeVoiceSession(input.userId); db.insert(schema.voiceSessions).values({ id: generateSnowflake(), spaceId: input.spaceId, channelId: input.channelId, userId: input.userId, startedAt: Date.now(), endedAt: null, }).run(); } catch (err) { console.warn('[voice-sessions] failed to open session', err); } } /** Closes the user's open session, if any. Never throws. */ export function closeVoiceSession(userId: string): void { try { getDb().update(schema.voiceSessions) .set({ endedAt: Date.now() }) .where(and(eq(schema.voiceSessions.userId, userId), isNull(schema.voiceSessions.endedAt))) .run(); } catch (err) { console.warn('[voice-sessions] failed to close session', err); } } /** * Closes sessions left open by a crash or restart. * * Their real end time is unknowable. Ending them at `startedAt` — a zero-length * session — discards that time rather than inventing it: crediting the gap * would silently hand someone hours they never spent, and the numbers are the * entire point of keeping this table. */ export function closeOrphanedVoiceSessions(): void { try { const result = getDb().update(schema.voiceSessions) .set({ endedAt: sql`${schema.voiceSessions.startedAt}` }) .where(isNull(schema.voiceSessions.endedAt)) .run(); if (result.changes > 0) { console.log(`[voice-sessions] closed ${result.changes} session(s) orphaned by a restart`); } } catch (err) { console.warn('[voice-sessions] failed to close orphans', err); } }