feat(stats): voice-time and message leaderboards per space
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.
This commit is contained in:
2026-08-31 13:29:59 -03:00
parent bbb190cbda
commit 1830051732
13 changed files with 4642 additions and 1 deletions
@@ -0,0 +1,65 @@
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);
}
}