mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
(wip) attendees
This commit is contained in:
@@ -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);
|
||||
+129
-8
@@ -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<PlayerEvent | null> {
|
||||
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<number> {
|
||||
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<EventAttendeeRow | null> {
|
||||
return db
|
||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
|
||||
.bind(eventId, playerId)
|
||||
.first<EventAttendeeRow>()
|
||||
}
|
||||
|
||||
/** Everyone who answered an event, in the order they responded. Backs a future guest list. */
|
||||
export async function getEventAttendees(
|
||||
db: D1Database,
|
||||
eventId: number
|
||||
): Promise<EventAttendeeRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id')
|
||||
.bind(eventId)
|
||||
.all<EventAttendeeRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Overwrite an event's stored blob in place. */
|
||||
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
|
||||
await db
|
||||
|
||||
+10
-1
@@ -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. */
|
||||
|
||||
@@ -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<App>({ 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<App>({ 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<App>({ 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.',
|
||||
|
||||
@@ -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<Response> =>
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user