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,14 @@
|
||||
CREATE TABLE `voice_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`space_id` text,
|
||||
`channel_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`started_at` integer NOT NULL,
|
||||
`ended_at` integer,
|
||||
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_voice_sessions_space_started` ON `voice_sessions` (`space_id`,`started_at`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_voice_sessions_user` ON `voice_sessions` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_voice_sessions_ended` ON `voice_sessions` (`ended_at`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1788193162813,
|
||||
"tag": "0013_fancy_betty_brant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1788193659704,
|
||||
"tag": "0014_mean_killer_shrike",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -17,6 +17,8 @@ import { dmRoutes } from './routes/dm.js';
|
||||
import { livekitRoutes } from './routes/livekit.js';
|
||||
import { spotifyRoutes } from './routes/spotify.js';
|
||||
import { auditRoutes } from './routes/audit.js';
|
||||
import { statsRoutes } from './routes/stats.js';
|
||||
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
import { utilRoutes } from './routes/utils.js';
|
||||
@@ -111,6 +113,10 @@ async function main(): Promise<void> {
|
||||
// Initialize database
|
||||
getDb();
|
||||
|
||||
// A restart leaves voice sessions open with no way to know when they really
|
||||
// ended. Sweep them before anything can read the statistics.
|
||||
closeOrphanedVoiceSessions();
|
||||
|
||||
// Reset orphaned `users.status` rows for locally-homed users. The previous
|
||||
// process's in-memory disconnect timers are gone, so any non-offline row
|
||||
// is stale by construction. Replicated (federated) rows are skipped — their
|
||||
@@ -130,6 +136,7 @@ async function main(): Promise<void> {
|
||||
await app.register(livekitRoutes);
|
||||
await app.register(spotifyRoutes);
|
||||
await app.register(auditRoutes);
|
||||
await app.register(statsRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(utilRoutes);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, eq, gte, sql, isNotNull, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { isMember } from '../utils/permissions.js';
|
||||
|
||||
/** Windows the UI offers. Anything else is clamped into this range. */
|
||||
const DEFAULT_DAYS = 30;
|
||||
const MAX_DAYS = 365;
|
||||
|
||||
interface Leader {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
avatar: string | null;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export async function statsRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Params: { id: string }; Querystring: { days?: string } }>(
|
||||
'/api/spaces/:id/stats',
|
||||
{ preHandler: authenticate },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
// Any member may look: these are the group's own numbers, not moderation
|
||||
// data. The audit log, which names who did what, stays admin-only.
|
||||
if (!isMember(id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||
}
|
||||
|
||||
const days = Math.min(Math.max(Number(request.query.days) || DEFAULT_DAYS, 1), MAX_DAYS);
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const db = getDb();
|
||||
|
||||
// Voice time. Only closed sessions count: an open one has no duration
|
||||
// yet, and counting "now - startedAt" would make the numbers move every
|
||||
// time the page is refreshed.
|
||||
const voiceRows = db.select({
|
||||
userId: schema.voiceSessions.userId,
|
||||
username: schema.users.username,
|
||||
displayName: schema.users.displayName,
|
||||
avatar: schema.users.avatar,
|
||||
value: sql<number>`sum(${schema.voiceSessions.endedAt} - ${schema.voiceSessions.startedAt})`,
|
||||
})
|
||||
.from(schema.voiceSessions)
|
||||
.innerJoin(schema.users, eq(schema.voiceSessions.userId, schema.users.id))
|
||||
.where(and(
|
||||
eq(schema.voiceSessions.spaceId, id),
|
||||
gte(schema.voiceSessions.startedAt, since),
|
||||
isNotNull(schema.voiceSessions.endedAt),
|
||||
))
|
||||
.groupBy(schema.voiceSessions.userId)
|
||||
.all() as Leader[];
|
||||
|
||||
// Messages. Scoped through the space's channels — the messages table has
|
||||
// no space column.
|
||||
const channelIds = db.select({ id: schema.channels.id })
|
||||
.from(schema.channels)
|
||||
.where(eq(schema.channels.spaceId, id))
|
||||
.all()
|
||||
.map((c) => c.id);
|
||||
|
||||
const messageRows = channelIds.length === 0 ? [] : db.select({
|
||||
userId: schema.messages.userId,
|
||||
username: schema.users.username,
|
||||
displayName: schema.users.displayName,
|
||||
avatar: schema.users.avatar,
|
||||
value: sql<number>`count(*)`,
|
||||
})
|
||||
.from(schema.messages)
|
||||
.innerJoin(schema.users, eq(schema.messages.userId, schema.users.id))
|
||||
.where(and(
|
||||
inArray(schema.messages.channelId, channelIds),
|
||||
gte(schema.messages.createdAt, since),
|
||||
))
|
||||
.groupBy(schema.messages.userId)
|
||||
.all() as Leader[];
|
||||
|
||||
const byValueDesc = (a: Leader, b: Leader) => b.value - a.value;
|
||||
|
||||
return reply.code(200).send({
|
||||
days,
|
||||
since,
|
||||
voice: voiceRows.sort(byValueDesc),
|
||||
messages: messageRows.sort(byValueDesc),
|
||||
totals: {
|
||||
voiceMs: voiceRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
|
||||
messages: messageRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { verifyJwt } from '../utils/auth.js';
|
||||
import { openVoiceSession, closeVoiceSession } from '../utils/voiceSessions.js';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
|
||||
import { handleClientEvent } from './events.js';
|
||||
@@ -714,6 +715,16 @@ class ConnectionManager {
|
||||
|
||||
room.participants.add(userId);
|
||||
this.userToRoom.set(userId, roomId);
|
||||
|
||||
// Recorded here rather than at the seven call sites that lead into voice:
|
||||
// every path — join, move, DM call, reconnect — funnels through this
|
||||
// method, so hooking it cannot miss one.
|
||||
openVoiceSession({
|
||||
spaceId: room.roomType === 'space' ? (room.metadata as SpaceRoomMeta).spaceId : null,
|
||||
channelId: roomId,
|
||||
userId,
|
||||
});
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
@@ -746,6 +757,8 @@ class ConnectionManager {
|
||||
const room = this.leaveRoom(roomId, userId);
|
||||
if (!room) return null;
|
||||
|
||||
closeVoiceSession(userId);
|
||||
|
||||
return { roomId, room };
|
||||
}
|
||||
|
||||
@@ -757,6 +770,9 @@ class ConnectionManager {
|
||||
const displaced: string[] = [];
|
||||
for (const userId of room.participants) {
|
||||
this.userToRoom.delete(userId);
|
||||
// Destroying a room bypasses leaveCurrentRoom, so these sessions would
|
||||
// otherwise stay open until the next restart swept them away.
|
||||
closeVoiceSession(userId);
|
||||
displaced.push(userId);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user