Files
website/server/src/model/events/eventRunParticipants.db.js
wtclaude 1667e636bd feat(events): the public calendar, event pages and participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events,
/public/events/:slug and /public/events/series/:slug, plus
GET /player/events/history, and the four screens over them.

Four org-lead decisions taken up front: split Phase 14 into 14a (website)
and 14b (the app); add a `listed` flag rather than letting `state` mean both
schedulable and announced; put the `events` capability string in the version
block rather than publishing core as a pseudo-module; and drop "venue" from
the spec rather than adding a field nothing had ever built.

`listed` is announcement, not permission. Publishing is what makes a
definition runnable, so without a separate flag a surprise event would have
to be advertised in order to be allowed to happen. It is a column, a switch
in Phase 13's editor, and three SQL predicates -- never a filter applied
after a read, which works exactly as well until the first caller that forgets.

The public shapes are a projection, and the projection is the security
boundary: nothing is spread, so a column added to event_runs next year does
not ride out through it. The spec, health, cleanup, claims, errors and
member_key are all absent by construction.

The six public event triggers gained `eventUrl` (version 1 -> 2), carrying
?run= because the page lives at the definition's slug while every trigger is
about one occurrence. notify.event-started gained the button, at seedVersion 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-08 06:18:38 -05:00

166 lines
7.9 KiB
JavaScript

