From 9bd3e1630f93040a20fab32c5e78e30b8224473e Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 23 Sep 2026 17:14:04 -0400 Subject: [PATCH] [api] fix multiple issues with events --- apps/api/migrations/0007_event_attendee.sql | 6 +- apps/api/migrations/0028_event_id_seq.sql | 27 ++ apps/api/src/events-db.ts | 136 +++++++--- apps/api/src/openapi.ts | 15 +- apps/api/src/routes/events.ts | 264 ++++++++++++++++---- apps/api/src/test/integration/api.test.ts | 206 ++++++++++++--- 6 files changed, 534 insertions(+), 120 deletions(-) create mode 100644 apps/api/migrations/0028_event_id_seq.sql diff --git a/apps/api/migrations/0007_event_attendee.sql b/apps/api/migrations/0007_event_attendee.sql index 55d3f08..e5c464e 100644 --- a/apps/api/migrations/0007_event_attendee.sql +++ b/apps/api/migrations/0007_event_attendee.sql @@ -4,9 +4,9 @@ -- 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 +-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go, 3 Pending (a bulk +-- invite's row for a player who hasn't answered yet). 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). -- diff --git a/apps/api/migrations/0028_event_id_seq.sql b/apps/api/migrations/0028_event_id_seq.sql new file mode 100644 index 0000000..47b91d2 --- /dev/null +++ b/apps/api/migrations/0028_event_id_seq.sql @@ -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; diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index 05381c2..3147d96 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -25,8 +25,8 @@ import { } from '@repo/domain' /** - * Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any - * seed rows). + * Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql + + * 0010_event_tag.sql + 0028_event_id_seq.sql, sans any seed rows). */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS event ( @@ -58,6 +58,12 @@ export const SCHEMA_DDL: string[] = [ PRIMARY KEY (event_id, 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 * 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). + * + * `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 = { going: 0, interested: 1, cantGo: 2, + pending: 3, } as const -/** The response types, for validating an incoming `Type`. */ -const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE) +/** The answers a player may give — every type but `pending`, for validating an incoming `Type`. */ +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 { - 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 - * `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one - * tag, and the client parses each in exactly one place. + * A tag as the `v2` envelope carries it to a build newer than 20230414: the PascalCase + * form of the stored `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — + * three casings of one tag, and the client parses each in exactly one place. */ export interface PlayerEventEnvelopeTag { 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 * 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 - * one to the other build leaves the event's tag chips empty — the decoder drops what it - * can't read rather than erroring. {@link toEventResult} picks; nothing else should. + * (20230414 and older) parses `["celebration"]` and anything newer parses + * `[{ Tag, Type }]`. Serving either one to the other build leaves the event's tag chips + * 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` to every build. */ export interface PlayerEventEnvelope extends PlayerEventBase { Tags: string[] | PlayerEventEnvelopeTag[] @@ -218,17 +236,18 @@ export interface PlayerEventResult { * tags — pass what `getEventTags` returns, so the answer reflects what was actually * written rather than what was asked for. * - * `legacyTags` 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 - * changes nothing else — `TagModifyResult.Tags` is a name list to both builds. + * `pairedTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see + * {@link PlayerEventEnvelope}): the `{ Tag, Type }` pairs of a build newer than 20230414 + * 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( event: PlayerEvent, tags: EventTag[] = [], - legacyTags = false + pairedTags = false ): PlayerEventResult { 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 { PlayerEvent: { Tags: carried, ...toEventBase(event) }, Result: 0, @@ -620,14 +639,11 @@ export async function createEvent( creatorPlayerId: number, input: EventInput ): Promise { - // Sequential id: one past the current max (the table starts empty). - const row = await db - .prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event') - .first<{ next: number }>() + const id = await nextEventId(db) const now = Date.now() const startTime = input.startTime ?? eventTime(now) const event: PlayerEvent = { - PlayerEventId: row?.next ?? 1, + PlayerEventId: id, CreatorPlayerId: creatorPlayerId, ImageName: input.imageName ?? null, 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. * 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). + * Returns the updated event and the player's response row as it now stands (the route + * pushes that row on the `PlayerEventResponseChanged` frame), 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 { +): Promise<{ event: PlayerEvent; response: EventAttendeeRow } | null> { const event = await getEventById(db, eventId) if (event === null) return null - await db + const response = 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` + 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())) - .run() + .first() const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) } 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 * when there's no such event. * * 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 - * player is a no-op rather than a reset. Since the rows land as Going, the invited - * count toward `AttendeeCount` from the moment they're invited — see the route. + * gave, so being invited can't reset an answer to Pending, and re-inviting the same + * player is a no-op. The rows land as Pending (3), not Going: the invitee hasn't + * 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 - * inserts nothing and returns nothing. That's what the route notifies on — a player - * whose existing answer was left alone gets no frame, because nothing changed for them. + * inserts nothing and returns nothing. That's who the route sends the invitation Message + * 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 * still returns the event. @@ -728,7 +749,7 @@ export async function inviteToEvent( ON CONFLICT (event_id, player_id) DO NOTHING 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) @@ -738,6 +759,22 @@ export async function inviteToEvent( 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 { + 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`. */ export async function countGoing(db: D1Database, eventId: number): Promise { const row = await db @@ -829,12 +866,31 @@ export async function updateEvent( 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 { + 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 * (`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 - * sequence and never reused, but orphan rows would still be counted against whatever id - * they name. + * attendee counts and the `#tag` search would still find. Event ids are drawn from a + * sequence that never reuses one (see {@link nextEventId}), so a deleted event's id is + * 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 * was no such event. diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 2c76c0c..8039c34 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1150,15 +1150,16 @@ export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: * image. * * `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 - * one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither - * is the lowercase `{ tag, type }` the v1 read serves. + * minting a new path, so a build newer than `20230414` gets the `{ Tag, Type }` pairs and + * that build, every older one, and any caller whose token names no build get the tag + * 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({ Tags: z .union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))]) .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' ), }) @@ -1176,7 +1177,7 @@ export const PlayerEventResultDto = z.object({ Result: z.int().describe('0 = success'), TagModifyResult: z.object({ 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 ' + '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. */ diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index fb4225a..78cefbe 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono' 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 { validateAndGetVersion } from '@repo/jwt' @@ -21,6 +21,7 @@ import { getEventsByIds, getEventsByRoom, getEventTags, + getGoingPlayerIds, getLiveEvents, inviteToEvent, isEventResponseType, @@ -70,7 +71,11 @@ import { import { createReport } from '../reports-db' 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 { 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 — - * what puts the event on their screen without a refetch, since an invite writes their - * response row for them. + * Push one frame to every player who is GOING to an event — the audience for a change + * to the event itself. Only Going rows (see {@link getGoingPlayerIds}): a player who is + * 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 - * existing answer changed nothing, so there is nothing to tell them about. + * `playerIds` is passed in rather than read here because a delete has to read them + * 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, + playerIds: number[], + type: NotificationType, + payload: Record, + playerEventId: number +): Promise { + 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, + event: PlayerEvent, + tags: EventTag[] +): Promise { + const payload: Record = { ...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, + playerIds: number[], + playerEventId: number +): Promise { + 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 * 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 * 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 - * rest: the invites are already stored by the time this runs. + * Hub failures are logged and swallowed: the answer is already stored by the time this + * runs. + */ +async function notifyResponseChanged( + c: Context, + event: PlayerEvent, + response: EventAttendeeRow +): Promise { + const payload = { + PlayerEvent: { ...toEventNotification(event) }, + PlayerEventResponse: { ...toEventResponse(response) }, + } satisfies PlayerEventResponsePayload + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + response.player_id, + NotificationType.PlayerEventResponseChanged, + payload + ) + } catch (err) { + logger.error('failed to push PlayerEventResponseChanged notification', { + playerEventId: event.PlayerEventId, + playerId: response.player_id, + 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, + inviterId: number, event: PlayerEvent, added: EventAttendeeRow[] ): Promise { - const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE) - const PlayerEvent = { ...toEventNotification(event) } for (const row of added) { - const payload = { - PlayerEvent, - PlayerEventResponse: { ...toEventResponse(row) }, - } satisfies PlayerEventResponsePayload - try { - await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload) - } catch (err) { - logger.error('failed to push PlayerEventResponseChanged notification', { - playerEventId: event.PlayerEventId, - playerId: row.player_id, - error: err instanceof Error ? err.message : String(err), - }) - } + 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, + fromPlayerId: number, + toPlayerId: number, + playerEventId: number +): Promise { + 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. * * 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 * 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 - * with no readable token version all get the 2023 shape. Builds are date-stamped, so they - * order as strings. + * `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request + * with no readable token version all get the 2023 names. Builds are date-stamped, so they + * 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 * build only empties its own tag chips. @@ -164,7 +312,7 @@ async function notifyInvited( async function eventResult(c: Context, event: PlayerEvent, tags: EventTag[]) { const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get()) 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, event: PlayerEvent, tags: EventTag[] * * 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. + * Like it, each pushes a `PlayerEventUpdated` to everyone Going. */ function editEventField( parse: (c: Context, event: PlayerEvent) => Promise @@ -199,7 +348,9 @@ function editEventField( if (input === null) return c.body(null, 400) const updated = await updateEvent(c.env.DB, eventId, input) // 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({ strict: false }) '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.', + 'The caller is also pushed a `PlayerEventResponseChanged` frame carrying the event ' + + '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, requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'), responses: { @@ -570,9 +726,10 @@ export const eventRoutes = new Hono({ strict: false }) // 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) - if (updated === null) return c.body(null, 404) - return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId))) + const result = await setEventResponse(c.env.DB, eventId, id, type) + if (result === null) return c.body(null, 404) + 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({ strict: false }) ) // 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( '/api/playerevents/v1/bulkInvite', describeRoute({ tags: ['Events'], summary: 'Invite players to an event', description: - 'Adds the invited players to the event as Going — the same `event_attendee` rows ' + - 'an RSVP writes, so an invited player shows up in `…/responses` and counts toward ' + - '`AttendeeCount` immediately, without having answered.\n\n' + + 'Adds the invited players to the event as Pending (`Type` 3) — the same ' + + '`event_attendee` rows an RSVP writes, so an invited player shows up in ' + + '`…/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 ' + - '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 ' + '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 ' + '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 ' + @@ -662,7 +829,7 @@ export const eventRoutes = new Hono({ strict: false }) security: AUTHED, requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'), 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)' }, 401: UNAUTHORIZED_RESPONSE, 403: { description: 'The caller is not on the event (empty body)' }, @@ -702,7 +869,7 @@ export const eventRoutes = new Hono({ strict: false }) ] const result = await inviteToEvent(c.env.DB, eventId, invited) // 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))) } ) @@ -781,6 +948,9 @@ export const eventRoutes = new Hono({ strict: false }) '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 ' + '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 ' + '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`. ' + @@ -804,7 +974,10 @@ export const eventRoutes = new Hono({ strict: false }) if (existing === null) return c.body(null, 404) 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 notifyEventDeleted(c, going, eventId) // 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`. return c.json(EVENT_DELETED_RESULT) @@ -857,6 +1030,9 @@ export const eventRoutes = new Hono({ strict: false }) '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 ' + '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 ' + '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.', @@ -891,7 +1067,9 @@ export const eventRoutes = new Hono({ strict: false }) if (eventInputRejection(input, existing) !== null) return c.body(null, 400) const updated = await updateEvent(c.env.DB, eventId, input) // 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)) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index a1e198e..5abc501 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -8034,10 +8034,10 @@ describe('player events', () => { expect(body.Result).toBe(0) expect(body.PlayerEvent.Name).toBe('Enveloped') // 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 - // (PascalCase: not the lowercase pairs the v1 read's `tags` serves). TagModifyResult - // is names to every build. - expect(body.PlayerEvent.Tags).toEqual([{ Tag: 'music', Type: 0 }]) + // caller's build shape — this token names no build, so the 2023 bare names (a newer + // build gets the PascalCase `{ Tag, Type }` pairs). TagModifyResult is names to every + // build. + expect(body.PlayerEvent.Tags).toEqual(['music']) expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] }) // No `State`, and the broadcast instance is present and null. 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 () => { // 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. // 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. @@ -8066,16 +8066,18 @@ describe('player events', () => { return (await res.json()) as PlayerEventResult } - // Newer than 20230414 is the 2025 client; 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 - // strings. - expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(NAMES) - expect((await created('20230414')).PlayerEvent.Tags).toEqual(PAIRS) - expect((await created('20220101')).PlayerEvent.Tags).toEqual(PAIRS) + // Newer than 20230414 gets the pairs; that build, an older one, and a token naming + // no build at all are all the 2023 client and get names. Builds are date-stamped, so + // they compare as strings. + expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(PAIRS) + expect((await created('20230414')).PlayerEvent.Tags).toEqual(NAMES) + expect((await created('20220101')).PlayerEvent.Tags).toEqual(NAMES) const legacy = await created() - expect(legacy.PlayerEvent.Tags).toEqual(PAIRS) - // Only the inline field moves: TagModifyResult carries names to both builds. + expect(legacy.PlayerEvent.Tags).toEqual(NAMES) + // Only the inline field moves: TagModifyResult carries names to both builds — it + // really is a List on the client, so the pairs must never land there. 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 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 ).PlayerEvent.Tags - expect(await read('20250718.01')).toEqual(NAMES) - expect(await read()).toEqual(PAIRS) + expect(await read('20250718.01')).toEqual(PAIRS) + expect(await read()).toEqual(NAMES) }) test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => { @@ -8682,7 +8684,7 @@ describe('player events', () => { ).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 id = event.PlayerEventId @@ -8698,8 +8700,9 @@ describe('player events', () => { expect(res.status).toBe(200) const body = (await res.json()) as PlayerEventResult expect(body.Result).toBe(0) - // The creator plus the two newly invited — 43 keeps their decline, so isn't counted. - expect(body.PlayerEvent.AttendeeCount).toBe(3) + // Only the creator: an invite is Pending, not Going, so the two newly invited don't + // 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<{ PlayerId: number @@ -8708,10 +8711,10 @@ describe('player events', () => { expect( responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type]) ).toEqual([ - [2, 0], + [2, 3], [42, 0], [43, 2], - [187, 0], + [187, 3], ]) // 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] }, '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. const none = await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, 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 () => { @@ -8745,17 +8757,57 @@ describe('player events', () => { data: Record> }> - // 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. expect(sent).toHaveLength(1) 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> + }> + expect(sent).toHaveLength(1) + expect(sent[0]!.playerId).toBe(2) expect(sent[0]!.notificationType).toBe(83) // PlayerEventResponseChanged // 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. + // The event carries the count as recomputed AFTER the answer. expect(sent[0]!.data.PlayerEvent).toMatchObject({ playerEventId: id, - name: 'Invite Frames', + name: 'RSVP Frames', attendeeCount: 2, }) 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$/), 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 + }> + + // 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 + }> + // 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 () => { @@ -9031,11 +9183,9 @@ describe('player events', () => { const path = `/api/playerevents/v2/${event.PlayerEventId}/tags` // 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'])) - expect(tagged.Tags).toEqual([ - { Tag: 'class', Type: 0 }, - { Tag: 'tag1', Type: 0 }, - ]) + expect(tagged.Tags).toEqual(['class', 'tag1']) // The envelope's TagModifyResult reports the same set the client redraws chips from. const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })