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
|
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
||||||
* JSON-blob pattern the image/invention/rooms/accounts tables use.
|
* JSON-blob pattern the image/invention/rooms/accounts tables use.
|
||||||
*
|
*
|
||||||
* The `api` worker owns this schema/migration (migrations/0006_event.sql, applied
|
* The `api` worker owns this schema/migration (migrations/0006_event.sql and
|
||||||
* under its own `migrations_table` so it doesn't clash with the other workers'
|
* 0007_event_attendee.sql, applied under its own `migrations_table` so they don't
|
||||||
* migrations on the shared database).
|
* 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
|
* 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
|
* 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.
|
* 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[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS event (
|
`CREATE TABLE IF NOT EXISTS event (
|
||||||
data TEXT NOT NULL,
|
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_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_club ON event (club_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
|
`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
|
* 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.
|
* 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
|
* 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
|
* 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`
|
* client can't render is worse than a placeholder the creator can edit. `State` starts
|
||||||
* starts at 1 — the creator is attending their own event — and `State` at 0
|
* at 0 (scheduled). The creator comes from the bearer token, never the body.
|
||||||
* (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(
|
export async function createEvent(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -300,10 +348,83 @@ export async function createEvent(
|
|||||||
DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
|
DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
|
||||||
CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 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
|
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. */
|
/** Overwrite an event's stored blob in place. */
|
||||||
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
|
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
|
||||||
await db
|
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'),
|
.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. */
|
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
|
||||||
export const PlayerEventsAll = z.object({
|
export const PlayerEventsAll = z.object({
|
||||||
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
|
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. */
|
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
getEventsByCreator,
|
getEventsByCreator,
|
||||||
getEventsByIds,
|
getEventsByIds,
|
||||||
getLiveEvents,
|
getLiveEvents,
|
||||||
|
isEventResponseType,
|
||||||
parseEventBody,
|
parseEventBody,
|
||||||
searchEvents,
|
searchEvents,
|
||||||
|
setEventResponse,
|
||||||
toEventNotification,
|
toEventNotification,
|
||||||
toEventResult,
|
toEventResult,
|
||||||
updateEvent,
|
updateEvent,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
pageParams,
|
pageParams,
|
||||||
PlayerEventDto,
|
PlayerEventDto,
|
||||||
PlayerEventRequest,
|
PlayerEventRequest,
|
||||||
|
PlayerEventRespondRequest,
|
||||||
PlayerEventResultDto,
|
PlayerEventResultDto,
|
||||||
PlayerEventsAll,
|
PlayerEventsAll,
|
||||||
PlayerEventsPage,
|
PlayerEventsPage,
|
||||||
@@ -86,8 +89,13 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
|||||||
summary: 'The caller’s player events',
|
summary: 'The caller’s player events',
|
||||||
description:
|
description:
|
||||||
'Events the player created and events they have RSVP’d to. `Created` is served ' +
|
'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 ' +
|
'from the event table, soonest first.\n\n' +
|
||||||
'records an RSVP yet.',
|
'`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,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
200: json(PlayerEventsAll, 'The caller’s created events, and an empty RSVP list'),
|
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)))
|
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
|
// Create. The creator comes from the bearer token, never the body — posting someone
|
||||||
// else's `CreatorPlayerId` doesn't make it theirs.
|
// else's `CreatorPlayerId` doesn't make it theirs.
|
||||||
.post(
|
.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 ' +
|
'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 ' +
|
'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' +
|
'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 ' +
|
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
|
||||||
'0. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT the bare ' +
|
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
|
||||||
'event the read endpoints serve.\n\n' +
|
'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 ' +
|
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
|
||||||
'the event in its camelCase notification projection. A hub failure is logged and ' +
|
'the event in its camelCase notification projection. A hub failure is logged and ' +
|
||||||
'swallowed — the event is already stored by then.',
|
'swallowed — the event is already stored by then.',
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import {
|
|||||||
|
|
||||||
import '../../api.app'
|
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 { 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 INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-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])
|
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 () => {
|
test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => {
|
||||||
const event = await create({
|
const event = await create({
|
||||||
RoomId: 5,
|
RoomId: 5,
|
||||||
@@ -2725,6 +2791,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/messages/v2/send',
|
'POST /api/messages/v2/send',
|
||||||
'POST /api/playerReputation/v1/bulk',
|
'POST /api/playerReputation/v1/bulk',
|
||||||
'POST /api/playerReputation/v2/bulk',
|
'POST /api/playerReputation/v2/bulk',
|
||||||
|
'POST /api/playerevents/v1/respond',
|
||||||
'POST /api/playerevents/v2',
|
'POST /api/playerevents/v2',
|
||||||
'POST /api/playerevents/v2/{eventId}',
|
'POST /api/playerevents/v2/{eventId}',
|
||||||
'POST /api/players/v1/progression/bulk',
|
'POST /api/players/v1/progression/bulk',
|
||||||
|
|||||||
Reference in New Issue
Block a user