// ── event_run_participants — SQL only ──────────────────────────────────────
//
// EVENTS.md §D and §J, and Phase 10 of EVENTS_PLAN.md. The eleventh and last of
// §D's core tables: who took part in a run, and how well.
//
// **Core writes this table and never sources it.** A `member_key` is
// module-opaque, exactly as a resource's `ref` is — core cannot map a character
// name onto a user row and must not try, because that mapping is one game's
// (`shard_links`, for module-uo) and would be that game compiled into core. A
// module that knows both halves reports both; core stores what it is told.
//
// **Every write is an upsert on `(run_id, member_key)`.** A module's collect step
// can be retried — that is what `EVENT_STEP_MAX_ATTEMPTS` means — and a retried
// collect that duplicated its rows would double a leaderboard. It is the same
// argument `materialisePhase`'s `INSERT IGNORE` makes about steps, one table
// along, with the difference that a re-report may carry a BETTER score and must
// win rather than be ignored.
const { query } = require('../../utils/db')
const { parseJson } = require('./eventJson')
const COLUMNS = `id, run_id, member_key, user_id, score, rank_at, joined_at, meta,
created_at, updated_at`
// `score` is `DECIMAL(18,4)` and the pool sets `decimalAsNumber`, so it already
// arrives as a JS number; the coercion is belt to that braces and costs nothing.
// `meta` is hydrated for the reason a resource's payload is: opaque to core, but
// every caller wants the object rather than the string the driver returns.
const hydrate = (row) =>
row && { ...row, score: Number(row.score), meta: parseJson(row.meta, null) }
/**
* Record one participant, or update the one already recorded.
*
* **`joined_at` is written on INSERT and never on UPDATE**, and that asymmetry is
* the point of the column: it is when this participant first appeared, and a
* second report — a later collect, a corrected score — must not rewrite it. The
* same applies to `rank_at`, which is not touched here at all: ranking is
* `core.results.publish`'s job and a re-report between two publications must not
* silently invent a rank nobody computed.
*
* `user_id` DOES move on a re-report, deliberately: a player who linked their
* website account between two collects should stop being anonymous, and the
* module is the only thing that can know they did.
*
* **It answers nothing, and the reason is a trap worth naming.** The obvious
* return is "was this new", read off `affectedRows` — 1 for an insert, 2 for an
* update. That is true only without `CLIENT_FOUND_ROWS`, and this connector
* sends it: with it, a re-report whose values are identical also answers 1, so
* the flag would report every idempotent retry as a fresh participant. The
* caller wants "how many were reported" anyway, which it already knows from the
* length of its own list.
*/
async function record({ runId, memberKey, userId = null, score = 0, meta = null, joinedAt = null }) {
await query(
`INSERT INTO event_run_participants (run_id, member_key, user_id, score, meta, joined_at)
VALUES (?, ?, ?, ?, ?, COALESCE(?, CURRENT_TIMESTAMP))
ON DUPLICATE KEY UPDATE
user_id = VALUES(user_id),
score = VALUES(score),
meta = VALUES(meta)`,
[runId, memberKey, userId, score, meta === null ? null : JSON.stringify(meta), joinedAt],
)
}
/** One run's participants, best first. The results table, and the console's. */
async function listForRun(runId, limit = 500) {
const rows = await query(
`SELECT ${COLUMNS} FROM event_run_participants
WHERE run_id = ?
ORDER BY score DESC, joined_at ASC, id ASC
LIMIT ?`,
[runId, limit],
)
return rows.map(hydrate)
}
/**
* One account's participation history, most recent event first (Phase 14a).
*
* **Joined all the way out to the definition, and the join is the access
* control.** A rehearsal is excluded by §D's own rule, and an unlisted
* definition is excluded because unlisting is what an operator does to an event
* they are not announcing — a history that named it would announce it to
* everyone who attended, which is everyone who could tell anybody.
*
* `member_key` is NOT selected. It is the game's identifier for a character and
* the caller is a player reading their own page; the run, the date, the score
* and the rank are what a history is, and the key adds a module-opaque string
* nothing on the page can render.
*
* `rank_at` is null until results are published, and that is a real state the
* screen shows rather than an error — a run whose participants are collected
* and unranked is exactly what Phase 10 made visible on the admin side.
*/
async function listForUser(userId, { limit = 50, before = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
const args = [userId]
// A keyset cursor on the participation row rather than an offset: the list
// gains a row every time the reader attends something, and an offset page two
// would skip whatever arrived in between.
const cursor = before ? ' AND p.id < ?' : ''
if (before) args.push(before)
const rows = await query(
`SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
r.timezone, r.results_published_at,
d.title AS definition_title, d.slug AS definition_slug,
s.name AS series_name, s.slug AS series_slug
FROM event_run_participants p
JOIN event_runs r ON r.id = p.run_id
JOIN event_definitions d ON d.id = r.definition_id
LEFT JOIN event_series s ON s.id = d.series_id
WHERE p.user_id = ?${cursor}
AND r.rehearsal = 0
AND d.listed = 1
ORDER BY p.id DESC
LIMIT ${n}`,
args,
)
return rows.map(hydrate)
}
/** How many the run has. Its own query because the trigger payload needs only this. */
async function countForRun(runId) {
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
return Number(rows[0]?.n || 0)
}
/**
* Number every participant of one run by score, best first.
*
* **One statement, and it has to be one.** The obvious form — `SET @rk := 0`
* followed by an `UPDATE … SET rank_at = (@rk := @rk + 1) ORDER BY …` — is
* wrong here in a way that would have passed every test that did not run twice
* concurrently: `query()` takes a connection from the pool per call and releases
* it, so the session variable is set on one connection and read on whichever the
* second call happens to get. A window function needs no session state at all.
*
* **The ordering is total.** `score DESC` alone leaves ties in whatever order the
* engine felt like, so two publications of the same run would hand out different
* ranks to the same two people; `joined_at` then `id` breaks every tie the same
* way every time, which is what makes re-publishing idempotent rather than a
* reshuffle.
*
* Ties share nothing — two people on the same score get consecutive ranks rather
* than a dense or competition ranking. That is a presentation decision belonging
* to whatever renders the table; what this owes is a stable number.
*/
async function rankRun(runId) {
const result = await query(
`UPDATE event_run_participants p
JOIN (SELECT id, ROW_NUMBER() OVER (ORDER BY score DESC, joined_at ASC, id ASC) AS rk
FROM event_run_participants
WHERE run_id = ?) r ON r.id = p.id
SET p.rank_at = r.rk`,
[runId],
)
// The connector sends CLIENT_FOUND_ROWS, so this counts rows MATCHED rather
// than rows changed — which is the number wanted here. Re-publishing a run
// whose ranks are already correct answers "12 ranked", not "0".
return Number(result.affectedRows || 0)
}
module.exports = { record, listForRun, listForUser, countForRun, rankRun }