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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user