feat: add realtime Discord leaderboards
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user