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:
@@ -610,3 +610,26 @@ export const auditEvents = sqliteTable('audit_events', {
|
||||
spaceIdx: index('idx_audit_events_space_created').on(table.spaceId, table.createdAt),
|
||||
actorIdx: index('idx_audit_events_actor').on(table.actorId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* One row per stay in a voice room, closed when the user leaves.
|
||||
*
|
||||
* Separate from `auditEvents` on purpose: that table records points in time,
|
||||
* while a call is an interval. Storing joins and leaves as separate point
|
||||
* events would make every statistics query pair rows by hand and guess at
|
||||
* joins whose leave never arrived (a crash, a restart).
|
||||
*
|
||||
* `endedAt` null means still connected. `spaceId` is null for DM calls.
|
||||
*/
|
||||
export const voiceSessions = sqliteTable('voice_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
channelId: text('channel_id').notNull(),
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
startedAt: integer('started_at').notNull(),
|
||||
endedAt: integer('ended_at'),
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_voice_sessions_space_started').on(table.spaceId, table.startedAt),
|
||||
userIdx: index('idx_voice_sessions_user').on(table.userId),
|
||||
openIdx: index('idx_voice_sessions_ended').on(table.endedAt),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user