feat: add realtime Discord leaderboards

This commit is contained in:
2026-07-25 00:06:52 +07:00 Unverified
parent 7a9e67e283
commit bb6740fcee
39 changed files with 1917 additions and 119 deletions
+44
View File
@@ -0,0 +1,44 @@
# ADR 0002: Real-time leaderboard read model
- Status: Accepted
- Date: 2026-07-24
## Context
The Discord bot is the authoritative writer for member XP and cumulative voice time.
The website needs public, paginated leaderboards that update while open without
coupling the form database, migrations, or website write paths to the bot database.
Voice history also contains channels that may no longer exist in the Discord guild.
## Decision
1. The website uses a separate Drizzle/Postgres.js module configured only through
`LEADERBOARD_DATABASE_URL`. Its client starts read-only sessions, uses a small
connection pool and short timeouts, and is not exported. The normal `DATABASE_URL`,
schema barrel, Drizzle configuration, and migrations remain unchanged.
2. Leaderboard database reads are uncached. A Server Component reads the current
values on navigation and after `router.refresh()`. Discord voice and Stage channel
metadata may remain in memory for five minutes.
3. The XP board includes stored level 1+ users and ranks cumulative XP descending.
The voice board sums time across current Discord voice and Stage channels, or one
selected current channel. Historical/deleted channels are excluded.
4. Both boards use shared competition ranks: equal values share a rank and the next
rank skips the tied positions. Equal-value rows use name and Discord ID for stable
display ordering.
5. The existing Redis-backed SSE route gains one public `leaderboards` topic. The bot
publishes minimal XP or channel-scoped VC events only after its database
transaction commits. The browser validates events, filters them to the visible
board, debounces refreshes, and refreshes again after reconnecting.
6. Redis and SSE are notification paths, not the source of truth. A transport failure
does not make the page unusable; navigation still performs a fresh database read.
## Consequences
- The bot remains the only process allowed to mutate leaderboard tables.
- A website database credential should also be restricted to read-only access at the
PostgreSQL role level; the client session setting adds defense in depth.
- Voice totals intentionally change when channels are created or removed because the
default scope is the guild’s current voice surface.
- Open pages update quickly without polling, while reconnection and normal navigation
recover missed notifications.
- Discord channel lookup and leaderboard database failures can be reported separately.
+29
View File
@@ -34,3 +34,32 @@ non-empty text, even when the overall question is optional.
A form that no longer accepts new submissions or updates. A person with an existing
submission may still read or delete it.
## XP leaderboard
A public ranking of stored level 1+ members by cumulative XP.
## VC leaderboard
A public ranking of cumulative voice time across the guild’s current voice and Stage
channels, or one selected current channel.
## Current channel
A Discord voice or Stage channel returned by the guild channel API at the time its
five-minute metadata cache is filled. Deleted and historical channels are not current.
## Shared rank
A competition ranking in which equal values receive the same position and the next
position skips the tied places, such as 1, 2, 2, 4.
## Leaderboard read model
The website’s uncached, read-only view of the bot-owned `user_levels` and `voice_time`
tables, combined with current Discord channel metadata.
## Leaderboard SSE event
A minimal Redis notification on `erika:sse:leaderboards` that tells an open XP or VC
page to refresh its Server Component data after a bot transaction commits.
+61
View File
@@ -0,0 +1,61 @@
# Leaderboard bot integration
The Discord bot owns all writes to `user_levels` and `voice_time`. The website only
subscribes to notifications and then reads the committed database state.
## Redis channel
Publish leaderboard notifications to exactly:
```text
erika:sse:leaderboards
```
The bot and website use the same Redis deployment. Do not add Redis credentials to
source code or event payloads.
## Event envelopes
XP changes use this exact JSON envelope:
```json
{"event":"xp","data":{}}
```
Voice-time changes use the affected Discord channel ID:
```json
{"event":"vc","data":{"channelId":"123456789012345678"}}
```
No additional envelope or data fields are accepted. `channelId` is a Discord
snowflake represented as a decimal string.
## Publish ordering
1. Start and complete the bot’s database transaction.
2. Confirm that the transaction committed successfully.
3. Publish the corresponding Redis event or events.
Never publish before commit, from a rollback path, or from a `finally` block. The
notification tells an open website page to read the database again, so publishing
before commit can produce a stale refresh.
## Event cardinality
- Publish one XP event when one transaction changes XP data.
- Publish one VC event per affected channel when one transaction changes voice time.
- When one transaction changes both XP and voice time, publish one XP event and one VC
event for each affected channel.
- For a bulk transaction, deduplicate affected scopes and channel IDs. Publish per
affected scope/channel, never per user.
Example: a bulk transaction changes XP for 200 users and voice time in channels `111`
and `222`. After commit, publish three events: one XP event, one VC event for `111`,
and one VC event for `222`.
## Error handling
Publishing is best-effort. Log Redis failures with enough context to identify the
event scope, but do not roll back or report a successfully committed database
transaction as failed. Open pages recover on SSE reconnection or the next navigation.