OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Voice stays get their own table rather than joining the audit log: that table records points in time, a call is an interval, and pairing join/leave point events would leave every query guessing at joins whose leave never arrived. Sessions are opened and closed inside joinRoom/leaveCurrentRoom rather than at the seven call sites that reach them, so no path can be missed, and destroyRoom closes them too — it bypasses leaveCurrentRoom and would otherwise leak open rows. A restart leaves sessions open with an unknowable end time. They are closed at startedAt, discarding that time rather than inventing it: crediting the gap would hand someone hours they never spent, and the numbers are the point. Mirrors the existing users.status sweep on boot. Only closed sessions count, so a figure does not move on every refresh. Bars scale to the leader, not the total — with five people every share of a total looks identical. Statistics are readable by any member, since they are the group's own numbers; the audit log, which names who did what, stays on MANAGE_SPACE.
66 lines
2.1 KiB
TypeScript
66 lines
2.1 KiB
TypeScript
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);
|
|
}
|
|
}
|