From aa304dbeded9d27948594aaf9e5226847bc0adec Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 18:54:40 -0400 Subject: [PATCH] (wip) attendees --- apps/api/migrations/0007_event_attendee.sql | 23 ++++ apps/api/src/events-db.ts | 137 ++++++++++++++++++-- apps/api/src/openapi.ts | 11 +- apps/api/src/routes/events.ts | 67 +++++++++- apps/api/src/test/integration/api.test.ts | 69 +++++++++- 5 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 apps/api/migrations/0007_event_attendee.sql diff --git a/apps/api/migrations/0007_event_attendee.sql b/apps/api/migrations/0007_event_attendee.sql new file mode 100644 index 0000000..55d3f08 --- /dev/null +++ b/apps/api/migrations/0007_event_attendee.sql @@ -0,0 +1,23 @@ +-- Player-event RSVPs: one row per player per event, recording how they answered +-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this +-- one is genuinely columnar — like the relationship/report tables — so it's a +-- normal relational table rather than a JSON blob. Owned by the `api` worker; +-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going +-- counts toward the event's `AttendeeCount`, which is recomputed from this table on +-- every response. A decline is recorded rather than deleted, so the client can show +-- a player their own answer and changing your mind is an UPDATE (the composite +-- primary key is what makes the upsert a replace). +-- +-- An event's creator gets a Going row at create time — that's why a fresh event's +-- AttendeeCount is 1. + +CREATE TABLE IF NOT EXISTS event_attendee ( + event_id INTEGER NOT NULL, + player_id INTEGER NOT NULL, + status INTEGER NOT NULL, + responded_at TEXT NOT NULL, + PRIMARY KEY (event_id, player_id) + ); +CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id); diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index d79ac00..81c7a69 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -4,16 +4,23 @@ * are SQLite generated (virtual) columns extracted from that JSON — the same * JSON-blob pattern the image/invention/rooms/accounts tables use. * - * The `api` worker owns this schema/migration (migrations/0006_event.sql, applied - * under its own `migrations_table` so it doesn't clash with the other workers' - * migrations on the shared database). + * The `api` worker owns this schema/migration (migrations/0006_event.sql and + * 0007_event_attendee.sql, applied under its own `migrations_table` so they don't + * clash with the other workers' migrations on the shared database). * * The stored record IS the DTO: every read endpoint serves the blob verbatim, so the * field set and casing here are exactly what the client parses. Timestamps are * normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match. + * + * RSVPs live alongside in `event_attendee`, one row per player per event. That one is + * genuinely columnar (like the relationship/report tables), so it's a normal + * relational table rather than a JSON blob. */ -/** Schema DDL (mirror of migrations/0006_event.sql, sans seed rows). */ +/** + * Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any + * seed rows). + */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS event ( data TEXT NOT NULL, @@ -28,8 +35,46 @@ export const SCHEMA_DDL: string[] = [ `CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`, `CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`, `CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`, + `CREATE TABLE IF NOT EXISTS event_attendee ( + event_id INTEGER NOT NULL, + player_id INTEGER NOT NULL, + status INTEGER NOT NULL, + responded_at TEXT NOT NULL, + PRIMARY KEY (event_id, player_id) + )`, + `CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`, ] +/** + * How a player answered an event invitation — the `Type` on + * `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`. + * + * Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and + * declining is recorded rather than deleted so the client can show the player their own + * answer (and so changing your mind is an update, not an insert). + */ +export const EVENT_RESPONSE = { + going: 0, + interested: 1, + cantGo: 2, +} as const + +/** The response types, for validating an incoming `Type`. */ +const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE) + +/** Whether a number is one of the three response types. */ +export function isEventResponseType(value: number): boolean { + return EVENT_RESPONSE_VALUES.includes(value) +} + +/** One player's answer to one event. */ +export interface EventAttendeeRow { + event_id: number + player_id: number + status: number + responded_at: string +} + /** * A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and * the settings the event runs under. Served verbatim by every read endpoint. @@ -266,9 +311,12 @@ const DEFAULT_DURATION_MS = 60 * 60 * 1000 * * Lenient about what the body carries, like the other writes here: an event with no * name or no time window is defaulted rather than rejected, because a rejection the - * client can't render is worse than a placeholder the creator can edit. `AttendeeCount` - * starts at 1 — the creator is attending their own event — and `State` at 0 - * (scheduled). The creator comes from the bearer token, never the body. + * client can't render is worse than a placeholder the creator can edit. `State` starts + * at 0 (scheduled). The creator comes from the bearer token, never the body. + * + * The creator is recorded as Going in `event_attendee`, which is what makes + * `AttendeeCount` start at 1: the count is derived from that table, so the creator + * needs a row there for the number to stay right once other players respond. */ export async function createEvent( db: D1Database, @@ -300,10 +348,83 @@ export async function createEvent( DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0, CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0, } - await db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)).run() + await db.batch([ + db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)), + db + .prepare( + `INSERT INTO event_attendee (event_id, player_id, status, responded_at) + VALUES (?1, ?2, ?3, ?4)` + ) + .bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)), + ]) return event } +/** + * Record a player's answer to an event, replacing whatever they said before — one row + * per player per event, so changing your mind is an update rather than a second RSVP. + * The event's `AttendeeCount` is recomputed from the table afterwards. + * + * Returns the updated event, or null when there's no such event. Anyone who can see an + * event may respond to it, the creator included (they're already Going from create, and + * nothing stops them declining their own event). + */ +export async function setEventResponse( + db: D1Database, + eventId: number, + playerId: number, + status: number +): Promise { + const event = await getEventById(db, eventId) + if (event === null) return null + + await db + .prepare( + `INSERT INTO event_attendee (event_id, player_id, status, responded_at) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4` + ) + .bind(eventId, playerId, status, eventTime(Date.now())) + .run() + + const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) } + await writeEvent(db, updated) + return updated +} + +/** How many players said they're Going — an event's `AttendeeCount`. */ +export async function countGoing(db: D1Database, eventId: number): Promise { + const row = await db + .prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2') + .bind(eventId, EVENT_RESPONSE.going) + .first<{ going: number }>() + return row?.going ?? 0 +} + +/** One player's answer to one event, or null when they haven't responded. */ +export async function getEventResponse( + db: D1Database, + eventId: number, + playerId: number +): Promise { + return db + .prepare('SELECT * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2') + .bind(eventId, playerId) + .first() +} + +/** Everyone who answered an event, in the order they responded. Backs a future guest list. */ +export async function getEventAttendees( + db: D1Database, + eventId: number +): Promise { + const { results } = await db + .prepare('SELECT * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id') + .bind(eventId) + .all() + return results +} + /** Overwrite an event's stored blob in place. */ async function writeEvent(db: D1Database, event: PlayerEvent): Promise { await db diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 52de287..b3f162f 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -457,10 +457,19 @@ export const PlayerEventRequest = PlayerEventDto.partial().extend({ .describe('The event’s fields, if nested rather than posted at the top level'), }) +/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */ +export const PlayerEventRespondRequest = z.object({ + PlayerEventId: z.int(), + Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'), +}) + /** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */ export const PlayerEventsAll = z.object({ Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'), - Responses: JsonArray.describe('Events the caller RSVP’d to — always empty, no RSVP storage'), + Responses: JsonArray.describe( + 'Events the caller RSVP’d to — always empty; RSVPs are stored, but this field’s ' + + 'entry shape has not been observed yet' + ), }) /** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */ diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index a321986..efb46b9 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -13,8 +13,10 @@ import { getEventsByCreator, getEventsByIds, getLiveEvents, + isEventResponseType, parseEventBody, searchEvents, + setEventResponse, toEventNotification, toEventResult, updateEvent, @@ -29,6 +31,7 @@ import { pageParams, PlayerEventDto, PlayerEventRequest, + PlayerEventRespondRequest, PlayerEventResultDto, PlayerEventsAll, PlayerEventsPage, @@ -86,8 +89,13 @@ export const eventRoutes = new Hono({ strict: false }) summary: 'The caller’s player events', description: 'Events the player created and events they have RSVP’d to. `Created` is served ' + - 'from the event table, soonest first. `Responses` is always empty — nothing ' + - 'records an RSVP yet.', + 'from the event table, soonest first.\n\n' + + '`Responses` is still always empty. RSVPs ARE stored now (see ' + + '`/api/playerevents/v1/respond` and the `event_attendee` table) — what isn’t known ' + + 'is the shape this field wants: whether an entry is a bare event like `Created`, ' + + 'or the event plus the answer, which is the useful thing to render. Serving the ' + + 'wrong one renders nothing rather than erroring, so it stays empty until a real ' + + 'response is observed.', security: AUTHED, responses: { 200: json(PlayerEventsAll, 'The caller’s created events, and an empty RSVP list'), @@ -248,6 +256,54 @@ export const eventRoutes = new Hono({ strict: false }) async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c))) ) + // RSVP. One row per player per event, so responding again replaces the previous + // answer rather than stacking up. Note this is the v1 path while create/update are + // v2 — that's how the client calls them. + .post( + '/api/playerevents/v1/respond', + describeRoute({ + tags: ['Events'], + summary: 'Answer a player event', + description: + 'Records how the caller is answering an event — `Type` is 0 Going, 1 Interested, ' + + '2 Can’t go. Responding again replaces the previous answer; there is one row per ' + + 'player per event, and a decline is recorded rather than deleted so the client can ' + + 'show a player what they said.\n\n' + + 'Only Going counts toward the event’s `AttendeeCount`, which is recomputed from ' + + 'the RSVP table on every response. Anyone may respond, the creator included — ' + + 'they are already Going from create, and nothing stops them declining their own ' + + 'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' + + 'v2 writes do, carrying the event with its updated count, so the client can ' + + 're-render from the response.\n\n' + + 'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' + + 'unknown event is a 404.', + security: AUTHED, + requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'), + responses: { + 200: json(PlayerEventResultDto, 'The event, with its updated attendee count'), + 400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' }, + 401: UNAUTHORIZED_RESPONSE, + 404: { description: 'No such event (empty body)' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = await c.req + .json<{ PlayerEventId?: unknown; Type?: unknown }>() + .catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown }) + const eventId = Number(body.PlayerEventId) + const type = Number(body.Type) + // Both are rejected rather than defaulted: an unrecognized answer stored as + // Going would silently inflate the count. + if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400) + + const updated = await setEventResponse(c.env.DB, eventId, id, type) + return updated === null ? c.body(null, 404) : c.json(toEventResult(updated)) + } + ) + // Create. The creator comes from the bearer token, never the body — posting someone // else's `CreatorPlayerId` doesn't make it theirs. .post( @@ -260,9 +316,10 @@ export const eventRoutes = new Hono({ strict: false }) 'body; the id is assigned here. Lenient about the rest, like the other writes ' + 'here — a missing name becomes “Untitled Event” and a missing time window becomes ' + 'an hour from now, rather than an error the client can’t render.\n\n' + - '`AttendeeCount` starts at 1 (the creator attends their own event) and `State` at ' + - '0. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT the bare ' + - 'event the read endpoints serve.\n\n' + + '`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' + + 'which is what makes `AttendeeCount` start at 1, since that count is derived from ' + + 'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' + + 'the bare event the read endpoints serve.\n\n' + 'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' + 'the event in its camelCase notification projection. A hub failure is logged and ' + 'swallowed — the event is already stored by then.', diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 903e91d..a622295 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -11,7 +11,12 @@ import { import '../../api.app' -import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../events-db' +import { + countGoing, + SCHEMA_DDL as EVENTS_SCHEMA_DDL, + getEventAttendees, + getEventResponse, +} from '../../events-db' import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db' import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db' @@ -2554,6 +2559,67 @@ describe('player events', () => { expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId]) }) + test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => { + const respond = async (body: unknown, sub = '42'): Promise => + post('/api/playerevents/v1/respond', body, sub) + + const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) }) + const id = event.PlayerEventId + // The creator is Going from create, which is where the initial 1 comes from. + expect(event.AttendeeCount).toBe(1) + expect(await countGoing(env.DB, id)).toBe(1) + + // 43 says Going → 2 attendees, and the envelope carries the updated event. + const res = await respond({ PlayerEventId: id, Type: 0 }, '43') + expect(res.status).toBe(200) + const body = (await res.json()) as PlayerEventResult + expect(body.Result).toBe(0) + expect(body.PlayerEvent.AttendeeCount).toBe(2) + expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ + event_id: id, + player_id: 43, + status: 0, + }) + + // Changing the answer REPLACES it — one row per player, not a second RSVP. + const changed = await respond({ PlayerEventId: id, Type: 2 }, '43') + expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1) + expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 }) + expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43]) + + // Interested is a maybe — recorded, but not counted. + await respond({ PlayerEventId: id, Type: 1 }, '43') + expect(await countGoing(env.DB, id)).toBe(1) + + // And the count sticks on the stored event, not just the response. + const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent + expect(fetched.AttendeeCount).toBe(1) + }) + + test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => { + const event = await create({ RoomId: 3, Name: 'Guarded' }) + + expect( + ( + await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/respond`, { + method: 'POST', + body: JSON.stringify({ PlayerEventId: event.PlayerEventId, Type: 0 }), + }) + ).status + ).toBe(401) + + // An unrecognized Type is rejected rather than defaulted — stored as Going it + // would silently inflate the count. + expect((await post('/api/playerevents/v1/respond', { PlayerEventId: 1, Type: 7 })).status).toBe( + 400 + ) + expect((await post('/api/playerevents/v1/respond', { Type: 0 })).status).toBe(400) + expect((await post('/api/playerevents/v1/respond', {})).status).toBe(400) + expect( + (await post('/api/playerevents/v1/respond', { PlayerEventId: 999999, Type: 0 })).status + ).toBe(404) + }) + test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => { const event = await create({ RoomId: 5, @@ -2725,6 +2791,7 @@ describe('openapi', () => { 'POST /api/messages/v2/send', 'POST /api/playerReputation/v1/bulk', 'POST /api/playerReputation/v2/bulk', + 'POST /api/playerevents/v1/respond', 'POST /api/playerevents/v2', 'POST /api/playerevents/v2/{eventId}', 'POST /api/players/v1/progression/bulk',