mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] fix multiple issues with events
This commit is contained in:
@@ -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).
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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'] })
|
||||||
|
|||||||
Reference in New Issue
Block a user