[api] fix multiple issues with events

This commit is contained in:
Devin Zuczek
2026-09-23 17:14:04 -04:00
parent 400f2903bd
commit 9bd3e1630f
6 changed files with 534 additions and 120 deletions
+3 -3
View File
@@ -4,9 +4,9 @@
-- normal relational table rather than a JSON blob. Owned by the `api` worker; -- normal relational table rather than a JSON blob. Owned by the `api` worker;
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync. -- 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 -- `status` is the response type: 0 Going, 1 Interested, 2 Can't go, 3 Pending (a bulk
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on -- invite's row for a player who hasn't answered yet). Only Going counts toward the
-- every response. A decline is recorded rather than deleted, so the client can show -- 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 -- a player their own answer and changing your mind is an UPDATE (the composite
-- primary key is what makes the upsert a replace). -- primary key is what makes the upsert a replace).
-- --
+27
View File
@@ -0,0 +1,27 @@
-- A never-reused id sequence for player events. Owned by the `api` worker; generated
-- from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- Event ids used to be `MAX(id) + 1` over the `event` table, which hands the NEWEST
-- event's id straight back out once it is deleted: delete an event, create another,
-- and the new one wears the old one's id — and inherits everything still filed under
-- it that lives outside the event's own rows (a `PlayerEventInvitation` message's
-- `player_event_id`, the client's own cache of the old event). An event id has to be
-- unique for all time, like the other ids here.
--
-- SQLite's AUTOINCREMENT is exactly that guarantee: the counter in `sqlite_sequence`
-- only ever goes up, whatever rows are deleted. `createEvent` draws an id by inserting
-- a row here and reading it back; the row itself is throwaway (it is deleted once
-- used — only the counter matters).
--
-- The seed row moves the counter past every event that already exists: inserting an
-- explicit id into an AUTOINCREMENT table raises the sequence to at least that id, so
-- the first draw after this migration is one past the current max, exactly what the
-- old query would have produced. On an empty table it inserts nothing.
CREATE TABLE IF NOT EXISTS event_id_seq (
id INTEGER PRIMARY KEY AUTOINCREMENT
);
-- HAVING, not WHERE: an aggregate over an empty table still yields one (NULL) row, and
-- a NULL id would draw 1 from the counter, making the first real event 2.
INSERT INTO event_id_seq (id)
SELECT MAX(id) FROM event HAVING MAX(id) IS NOT NULL;
+96 -40
View File
@@ -25,8 +25,8 @@ import {
} from '@repo/domain' } from '@repo/domain'
/** /**
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any * Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql +
* seed rows). * 0010_event_tag.sql + 0028_event_id_seq.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 (
@@ -58,6 +58,12 @@ export const SCHEMA_DDL: string[] = [
PRIMARY KEY (event_id, tag) PRIMARY KEY (event_id, tag)
)`, )`,
`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`, `CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
// The id sequence: AUTOINCREMENT so an id is never handed out twice, whatever gets
// deleted. See {@link nextEventId}. The migration also seeds it past the existing
// events; on a fresh database there are none, so the seed is omitted here.
`CREATE TABLE IF NOT EXISTS event_id_seq (
id INTEGER PRIMARY KEY AUTOINCREMENT
)`,
] ]
/** /**
@@ -67,19 +73,29 @@ export const SCHEMA_DDL: string[] = [
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and * 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 * 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). * answer (and so changing your mind is an update, not an insert).
*
* `pending` is not an answer: it's the row a bulk invite writes for the invitee, before
* they've said anything. It's what the client shows as an open invitation — an invite
* that landed as Going looked like the player had already accepted. Only the server
* writes it; `respond` won't take it as a `Type`.
*/ */
export const EVENT_RESPONSE = { export const EVENT_RESPONSE = {
going: 0, going: 0,
interested: 1, interested: 1,
cantGo: 2, cantGo: 2,
pending: 3,
} as const } as const
/** The response types, for validating an incoming `Type`. */ /** The answers a player may give — every type but `pending`, for validating an incoming `Type`. */
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE) const EVENT_ANSWER_VALUES: number[] = [
EVENT_RESPONSE.going,
EVENT_RESPONSE.interested,
EVENT_RESPONSE.cantGo,
]
/** Whether a number is one of the three response types. */ /** Whether a number is one of the three answers a player can give (never `pending`). */
export function isEventResponseType(value: number): boolean { export function isEventResponseType(value: number): boolean {
return EVENT_RESPONSE_VALUES.includes(value) return EVENT_ANSWER_VALUES.includes(value)
} }
/** /**
@@ -174,9 +190,9 @@ interface EventRow {
} }
/** /**
* A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored * A tag as the `v2` envelope carries it to a build newer than 20230414: the PascalCase
* `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one * form of the stored `{ tag, type }` pair. NOT the lowercase pair the v1 read serves —
* tag, and the client parses each in exactly one place. * three casings of one tag, and the client parses each in exactly one place.
*/ */
export interface PlayerEventEnvelopeTag { export interface PlayerEventEnvelopeTag {
Tag: string Tag: string
@@ -190,9 +206,11 @@ export interface PlayerEventEnvelopeTag {
* *
* `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room * `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room
* changed it under the same unversioned path rather than minting a `v3`: the 2023 build * changed it under the same unversioned path rather than minting a `v3`: the 2023 build
* parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either * (20230414 and older) parses `["celebration"]` and anything newer parses
* one to the other build leaves the event's tag chips empty — the decoder drops what it * `[{ Tag, Type }]`. Serving either one to the other build leaves the event's tag chips
* can't read rather than erroring. {@link toEventResult} picks; nothing else should. * empty — the decoder drops what it can't read rather than erroring.
* {@link toEventResult} picks; nothing else should. `TagModifyResult.Tags` is NOT part of
* this: it is a `List<string>` to every build.
*/ */
export interface PlayerEventEnvelope extends PlayerEventBase { export interface PlayerEventEnvelope extends PlayerEventBase {
Tags: string[] | PlayerEventEnvelopeTag[] Tags: string[] | PlayerEventEnvelopeTag[]
@@ -218,17 +236,18 @@ export interface PlayerEventResult {
* tags — pass what `getEventTags` returns, so the answer reflects what was actually * tags — pass what `getEventTags` returns, so the answer reflects what was actually
* written rather than what was asked for. * written rather than what was asked for.
* *
* `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see * `pairedTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
* {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It * {@link PlayerEventEnvelope}): the `{ Tag, Type }` pairs of a build newer than 20230414
* changes nothing else — `TagModifyResult.Tags` is a name list to both builds. * when set, the 2023 build's bare names when not. It changes nothing else —
* `TagModifyResult.Tags` is a name list to both builds.
*/ */
export function toEventResult( export function toEventResult(
event: PlayerEvent, event: PlayerEvent,
tags: EventTag[] = [], tags: EventTag[] = [],
legacyTags = false pairedTags = false
): PlayerEventResult { ): PlayerEventResult {
const names = tags.map((t) => t.tag) const names = tags.map((t) => t.tag)
const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names const carried = pairedTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
return { return {
PlayerEvent: { Tags: carried, ...toEventBase(event) }, PlayerEvent: { Tags: carried, ...toEventBase(event) },
Result: 0, Result: 0,
@@ -620,14 +639,11 @@ export async function createEvent(
creatorPlayerId: number, creatorPlayerId: number,
input: EventInput input: EventInput
): Promise<PlayerEvent> { ): Promise<PlayerEvent> {
// Sequential id: one past the current max (the table starts empty). const id = await nextEventId(db)
const row = await db
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
.first<{ next: number }>()
const now = Date.now() const now = Date.now()
const startTime = input.startTime ?? eventTime(now) const startTime = input.startTime ?? eventTime(now)
const event: PlayerEvent = { const event: PlayerEvent = {
PlayerEventId: row?.next ?? 1, PlayerEventId: id,
CreatorPlayerId: creatorPlayerId, CreatorPlayerId: creatorPlayerId,
ImageName: input.imageName ?? null, ImageName: input.imageName ?? null,
RoomId: input.roomId ?? 0, RoomId: input.roomId ?? 0,
@@ -665,46 +681,51 @@ export async function createEvent(
* per player per event, so changing your mind is an update rather than a second RSVP. * 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. * 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 * Returns the updated event and the player's response row as it now stands (the route
* event may respond to it, the creator included (they're already Going from create, and * pushes that row on the `PlayerEventResponseChanged` frame), or null when there's no
* nothing stops them declining their own event). * 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( export async function setEventResponse(
db: D1Database, db: D1Database,
eventId: number, eventId: number,
playerId: number, playerId: number,
status: number status: number
): Promise<PlayerEvent | null> { ): Promise<{ event: PlayerEvent; response: EventAttendeeRow } | null> {
const event = await getEventById(db, eventId) const event = await getEventById(db, eventId)
if (event === null) return null if (event === null) return null
await db const response = await db
.prepare( .prepare(
`INSERT INTO event_attendee (event_id, player_id, status, responded_at) `INSERT INTO event_attendee (event_id, player_id, status, responded_at)
VALUES (?1, ?2, ?3, ?4) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4` ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4
RETURNING rowid AS id, *`
) )
.bind(eventId, playerId, status, eventTime(Date.now())) .bind(eventId, playerId, status, eventTime(Date.now()))
.run() .first<EventAttendeeRow>()
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) } const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
await writeEvent(db, updated) await writeEvent(db, updated)
return updated // An upsert with RETURNING always yields the row it inserted or updated.
return { event: updated, response: response! }
} }
/** /**
* Add invited players to an event as Going — the bulk invite. Returns the updated * Add invited players to an event as Pending — the bulk invite. Returns the updated
* event (with its recounted `AttendeeCount`) and the rows actually created, or null * event (with its recounted `AttendeeCount`) and the rows actually created, or null
* when there's no such event. * when there's no such event.
* *
* An invite only ever INSERTS: a player who already has a row keeps the answer they * An invite only ever INSERTS: a player who already has a row keeps the answer they
* gave, so being invited can't flip a decline back to Going, and re-inviting the same * gave, so being invited can't reset an answer to Pending, and re-inviting the same
* player is a no-op rather than a reset. Since the rows land as Going, the invited * player is a no-op. The rows land as Pending (3), not Going: the invitee hasn't
* count toward `AttendeeCount` from the moment they're invited — see the route. * accepted anything yet, so they don't count toward `AttendeeCount` until they answer
* through `respond` — see the route.
* *
* `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict * `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict
* inserts nothing and returns nothing. That's what the route notifies on — a player * inserts nothing and returns nothing. That's who the route sends the invitation Message
* whose existing answer was left alone gets no frame, because nothing changed for them. * to — a player whose existing answer was left alone gets nothing, because nothing
* changed for them.
* *
* Ids are deduplicated by the composite primary key; an empty list is a no-op that * Ids are deduplicated by the composite primary key; an empty list is a no-op that
* still returns the event. * still returns the event.
@@ -728,7 +749,7 @@ export async function inviteToEvent(
ON CONFLICT (event_id, player_id) DO NOTHING ON CONFLICT (event_id, player_id) DO NOTHING
RETURNING rowid AS id, *` RETURNING rowid AS id, *`
) )
.bind(eventId, playerId, EVENT_RESPONSE.going, at) .bind(eventId, playerId, EVENT_RESPONSE.pending, at)
) )
) )
const added = inserts.flatMap((r) => r.results) const added = inserts.flatMap((r) => r.results)
@@ -738,6 +759,22 @@ export async function inviteToEvent(
return { event: updated, added } return { event: updated, added }
} }
/**
* The players who said they're GOING to an event — who the `PlayerEventUpdated` and
* `PlayerEventDeleted` frames go to. Interested, declined and Pending rows are left out:
* an invitee who hasn't accepted isn't attending, so nothing about the event has changed
* for them. The creator is included through their own Going row from create.
*/
export async function getGoingPlayerIds(db: D1Database, eventId: number): Promise<number[]> {
const { results } = await db
.prepare(
'SELECT player_id FROM event_attendee WHERE event_id = ?1 AND status = ?2 ORDER BY player_id'
)
.bind(eventId, EVENT_RESPONSE.going)
.all<{ player_id: number }>()
return results.map((r) => r.player_id)
}
/** How many players said they're Going — an event's `AttendeeCount`. */ /** How many players said they're Going — an event's `AttendeeCount`. */
export async function countGoing(db: D1Database, eventId: number): Promise<number> { export async function countGoing(db: D1Database, eventId: number): Promise<number> {
const row = await db const row = await db
@@ -829,12 +866,31 @@ export async function updateEvent(
return updated return updated
} }
/**
* Draw the next event id. Sequential and NEVER reused: the id comes from the
* `event_id_seq` AUTOINCREMENT counter, which only ever goes up, rather than from
* `MAX(id) + 1` over the events themselves — that handed the newest event's id straight
* back out once it was deleted, so a replacement event wore the old one's id and
* inherited whatever was still filed under it (an invitation message's `PlayerEventId`,
* the client's cache). The drawn row is thrown away in the same batch; only the counter
* matters, and SQLite keeps that in `sqlite_sequence` regardless of the rows.
*/
async function nextEventId(db: D1Database): Promise<number> {
const [drawn] = await db.batch<{ id: number }>([
db.prepare('INSERT INTO event_id_seq DEFAULT VALUES RETURNING id'),
db.prepare('DELETE FROM event_id_seq'),
])
const id = drawn.results[0]?.id
if (id === undefined) throw new Error('event_id_seq returned no id')
return id
}
/** /**
* Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags * Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags
* (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the * (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the
* attendee counts and the `#tag` search would still find. Event ids are assigned in * attendee counts and the `#tag` search would still find. Event ids are drawn from a
* sequence and never reused, but orphan rows would still be counted against whatever id * sequence that never reuses one (see {@link nextEventId}), so a deleted event's id is
* they name. * retired with it — but orphan rows would still be counted against whatever id they name.
* *
* Answers the event as it was, so the caller can report what it deleted; `null` when there * Answers the event as it was, so the caller can report what it deleted; `null` when there
* was no such event. * was no such event.
+9 -6
View File
@@ -1150,15 +1150,16 @@ export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName:
* image. * image.
* *
* `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without * `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without
* minting a new path, so a build newer than `20230414` gets the tag NAMES and every older * minting a new path, so a build newer than `20230414` gets the `{ Tag, Type }` pairs and
* one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither * that build, every older one, and any caller whose token names no build get the tag
* is the lowercase `{ tag, type }` the v1 read serves. * NAMES. Neither is the lowercase `{ tag, type }` the v1 read serves. `TagModifyResult.Tags`
* is a name list to every build.
*/ */
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({ export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
Tags: z Tags: z
.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))]) .union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
.describe( .describe(
'The event’s tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' + 'The event’s tags: `{ Tag, Type }` pairs for a build newer than 20230414, names for ' +
'that build and older' 'that build and older'
), ),
}) })
@@ -1176,7 +1177,7 @@ export const PlayerEventResultDto = z.object({
Result: z.int().describe('0 = success'), Result: z.int().describe('0 = success'),
TagModifyResult: z.object({ TagModifyResult: z.object({
Result: z.int().describe('0 = success'), Result: z.int().describe('0 = success'),
Tags: z.array(z.string()).describe('The tags the event now carries'), Tags: z.array(z.string()).describe('The tags the event now carries — names to every build'),
}), }),
}) })
@@ -1264,7 +1265,9 @@ export const PlayerEventResponseDto = z.object({
'When the answer that stands was given — a changed answer updates the row, so this ' + 'When the answer that stands was given — a changed answer updates the row, so this ' +
'moves with it rather than recording the player’s first response' 'moves with it rather than recording the player’s first response'
), ),
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'), Type: z
.int()
.describe('0 Going, 1 Interested, 2 Can’t go, 3 Pending (invited, not yet answered)'),
}) })
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */ /** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
+215 -37
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' import { describeRoute } from 'hono-openapi'
import { Accessibility, GAME_VERSION } from '@repo/domain' import { Accessibility, createNotification, GAME_VERSION, MessageType } from '@repo/domain'
import { logger } from '@repo/hono-helpers' import { logger } from '@repo/hono-helpers'
import { validateAndGetVersion } from '@repo/jwt' import { validateAndGetVersion } from '@repo/jwt'
@@ -21,6 +21,7 @@ import {
getEventsByIds, getEventsByIds,
getEventsByRoom, getEventsByRoom,
getEventTags, getEventTags,
getGoingPlayerIds,
getLiveEvents, getLiveEvents,
inviteToEvent, inviteToEvent,
isEventResponseType, isEventResponseType,
@@ -70,7 +71,11 @@ import {
import { createReport } from '../reports-db' import { createReport } from '../reports-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads' import type { StoredNotification } from '@repo/domain'
import type {
PlayerEventIdPayload,
PlayerEventResponsePayload,
} from '../../../notify/src/notification-payloads'
import type { App } from '../context' import type { App } from '../context'
import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db' import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db'
@@ -105,12 +110,84 @@ async function notifyEventCreated(
} }
/** /**
* Push a `PlayerEventResponseChanged` (83) to each player a bulk invite just added — * Push one frame to every player who is GOING to an event — the audience for a change
* what puts the event on their screen without a refetch, since an invite writes their * to the event itself. Only Going rows (see {@link getGoingPlayerIds}): a player who is
* response row for them. * merely invited, interested or declined has nothing on screen to update. The creator
* is among them through their own Going row, so their own edit or delete reaches their
* client too, the way `PlayerEventCreated` does.
* *
* Only the players who actually gained a row are notified: an invite that hit an * `playerIds` is passed in rather than read here because a delete has to read them
* existing answer changed nothing, so there is nothing to tell them about. * BEFORE the rows go. Hub failures are logged and swallowed, and one player's failure
* doesn't stop the rest: the change is already stored by the time this runs.
*/
async function notifyGoing(
c: Context<App>,
playerIds: number[],
type: NotificationType,
payload: Record<string, unknown>,
playerEventId: number
): Promise<void> {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
for (const playerId of playerIds) {
try {
await hub.notifyPlayer(playerId, type, payload)
} catch (err) {
logger.error('failed to push player event notification', {
notificationType: type,
playerEventId,
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
}
/**
* Push a `PlayerEventUpdated` (81) to everyone Going to an event that was just edited —
* the whole-event update and every single-field edit alike — carrying the event as it
* now stands, in the same camelCase {@link toEventNotification} projection the
* `PlayerEventCreated` frame uses: the two share a payload shape on the client
* (`PlayerEventPayload` in notification-payloads.ts documents its fields; the decoder
* accepts either casing).
*/
async function notifyEventUpdated(
c: Context<App>,
event: PlayerEvent,
tags: EventTag[]
): Promise<void> {
const payload: Record<string, unknown> = { ...toEventNotification(event, tags) }
await notifyGoing(
c,
await getGoingPlayerIds(c.env.DB, event.PlayerEventId),
NotificationType.PlayerEventUpdated,
payload,
event.PlayerEventId
)
}
/**
* Push a `PlayerEventDeleted` (82) to everyone who was Going to an event that was just
* deleted, so it leaves their screen. The frame is just the id — there is no event left
* to carry. `playerIds` must have been read before the delete took the RSVP rows with it.
*/
async function notifyEventDeleted(
c: Context<App>,
playerIds: number[],
playerEventId: number
): Promise<void> {
const payload = { PlayerEventId: playerEventId } satisfies PlayerEventIdPayload
await notifyGoing(c, playerIds, NotificationType.PlayerEventDeleted, payload, playerEventId)
}
/**
* Push a `PlayerEventResponseChanged` (83) to a player who just ANSWERED an event
* (`POST /api/playerevents/v1/respond`) — what puts the event on their screen without a
* refetch. This is the responder's own frame, carrying their own row.
*
* An INVITE does not send this. What an invitee gets is the invitation as a Message
* (see {@link pushEventInvitation}); their Pending row is not a response, and the
* client populates the event from this frame only once they have actually responded.
* Sending it on invite made the event appear as if the player had already answered.
* *
* The frame carries BOTH nested objects the client's decoder expects. That is not * The frame carries BOTH nested objects the client's decoder expects. That is not
* optional — several of its handlers dereference one level down with no null guard, so * optional — several of its handlers dereference one level down with no null guard, so
@@ -120,30 +197,100 @@ async function notifyEventCreated(
* response in the PascalCase {@link toEventResponse} one the RSVP list serves; the * response in the PascalCase {@link toEventResponse} one the RSVP list serves; the
* decoder accepts either casing, so the two need not agree. * decoder accepts either casing, so the two need not agree.
* *
* Hub failures are logged and swallowed, and one player's failure doesn't stop the * Hub failures are logged and swallowed: the answer is already stored by the time this
* rest: the invites are already stored by the time this runs. * runs.
*/ */
async function notifyInvited( async function notifyResponseChanged(
c: Context<App>, c: Context<App>,
event: PlayerEvent, event: PlayerEvent,
added: EventAttendeeRow[] response: EventAttendeeRow
): Promise<void> { ): Promise<void> {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const PlayerEvent = { ...toEventNotification(event) }
for (const row of added) {
const payload = { const payload = {
PlayerEvent, PlayerEvent: { ...toEventNotification(event) },
PlayerEventResponse: { ...toEventResponse(row) }, PlayerEventResponse: { ...toEventResponse(response) },
} satisfies PlayerEventResponsePayload } satisfies PlayerEventResponsePayload
try { try {
await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload) await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
response.player_id,
NotificationType.PlayerEventResponseChanged,
payload
)
} catch (err) { } catch (err) {
logger.error('failed to push PlayerEventResponseChanged notification', { logger.error('failed to push PlayerEventResponseChanged notification', {
playerEventId: event.PlayerEventId, playerEventId: event.PlayerEventId,
playerId: row.player_id, playerId: response.player_id,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}) })
} }
}
/**
* Tell each player a bulk invite just added about it: the invitation as a Message of
* type 81 `PlayerEventInvitation`, stored and pushed as `MessageReceived` — see
* {@link pushEventInvitation}. That Message is the whole of what an invitee is sent; the
* `PlayerEventResponseChanged` frame is theirs only once they answer.
*
* Only the players who actually gained a row are notified: an invite that hit an
* existing answer changed nothing, so there is nothing to tell them about. One player's
* failure doesn't stop the rest: the invites are already stored by the time this runs.
*/
async function notifyInvited(
c: Context<App>,
inviterId: number,
event: PlayerEvent,
added: EventAttendeeRow[]
): Promise<void> {
for (const row of added) {
await pushEventInvitation(c, inviterId, row.player_id, event.PlayerEventId)
}
}
/**
* Store and push the invitation Message a bulk invite shows the invitee: a Message of
* type 81 `PlayerEventInvitation` from the inviter, delivered as `MessageReceived` and
* kept in the inbox (`GET /api/messages/v2/get`) so a player who was offline still finds
* it. The event is named twice — on the row's own `PlayerEventId` column, and as `Data`,
* the event id as a STRING (a Message's `Data` is always a string on the wire).
*
* The row is committed before the push; a hub failure is logged and swallowed, since the
* invite itself is already stored.
*/
async function pushEventInvitation(
c: Context<App>,
fromPlayerId: number,
toPlayerId: number,
playerEventId: number
): Promise<void> {
let message: StoredNotification
try {
message = await createNotification(c.env.DB, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: MessageType.PlayerEventInvitation,
Data: String(playerEventId),
PlayerEventId: playerEventId,
})
} catch (err) {
logger.error('failed to store PlayerEventInvitation message', {
playerEventId,
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toPlayerId,
NotificationType.MessageReceived,
{ ...message }
)
} catch (err) {
logger.error('failed to push PlayerEventInvitation MessageReceived notification', {
notificationId: message.Id,
playerEventId,
toPlayerId,
error: err instanceof Error ? err.message : String(err),
})
} }
} }
@@ -151,12 +298,13 @@ async function notifyInvited(
* Wrap an event in the v2 envelope for THIS caller's build. * Wrap an event in the v2 envelope for THIS caller's build.
* *
* Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint * Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint
* owes the 2023 build `[{ Tag, Type }]` and the 2025 build `["celebration"]`. The build * owes the 2023 build `["celebration"]` and anything newer `[{ Tag, Type }]`. The build
* comes off the token's `rn.ver` claim — the request carries no version of its own — and * comes off the token's `rn.ver` claim — the request carries no version of its own — and
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than * the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
* `GAME_VERSION` (20230414) is the 2025 client; that build, anything older, and a request * `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request
* with no readable token version all get the 2023 shape. Builds are date-stamped, so they * with no readable token version all get the 2023 names. Builds are date-stamped, so they
* order as strings. * order as strings. `TagModifyResult.Tags` is a name list to every build — only the
* inline field moves.
* *
* Like the other version gates here the claim is unverified — a client that lies about its * Like the other version gates here the claim is unverified — a client that lies about its
* build only empties its own tag chips. * build only empties its own tag chips.
@@ -164,7 +312,7 @@ async function notifyInvited(
async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) { async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const isModernBuild = version !== null && version > GAME_VERSION const isModernBuild = version !== null && version > GAME_VERSION
return toEventResult(event, tags, !isModernBuild) return toEventResult(event, tags, isModernBuild)
} }
/** /**
@@ -180,6 +328,7 @@ async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]
* *
* These edits are creator-only like the whole-event update, and they go through the same * These edits are creator-only like the whole-event update, and they go through the same
* {@link updateEvent}, so a patch touching one field leaves the rest of the event alone. * {@link updateEvent}, so a patch touching one field leaves the rest of the event alone.
* Like it, each pushes a `PlayerEventUpdated` to everyone Going.
*/ */
function editEventField( function editEventField(
parse: (c: Context<App>, event: PlayerEvent) => Promise<EventInput | null> parse: (c: Context<App>, event: PlayerEvent) => Promise<EventInput | null>
@@ -199,7 +348,9 @@ function editEventField(
if (input === null) return c.body(null, 400) if (input === null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input) const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out. // updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId))) const tags = await getEventTags(c.env.DB, eventId)
await notifyEventUpdated(c, updated!, tags)
return c.json(await eventResult(c, updated!, tags))
} }
} }
@@ -546,8 +697,13 @@ export const eventRoutes = new Hono<App>({ strict: false })
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' + 'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
'v2 writes do, carrying the event with its updated count, so the client can ' + 'v2 writes do, carrying the event with its updated count, so the client can ' +
're-render from the response.\n\n' + 're-render from the response.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' + 'The caller is also pushed a `PlayerEventResponseChanged` frame carrying the event ' +
'unknown event is a 404.', 'and their new response row — this frame, not the invite, is what populates the ' +
'event on the client. An invitee answering Pending → Going gets it here like ' +
'anyone else.\n\n' +
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400 — 3 ' +
'Pending is the server’s own mark for an unanswered invite, not an answer a ' +
'player can give; an unknown event is a 404.',
security: AUTHED, security: AUTHED,
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'), requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
responses: { responses: {
@@ -570,9 +726,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
// Going would silently inflate the count. // Going would silently inflate the count.
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400) if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
const updated = await setEventResponse(c.env.DB, eventId, id, type) const result = await setEventResponse(c.env.DB, eventId, id, type)
if (updated === null) return c.body(null, 404) if (result === null) return c.body(null, 404)
return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId))) await notifyResponseChanged(c, result.event, result.response)
return c.json(await eventResult(c, result.event, await getEventTags(c.env.DB, eventId)))
} }
) )
@@ -640,20 +797,30 @@ export const eventRoutes = new Hono<App>({ strict: false })
) )
// Bulk invite — the "invite friends" button on an event. Adds the invited players to // Bulk invite — the "invite friends" button on an event. Adds the invited players to
// the same `event_attendee` table an RSVP writes to, as Going. // the same `event_attendee` table an RSVP writes to, as Pending (3) — an open
// invitation, not an acceptance.
.post( .post(
'/api/playerevents/v1/bulkInvite', '/api/playerevents/v1/bulkInvite',
describeRoute({ describeRoute({
tags: ['Events'], tags: ['Events'],
summary: 'Invite players to an event', summary: 'Invite players to an event',
description: description:
'Adds the invited players to the event as Going — the same `event_attendee` rows ' + 'Adds the invited players to the event as Pending (`Type` 3) — the same ' +
'an RSVP writes, so an invited player shows up in `…/responses` and counts toward ' + '`event_attendee` rows an RSVP writes, so an invited player shows up in ' +
'`AttendeeCount` immediately, without having answered.\n\n' + '`…/responses` with an open invitation. Pending is not Going: the invitee does not ' +
'count toward `AttendeeCount` until they answer through `…/respond`. (Inviting as ' +
'Going made the invitation look already accepted on the client.)\n\n' +
'An invite never overwrites an answer: a player who already responded keeps what ' + 'An invite never overwrites an answer: a player who already responded keeps what ' +
'they said, so inviting someone who declined does not flip them back to Going, and ' + 'they said, so inviting someone who declined does not reset them to Pending, and ' +
're-inviting is a no-op. The caller is skipped (they are already on the list), as ' + 're-inviting is a no-op. The caller is skipped (they are already on the list), as ' +
'are duplicate ids.\n\n' + 'are duplicate ids.\n\n' +
'Each newly invited player is sent the invitation as a Message of type 81 ' +
'`PlayerEventInvitation` from the caller — stored in their inbox ' +
'(`GET /api/messages/v2/get`) and pushed as `MessageReceived`, with the event id ' +
'on `PlayerEventId` and, as a string, in `Data`. That is all an invitee gets: no ' +
'`PlayerEventResponseChanged` is pushed on invite — that frame is what `…/respond` ' +
'sends once they answer, and is what populates the event on their client. ' +
'Players whose existing answer was left alone get nothing.\n\n' +
'The caller must be on the event themselves — its creator, or a player with a ' + 'The caller must be on the event themselves — its creator, or a player with a ' +
'response row of any kind. Anyone else gets 403: an invite adds attendees, so it ' + 'response row of any kind. Anyone else gets 403: an invite adds attendees, so it ' +
'is not something a passer-by can do. Answers the same ' + 'is not something a passer-by can do. Answers the same ' +
@@ -662,7 +829,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
security: AUTHED, security: AUTHED,
requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'), requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'),
responses: { responses: {
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'), 200: json(PlayerEventResultDto, 'The event (its attendee count is unchanged by an invite)'),
400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' }, 400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' },
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: { description: 'The caller is not on the event (empty body)' }, 403: { description: 'The caller is not on the event (empty body)' },
@@ -702,7 +869,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
] ]
const result = await inviteToEvent(c.env.DB, eventId, invited) const result = await inviteToEvent(c.env.DB, eventId, invited)
// inviteToEvent only returns null when the row vanished, which the read above rules out. // inviteToEvent only returns null when the row vanished, which the read above rules out.
await notifyInvited(c, result!.event, result!.added) await notifyInvited(c, id, result!.event, result!.added)
return c.json(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId))) return c.json(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId)))
} }
) )
@@ -781,6 +948,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
'Deletes an event the caller created, along with its RSVPs and its tags — an event ' + 'Deletes an event the caller created, along with its RSVPs and its tags — an event ' +
'whose attendee rows outlived it would still be counted, and its tags would still ' + 'whose attendee rows outlived it would still be counted, and its tags would still ' +
'answer `#tag` searches.\n\n' + 'answer `#tag` searches.\n\n' +
'Everyone who was GOING to the event (the creator included) is pushed a ' +
'`PlayerEventDeleted` frame naming the event id, so it leaves their screen; ' +
'interested, declined and still-Pending invitees are not.\n\n' +
'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' + 'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' +
'envelope with `PlayerEvent` and `TagModifyResult` both null — the event is gone, ' + 'envelope with `PlayerEvent` and `TagModifyResult` both null — the event is gone, ' +
'so there is nothing for the client to redraw from, and it reads only `Result`. ' + 'so there is nothing for the client to redraw from, and it reads only `Result`. ' +
@@ -804,7 +974,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (existing === null) return c.body(null, 404) if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403) if (existing.CreatorPlayerId !== id) return c.body(null, 403)
// Who to tell has to be read BEFORE the delete: it takes the RSVP rows with it.
const going = await getGoingPlayerIds(c.env.DB, eventId)
await deleteEvent(c.env.DB, eventId) await deleteEvent(c.env.DB, eventId)
await notifyEventDeleted(c, going, eventId)
// Both payload fields are null here — the delete envelope is not the one the other // Both payload fields are null here — the delete envelope is not the one the other
// v2 routes answer with. Nothing is left to redraw, and the client reads `Result`. // v2 routes answer with. Nothing is left to redraw, and the client reads `Result`.
return c.json(EVENT_DELETED_RESULT) return c.json(EVENT_DELETED_RESULT)
@@ -857,6 +1030,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
'The id, the creator and the attendee count are not editable: ownership doesn’t ' + 'The id, the creator and the attendee count are not editable: ownership doesn’t ' +
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' + 'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.\n\n' + 'an unknown event is 404. Answers the same envelope as create.\n\n' +
'Everyone who is GOING to the event (the creator included) is pushed a ' +
'`PlayerEventUpdated` frame carrying the edited event; interested, declined and ' +
'still-Pending invitees are not. The single-field edits push the same frame.\n\n' +
'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' + 'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' +
'carries: moving the start alone still has to leave a window that ends after it ' + 'carries: moving the start alone still has to leave a window that ends after it ' +
'and runs no longer than a day against the STORED end.', 'and runs no longer than a day against the STORED end.',
@@ -891,7 +1067,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (eventInputRejection(input, existing) !== null) return c.body(null, 400) if (eventInputRejection(input, existing) !== null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input) const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out. // updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId))) const tags = await getEventTags(c.env.DB, eventId)
await notifyEventUpdated(c, updated!, tags)
return c.json(await eventResult(c, updated!, tags))
} }
) )
+178 -28
View File
@@ -8034,10 +8034,10 @@ describe('player events', () => {
expect(body.Result).toBe(0) expect(body.Result).toBe(0)
expect(body.PlayerEvent.Name).toBe('Enveloped') expect(body.PlayerEvent.Name).toBe('Enveloped')
// The tags ride inline on the event AND in TagModifyResult. Inline they take the // The tags ride inline on the event AND in TagModifyResult. Inline they take the
// caller's build shape — this token names no build, so the 2023 `{ Tag, Type }` pairs // caller's build shape — this token names no build, so the 2023 bare names (a newer
// (PascalCase: not the lowercase pairs the v1 read's `tags` serves). TagModifyResult // build gets the PascalCase `{ Tag, Type }` pairs). TagModifyResult is names to every
// is names to every build. // build.
expect(body.PlayerEvent.Tags).toEqual([{ Tag: 'music', Type: 0 }]) expect(body.PlayerEvent.Tags).toEqual(['music'])
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] }) expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] })
// No `State`, and the broadcast instance is present and null. // No `State`, and the broadcast instance is present and null.
expect(body.PlayerEvent).not.toHaveProperty('State') expect(body.PlayerEvent).not.toHaveProperty('State')
@@ -8046,7 +8046,7 @@ describe('player events', () => {
test('the v2 envelope shapes PlayerEvent.Tags per the caller’s build', async () => { test('the v2 envelope shapes PlayerEvent.Tags per the caller’s build', async () => {
// Rec Room reshaped this field without minting a new path, so one endpoint owes two // Rec Room reshaped this field without minting a new path, so one endpoint owes two
// shapes: the 2023 build parses `{ Tag, Type }` pairs, the 2025 build bare names. // shapes: the 2023 build parses bare names, anything newer `{ Tag, Type }` pairs.
// Serving either to the wrong build empties the event's chips instead of erroring. // Serving either to the wrong build empties the event's chips instead of erroring.
// A tag of this test's own: the `#tag` search tests assert exact result sets, and the // A tag of this test's own: the `#tag` search tests assert exact result sets, and the
// four events below would join any set they share a tag with. // four events below would join any set they share a tag with.
@@ -8066,16 +8066,18 @@ describe('player events', () => {
return (await res.json()) as PlayerEventResult return (await res.json()) as PlayerEventResult
} }
// Newer than 20230414 is the 2025 client; that build, an older one, and a token naming // Newer than 20230414 gets the pairs; that build, an older one, and a token naming
// no build at all are all the 2023 client. Builds are date-stamped, so they compare as // no build at all are all the 2023 client and get names. Builds are date-stamped, so
// strings. // they compare as strings.
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(NAMES) expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(PAIRS)
expect((await created('20230414')).PlayerEvent.Tags).toEqual(PAIRS) expect((await created('20230414')).PlayerEvent.Tags).toEqual(NAMES)
expect((await created('20220101')).PlayerEvent.Tags).toEqual(PAIRS) expect((await created('20220101')).PlayerEvent.Tags).toEqual(NAMES)
const legacy = await created() const legacy = await created()
expect(legacy.PlayerEvent.Tags).toEqual(PAIRS) expect(legacy.PlayerEvent.Tags).toEqual(NAMES)
// Only the inline field moves: TagModifyResult carries names to both builds. // Only the inline field moves: TagModifyResult carries names to both builds — it
// really is a List<string> on the client, so the pairs must never land there.
expect(legacy.TagModifyResult).toEqual({ Result: 0, Tags: NAMES }) expect(legacy.TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
expect((await created('20250718.01')).TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
// The gate is on the ENVELOPE, not on the create: the read and the field edits answer // The gate is on the ENVELOPE, not on the create: the read and the field edits answer
// the same shape, so a client that made an event and one opening it cold agree. // the same shape, so a client that made an event and one opening it cold agree.
@@ -8088,8 +8090,8 @@ describe('player events', () => {
}) })
).json()) as PlayerEventResult ).json()) as PlayerEventResult
).PlayerEvent.Tags ).PlayerEvent.Tags
expect(await read('20250718.01')).toEqual(NAMES) expect(await read('20250718.01')).toEqual(PAIRS)
expect(await read()).toEqual(PAIRS) expect(await read()).toEqual(NAMES)
}) })
test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => { test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => {
@@ -8682,7 +8684,7 @@ describe('player events', () => {
).toBe(401) ).toBe(401)
}) })
test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => { test('POST /api/playerevents/v1/bulkInvite adds invitees as Pending without overwriting answers', async () => {
const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) }) const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) })
const id = event.PlayerEventId const id = event.PlayerEventId
@@ -8698,8 +8700,9 @@ describe('player events', () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0) expect(body.Result).toBe(0)
// The creator plus the two newly invited — 43 keeps their decline, so isn't counted. // Only the creator: an invite is Pending, not Going, so the two newly invited don't
expect(body.PlayerEvent.AttendeeCount).toBe(3) // count until they answer — and 43 keeps their decline.
expect(body.PlayerEvent.AttendeeCount).toBe(1)
const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{ const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{
PlayerId: number PlayerId: number
@@ -8708,10 +8711,10 @@ describe('player events', () => {
expect( expect(
responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type]) responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type])
).toEqual([ ).toEqual([
[2, 0], [2, 3],
[42, 0], [42, 0],
[43, 2], [43, 2],
[187, 0], [187, 3],
]) ])
// Re-inviting is a no-op, not a reset: 43 still declines and the count holds. // Re-inviting is a no-op, not a reset: 43 still declines and the count holds.
@@ -8720,14 +8723,23 @@ describe('player events', () => {
{ PlayerEventId: id, InvitedPlayerIds: [187, 43] }, { PlayerEventId: id, InvitedPlayerIds: [187, 43] },
'42' '42'
) )
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3) expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
// An empty list is a no-op that still answers the event. // An empty list is a no-op that still answers the event.
const none = await post('/api/playerevents/v1/bulkInvite', { const none = await post('/api/playerevents/v1/bulkInvite', {
PlayerEventId: id, PlayerEventId: id,
InvitedPlayerIds: [], InvitedPlayerIds: [],
}) })
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3) expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
// Accepting the invite is an ordinary respond: Pending becomes Going and counts.
const accepted = await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
expect(((await accepted.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(2)
// Pending is the server's mark, not an answer a player can give.
expect(
(await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 3 }, '187')).status
).toBe(400)
}) })
test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => { test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => {
@@ -8745,17 +8757,57 @@ describe('player events', () => {
data: Record<string, Record<string, unknown>> data: Record<string, Record<string, unknown>>
}> }>
// One frame, to the one player who gained a row. 43 kept their answer, so nothing // ONE frame, to the one player who gained a row: the invitation Message. No
// PlayerEventResponseChanged — a Pending row is not a response; that frame is the
// responder's, sent by `respond` once they answer. 43 kept their answer, so nothing
// changed for them and nothing is pushed. // changed for them and nothing is pushed.
expect(sent).toHaveLength(1) expect(sent).toHaveLength(1)
expect(sent[0]!.playerId).toBe(2) expect(sent[0]!.playerId).toBe(2)
expect(sent[0]!.notificationType).toBe(2) // MessageReceived
// A PlayerEventInvitation (81) Message from the inviter, naming the event on
// PlayerEventId and as a string in Data.
expect(sent[0]!.data).toMatchObject({
FromPlayerId: 42,
ToPlayerId: 2,
Type: 81,
Data: String(id),
PlayerEventId: id,
})
// Stored under the same id, so an offline invitee finds it on login.
const frameId = (sent[0]!.data as unknown as { Id: number }).Id
expect(frameId).toBeGreaterThan(0)
const inbox = (await (
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('2') })
).json()) as Array<{ Id: number; Type: number; PlayerEventId: number | null }>
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ Type: 81, PlayerEventId: id })
})
test('POST /api/playerevents/v1/respond pushes PlayerEventResponseChanged to the responder', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'RSVP Frames', StartTime: at(HOUR) })
const id = event.PlayerEventId
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [2] })
// The invitee accepts: THIS is where their response frame comes from — the frame
// that populates the event on their client — carrying their row as it now stands.
await hub.fetch('http://do/all', { method: 'DELETE' })
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, Record<string, unknown>>
}>
expect(sent).toHaveLength(1)
expect(sent[0]!.playerId).toBe(2)
expect(sent[0]!.notificationType).toBe(83) // PlayerEventResponseChanged expect(sent[0]!.notificationType).toBe(83) // PlayerEventResponseChanged
// BOTH nested objects are present — the client dereferences them without a null // BOTH nested objects are present — the client dereferences them without a null
// guard, so a missing one is a NullReferenceException rather than a blank field. // guard, so a missing one is a NullReferenceException rather than a blank field.
// The event carries the count as recomputed AFTER the answer.
expect(sent[0]!.data.PlayerEvent).toMatchObject({ expect(sent[0]!.data.PlayerEvent).toMatchObject({
playerEventId: id, playerEventId: id,
name: 'Invite Frames', name: 'RSVP Frames',
attendeeCount: 2, attendeeCount: 2,
}) })
expect(sent[0]!.data.PlayerEventResponse).toEqual({ expect(sent[0]!.data.PlayerEventResponse).toEqual({
@@ -8765,6 +8817,106 @@ describe('player events', () => {
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/), CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
Type: 0, Type: 0,
}) })
// Changing the answer pushes again, with the same row id and the new Type — the
// upsert updates in place, so the frame's id matches what the RSVP list serves.
await hub.fetch('http://do/all', { method: 'DELETE' })
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '2')
const again = (await (await hub.fetch('http://do/all')).json()) as typeof sent
expect(again).toHaveLength(1)
expect(again[0]!.data.PlayerEventResponse).toMatchObject({
PlayerEventResponseId: sent[0]!.data.PlayerEventResponse!.PlayerEventResponseId,
Type: 2,
})
expect(again[0]!.data.PlayerEvent).toMatchObject({ attendeeCount: 1 })
})
test('a deleted event’s id is never reused by a later create', async () => {
// Ids used to be MAX(id) + 1 over the table, so deleting the newest event handed its
// id to the next one created — which then inherited everything still filed under
// that id outside the event's own rows (an invitation message, the client's cache).
const first = await create({ RoomId: 3, Name: 'First', StartTime: at(HOUR) })
expect(
(await post(`/api/playerevents/v2/delete/${first.PlayerEventId}`, undefined)).status
).toBe(200)
const second = await create({ RoomId: 3, Name: 'Second', StartTime: at(HOUR) })
expect(second.PlayerEventId).toBeGreaterThan(first.PlayerEventId)
// And the replacement starts with its own guest list: just its creator.
const responses = (await (
await get(`/api/playerevents/v1/${second.PlayerEventId}/responses`)
).json()) as Array<{ PlayerId: number }>
expect(responses.map((r) => r.PlayerId)).toEqual([42])
})
test('editing an event pushes PlayerEventUpdated to everyone Going, and only them', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'Before', StartTime: at(HOUR) })
const id = event.PlayerEventId
// 2 is Going, 43 is Interested, 44 declined, 45 is invited and still Pending.
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '44')
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
const frames = async () =>
(await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, unknown>
}>
// The whole-event update: the creator (Going from create) and 2 hear about it, with
// the event as it now stands in the camelCase projection PlayerEventCreated uses.
await hub.fetch('http://do/all', { method: 'DELETE' })
const res = await post(`/api/playerevents/v2/${id}`, { Name: 'After' })
expect(res.status).toBe(200)
const sent = await frames()
expect(sent.map((s) => [s.playerId, s.notificationType])).toEqual([
[2, 81], // PlayerEventUpdated
[42, 81],
])
for (const frame of sent) {
expect(frame.data).toMatchObject({ playerEventId: id, name: 'After', attendeeCount: 2 })
}
// A single-field edit pushes the same frame to the same audience.
await hub.fetch('http://do/all', { method: 'DELETE' })
const renamed = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2/${id}/name`, {
method: 'PUT',
headers: { ...(await bearer('42')), 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name: 'Renamed' }),
})
expect(renamed.status).toBe(200)
const again = await frames()
expect(again.map((s) => [s.playerId, s.notificationType])).toEqual([
[2, 81],
[42, 81],
])
expect(again[0]!.data).toMatchObject({ name: 'Renamed' })
})
test('deleting an event pushes PlayerEventDeleted to everyone who was Going, and only them', async () => {
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const event = await create({ RoomId: 3, Name: 'Doomed', StartTime: at(HOUR) })
const id = event.PlayerEventId
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
await hub.fetch('http://do/all', { method: 'DELETE' })
const res = await post(`/api/playerevents/v2/delete/${id}`, undefined)
expect(res.status).toBe(200)
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
playerId: number
notificationType: number
data: Record<string, unknown>
}>
// The audience is read before the RSVP rows are deleted with the event; the frame
// is just the id, there being no event left to carry.
expect(sent).toEqual([
{ playerId: 2, notificationType: 82, data: { PlayerEventId: id } }, // PlayerEventDeleted
{ playerId: 42, notificationType: 82, data: { PlayerEventId: id } },
])
}) })
test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => { test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => {
@@ -9031,11 +9183,9 @@ describe('player events', () => {
const path = `/api/playerevents/v2/${event.PlayerEventId}/tags` const path = `/api/playerevents/v2/${event.PlayerEventId}/tags`
// A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes. // A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes.
// Names, since this token names no build (a newer build would get the pairs).
const tagged = await edited(await putJson(path, ['tag1', '#Class'])) const tagged = await edited(await putJson(path, ['tag1', '#Class']))
expect(tagged.Tags).toEqual([ expect(tagged.Tags).toEqual(['class', 'tag1'])
{ Tag: 'class', Type: 0 },
{ Tag: 'tag1', Type: 0 },
])
// The envelope's TagModifyResult reports the same set the client redraws chips from. // The envelope's TagModifyResult reports the same set the client redraws chips from.
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] }) expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })