From 10eb89ac12882c5205affba90e5c9c32d83300e4 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 18:44:11 -0400 Subject: [PATCH] (wip) events --- apps/api/migrations/0006_event.sql | 25 ++ apps/api/src/api.app.ts | 8 +- apps/api/src/events-db.ts | 463 ++++++++++++++++++++++ apps/api/src/openapi.ts | 53 ++- apps/api/src/routes/events.ts | 345 ++++++++++++++++ apps/api/src/routes/gameplay.ts | 86 +--- apps/api/src/test/integration/api.test.ts | 427 ++++++++++++++++++-- 7 files changed, 1284 insertions(+), 123 deletions(-) create mode 100644 apps/api/migrations/0006_event.sql create mode 100644 apps/api/src/events-db.ts create mode 100644 apps/api/src/routes/events.ts diff --git a/apps/api/migrations/0006_event.sql b/apps/api/migrations/0006_event.sql new file mode 100644 index 0000000..51b950b --- /dev/null +++ b/apps/api/migrations/0006_event.sql @@ -0,0 +1,25 @@ +-- Player-event storage (scheduled events: a room, a window of time, and the +-- settings the event runs under). Like the image/invention/rooms/accounts tables +-- in this shared database, an event is a single JSON blob in the `data` column, +-- with queryable fields exposed as SQLite generated (virtual) columns extracted +-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts +-- (SCHEMA_DDL) — keep in sync. +-- +-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the +-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` / +-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the +-- browse query filters finished events in SQL on that. + +CREATE TABLE IF NOT EXISTS event ( + data TEXT NOT NULL, + id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL, + creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL, + room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL, + club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL, + start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL, + end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL + ); +CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id); +CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id); +CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id); +CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time); diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index e85c6a4..7ac786f 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -6,6 +6,7 @@ import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { avatarRoutes } from './routes/avatar' import { configRoutes } from './routes/config' +import { eventRoutes } from './routes/events' import { gameplayRoutes } from './routes/gameplay' import { imageRoutes } from './routes/images' import { inventoryRoutes } from './routes/inventory' @@ -48,6 +49,7 @@ const app = new Hono({ strict: false }) .route('/', progressionRoutes) .route('/', avatarRoutes) .route('/', gameplayRoutes) + .route('/', eventRoutes) .route('/', moderationRoutes) .route('/', inventoryRoutes) .route('/', roomRoutes) @@ -68,9 +70,9 @@ app.get( 'The catch-all Game API for recflare, a private-server reimplementation of the Rec', 'Room backend: everything the client calls that has not been split out into its own', 'worker yet. Today that is config, the friend graph, inventions, saved photos,', - 'reputation and the assorted sinks the client hits while loading. Relationships,', - 'inventions and images are D1-backed; several endpoints are still stubs, noted per', - 'route.', + 'player events, reputation and the assorted sinks the client hits while loading.', + 'Relationships, inventions, images and player events are D1-backed; several', + 'endpoints are still stubs, noted per route.', '', 'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,', 'equipment, consumables and objectives on `econ`) are already served there — the', diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts new file mode 100644 index 0000000..d79ac00 --- /dev/null +++ b/apps/api/src/events-db.ts @@ -0,0 +1,463 @@ +/** + * Player-event storage on the shared `recflare` D1 database. Each event is a single + * JSON blob in the `data` column; queryable fields (id, creator, club, start time) + * are SQLite generated (virtual) columns extracted from that JSON — the same + * JSON-blob pattern the image/invention/rooms/accounts tables use. + * + * The `api` worker owns this schema/migration (migrations/0006_event.sql, applied + * under its own `migrations_table` so it doesn't clash with the other workers' + * migrations on the shared database). + * + * The stored record IS the DTO: every read endpoint serves the blob verbatim, so the + * field set and casing here are exactly what the client parses. Timestamps are + * normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match. + */ + +/** Schema DDL (mirror of migrations/0006_event.sql, sans seed rows). */ +export const SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS event ( + data TEXT NOT NULL, + id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL, + creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL, + room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL, + club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL, + start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL, + end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`, + `CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`, + `CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`, + `CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`, +] + +/** + * A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and + * the settings the event runs under. Served verbatim by every read endpoint. + * + * `SubRoomId`/`ClubId`/`ImageName` are genuinely nullable: an event can name the room + * without pinning a subroom, needn't belong to a club, and has no banner until one is + * uploaded. The three `*Permissions`/`State`/`Accessibility` ints are stored as the + * client sends them — their enums aren't reversed yet, so nothing here interprets + * them beyond the defaults below. + */ +export interface PlayerEvent { + PlayerEventId: number + CreatorPlayerId: number + ImageName: string | null + RoomId: number + SubRoomId: number | null + ClubId: number | null + Name: string + Description: string + /** ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`). */ + StartTime: string + EndTime: string + AttendeeCount: number + State: number + Accessibility: number + IsMultiInstance: boolean + SupportMultiInstanceRoomChat: boolean + DefaultBroadcastPermissions: number + CanRequestBroadcastPermissions: number +} + +interface EventRow { + data: string +} + +/** + * The envelope the create/update writes answer with — the event nested under a status, + * rather than the bare record the read endpoints serve. `Result` is 0 on success. + * + * `TagModifyResult` is always null: the real API reports the outcome of the tag edit + * that rides along with the write, and we store no event tags (see the tag-filter + * chips, which are static). The field stays present because the client's parser + * expects it. + */ +export interface PlayerEventResult { + Result: number + TagModifyResult: null + PlayerEvent: PlayerEvent +} + +/** Wrap a stored event in the write envelope. */ +export function toEventResult(event: PlayerEvent): PlayerEventResult { + return { Result: 0, TagModifyResult: null, PlayerEvent: event } +} + +/** + * The projection of an event carried on a hub notification frame (`PlayerEventCreated` + * and its siblings). Deliberately NOT the stored record, in three ways — don't unify + * them: + * + * - it is camelCase, where the record and every read endpoint are PascalCase; + * - it carries `tags` and `broadcastingRoomInstanceId`, which the record has no fields + * for (no event tags are stored, and nothing broadcasts an event yet, so both are + * empty/null), and drops `State`; + * - its timestamps are padded to .NET tick precision (`…T19:00:00.0000000Z`) while the + * record stores them bare. That asymmetry is the reference server's: its notification + * frames carry the padded form and its event reads don't. + */ +export interface PlayerEventNotification { + tags: Array<{ tag: string; type: number }> + playerEventId: number + creatorPlayerId: number + roomId: number + subRoomId: number | null + clubId: number | null + name: string + description: string + imageName: string + startTime: string + endTime: string + attendeeCount: number + accessibility: number + isMultiInstance: boolean + supportMultiInstanceRoomChat: boolean + defaultBroadcastPermissions: number + canRequestBroadcastPermissions: number + broadcastingRoomInstanceId: number | null +} + +/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */ +function toTickPrecision(iso: string): string { + const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso) + if (match === null) return iso + return `${match[1]}.${(match[2] ?? '').padEnd(7, '0').slice(0, 7)}Z` +} + +/** + * Project a stored event into its notification frame. `imageName` becomes an empty + * string rather than null when the event has no banner: the frame carries `""`, and a + * null wouldn't survive the trip anyway — the hub drops null values from `Msg`. + */ +export function toEventNotification(event: PlayerEvent): PlayerEventNotification { + return { + tags: [], + playerEventId: event.PlayerEventId, + creatorPlayerId: event.CreatorPlayerId, + roomId: event.RoomId, + subRoomId: event.SubRoomId, + clubId: event.ClubId, + name: event.Name, + description: event.Description, + imageName: event.ImageName ?? '', + startTime: toTickPrecision(event.StartTime), + endTime: toTickPrecision(event.EndTime), + attendeeCount: event.AttendeeCount, + accessibility: event.Accessibility, + isMultiInstance: event.IsMultiInstance, + supportMultiInstanceRoomChat: event.SupportMultiInstanceRoomChat, + defaultBroadcastPermissions: event.DefaultBroadcastPermissions, + canRequestBroadcastPermissions: event.CanRequestBroadcastPermissions, + broadcastingRoomInstanceId: null, + } +} + +/** + * Normalize a timestamp to the form the client sends and reads back — + * `2020-11-29T22:00:00Z`, with no fractional seconds. `toISOString()` always emits + * milliseconds, which the samples never carry, so they're trimmed. + */ +function eventTime(ms: number): string { + return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z') +} + +/** + * Fields a create or update supplies, camelCased. Every one is optional: create + * defaults what's missing, and update leaves anything absent at its stored value — + * which is why the nullable ids are `number | null` rather than merely absent, so a + * posted `"ClubId": null` can genuinely clear a club. + */ +export interface EventInput { + imageName?: string | null + roomId?: number + subRoomId?: number | null + clubId?: number | null + name?: string + description?: string + startTime?: string + endTime?: string + state?: number + accessibility?: number + isMultiInstance?: boolean + supportMultiInstanceRoomChat?: boolean + defaultBroadcastPermissions?: number + canRequestBroadcastPermissions?: number +} + +/** Read a value as an integer, or undefined when absent / not a number. */ +function asInt(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value) + if (typeof value === 'string') { + const n = Number.parseInt(value, 10) + if (!Number.isNaN(n)) return n + } + return undefined +} + +/** + * Parse a posted event body into an {@link EventInput}. + * + * Accepts the event's fields either at the top level or nested under `PlayerEvent`: + * the client posts the same envelope it reads back, and both forms are in circulation. + * A field the body doesn't carry stays undefined (create defaults it, update keeps the + * stored value); an explicit `null` on one of the nullable ids is preserved so it can + * clear the value. Timestamps are normalized here, so an unparseable one is dropped + * rather than stored. + */ +export function parseEventBody(body: unknown): EventInput { + const outer = (typeof body === 'object' && body !== null ? body : {}) as Record + const nested = outer.PlayerEvent + const obj = (typeof nested === 'object' && nested !== null ? nested : outer) as Record< + string, + unknown + > + + const has = (key: string): boolean => Object.hasOwn(obj, key) + // A nullable id: absent leaves it alone, an explicit null clears it. + const nullableInt = (key: string): number | null | undefined => { + if (!has(key)) return undefined + return obj[key] === null ? null : asInt(obj[key]) + } + const time = (key: string): string | undefined => { + const raw = obj[key] + if (typeof raw !== 'string') return undefined + const parsed = Date.parse(raw) + return Number.isNaN(parsed) ? undefined : eventTime(parsed) + } + const bool = (key: string): boolean | undefined => { + const raw = obj[key] + if (typeof raw === 'boolean') return raw + if (raw === 'true') return true + if (raw === 'false') return false + return undefined + } + // The banner name: same absent/null distinction as the nullable ids. + const nullableString = (key: string): string | null | undefined => { + if (!has(key)) return undefined + if (obj[key] === null) return null + return typeof obj[key] === 'string' ? (obj[key] as string) : undefined + } + + return { + imageName: nullableString('ImageName'), + roomId: asInt(obj.RoomId), + subRoomId: nullableInt('SubRoomId'), + clubId: nullableInt('ClubId'), + name: typeof obj.Name === 'string' ? obj.Name : undefined, + description: typeof obj.Description === 'string' ? obj.Description : undefined, + startTime: time('StartTime'), + endTime: time('EndTime'), + state: asInt(obj.State), + accessibility: asInt(obj.Accessibility), + isMultiInstance: bool('IsMultiInstance'), + supportMultiInstanceRoomChat: bool('SupportMultiInstanceRoomChat'), + defaultBroadcastPermissions: asInt(obj.DefaultBroadcastPermissions), + canRequestBroadcastPermissions: asInt(obj.CanRequestBroadcastPermissions), + } +} + +/** How long an event runs when the body names a start but no end. */ +const DEFAULT_DURATION_MS = 60 * 60 * 1000 + +/** + * Insert a new event, returning the stored record. + * + * Lenient about what the body carries, like the other writes here: an event with no + * name or no time window is defaulted rather than rejected, because a rejection the + * client can't render is worse than a placeholder the creator can edit. `AttendeeCount` + * starts at 1 — the creator is attending their own event — and `State` at 0 + * (scheduled). The creator comes from the bearer token, never the body. + */ +export async function createEvent( + db: D1Database, + 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 now = Date.now() + const startTime = input.startTime ?? eventTime(now) + const event: PlayerEvent = { + PlayerEventId: row?.next ?? 1, + CreatorPlayerId: creatorPlayerId, + ImageName: input.imageName ?? null, + RoomId: input.roomId ?? 0, + SubRoomId: input.subRoomId ?? null, + ClubId: input.clubId ?? null, + Name: input.name?.trim() || 'Untitled Event', + Description: input.description ?? '', + StartTime: startTime, + EndTime: input.endTime ?? eventTime(Date.parse(startTime) + DEFAULT_DURATION_MS), + AttendeeCount: 1, + State: input.state ?? 0, + Accessibility: input.accessibility ?? 1, + IsMultiInstance: input.isMultiInstance ?? false, + SupportMultiInstanceRoomChat: input.supportMultiInstanceRoomChat ?? false, + DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0, + CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0, + } + await db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)).run() + return event +} + +/** Overwrite an event's stored blob in place. */ +async function writeEvent(db: D1Database, event: PlayerEvent): Promise { + await db + .prepare('UPDATE event SET data = ?1 WHERE id = ?2') + .bind(JSON.stringify(event), event.PlayerEventId) + .run() +} + +/** + * Apply an edit to an event. Only the fields the body carried change; everything else + * keeps its stored value, so a partial post can't blank out the rest of the event. + * The id, the creator and the attendee count are not editable — ownership doesn't + * transfer and RSVPs aren't set by hand. Returns the updated event, or null when + * there's no such row. + */ +export async function updateEvent( + db: D1Database, + eventId: number, + input: EventInput +): Promise { + const event = await getEventById(db, eventId) + if (event === null) return null + + const updated: PlayerEvent = { + ...event, + ImageName: input.imageName === undefined ? event.ImageName : input.imageName, + RoomId: input.roomId ?? event.RoomId, + SubRoomId: input.subRoomId === undefined ? event.SubRoomId : input.subRoomId, + ClubId: input.clubId === undefined ? event.ClubId : input.clubId, + Name: input.name?.trim() || event.Name, + Description: input.description ?? event.Description, + StartTime: input.startTime ?? event.StartTime, + EndTime: input.endTime ?? event.EndTime, + State: input.state ?? event.State, + Accessibility: input.accessibility ?? event.Accessibility, + IsMultiInstance: input.isMultiInstance ?? event.IsMultiInstance, + SupportMultiInstanceRoomChat: + input.supportMultiInstanceRoomChat ?? event.SupportMultiInstanceRoomChat, + DefaultBroadcastPermissions: + input.defaultBroadcastPermissions ?? event.DefaultBroadcastPermissions, + CanRequestBroadcastPermissions: + input.canRequestBroadcastPermissions ?? event.CanRequestBroadcastPermissions, + } + await writeEvent(db, updated) + return updated +} + +/** One event by id, or null when there's no such row. */ +export async function getEventById(db: D1Database, eventId: number): Promise { + const row = await db + .prepare('SELECT data FROM event WHERE id = ?1') + .bind(eventId) + .first() + return row ? (JSON.parse(row.data) as PlayerEvent) : null +} + +/** + * Several events by id — the bulk fetch. Answers in the order the ids were asked for + * (the client renders them in the order it requested), skipping ids with no row rather + * than leaving a hole. Duplicated ids resolve to the same event. + */ +export async function getEventsByIds(db: D1Database, ids: number[]): Promise { + if (ids.length === 0) return [] + const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ') + const { results } = await db + .prepare(`SELECT data FROM event WHERE id IN (${placeholders})`) + .bind(...ids) + .all() + const byId = new Map() + for (const r of results) { + const event = JSON.parse(r.data) as PlayerEvent + byId.set(event.PlayerEventId, event) + } + return ids.map((id) => byId.get(id)).filter((e): e is PlayerEvent => e !== undefined) +} + +/** + * The events a player created — their "my events" list, soonest first. Uses the + * creator_player_id index; the per-player set is small, so ordering is done in memory. + */ +export async function getEventsByCreator( + db: D1Database, + creatorPlayerId: number +): Promise { + const { results } = await db + .prepare('SELECT data FROM event WHERE creator_player_id = ?1') + .bind(creatorPlayerId) + .all() + return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest) +} + +/** + * The events belonging to a set of clubs — the events shelf on a club's page, soonest + * first. Selected on the indexed club_id column. An empty id list is an empty shelf + * rather than every event. + */ +export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise { + if (clubIds.length === 0) return [] + const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ') + const { results } = await db + .prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`) + .bind(...clubIds) + .all() + return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest) +} + +/** + * The events happening right now — started and not yet finished. Backs the "happening + * now" browse query. Both bounds compare lexicographically on the generated ISO-8601 + * columns, so the whole filter stays in SQL. + */ +export async function getLiveEvents(db: D1Database, now = Date.now()): Promise { + const at = eventTime(now) + const { results } = await db + .prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1') + .bind(at) + .all() + return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest) +} + +/** Soonest start first; ties broken by id so paging is stable. */ +function bySoonest(a: PlayerEvent, b: PlayerEvent): number { + return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId +} + +/** + * Event search — the browse query on the player-events screen. `query` is matched + * case-insensitively against the name and description, term by term; an empty query + * browses everything upcoming. Paginated via skip/take, soonest first. + * + * Events that have already finished are excluded: this backs a browse screen, where a + * name match on something that ended last month is noise. The per-event history a + * creator wants comes from `getEventsByCreator`, which keeps them. + */ +export async function searchEvents( + db: D1Database, + query: string, + skip: number, + take: number +): Promise { + // end_time is a generated column of an ISO-8601 UTC string, so it compares + // lexicographically — the filter stays in SQL. + const { results } = await db + .prepare('SELECT data FROM event WHERE end_time >= ?1') + .bind(eventTime(Date.now())) + .all() + let events = results.map((r) => JSON.parse(r.data) as PlayerEvent) + + const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean) + for (const term of terms) { + events = events.filter( + (e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term) + ) + } + + return events.sort(bySoonest).slice(skip, skip + take) +} diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 9889e4d..52de287 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -408,10 +408,59 @@ export const KeepsakeConfig = z.object({ SocialXpBoostEnabled: z.boolean(), }) +/** + * A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint + * serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and + * echoed as the client sends them; their enums aren't reversed yet. + */ +export const PlayerEventDto = z.object({ + PlayerEventId: z.int(), + CreatorPlayerId: z.int(), + ImageName: z.string().nullable().describe('Banner image; null until one is uploaded'), + RoomId: z.int(), + SubRoomId: z.int().nullable().describe('Null when the event doesn’t pin a subroom'), + ClubId: z.int().nullable().describe('Null when the event isn’t a club’s'), + Name: z.string(), + Description: z.string(), + StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'), + EndTime: z.string().describe('ISO 8601 UTC, seconds precision'), + AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'), + State: z.int().describe('0 = scheduled'), + Accessibility: z.int(), + IsMultiInstance: z.boolean(), + SupportMultiInstanceRoomChat: z.boolean(), + DefaultBroadcastPermissions: z.int(), + CanRequestBroadcastPermissions: z.int(), +}) + +/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */ +export const PlayerEventResultDto = z.object({ + Result: z.int().describe('0 = success'), + TagModifyResult: z + .null() + .describe('Always null — the write carries no tag edit, as no event tags are stored'), + PlayerEvent: PlayerEventDto, +}) + +/** + * The JSON body of an event create / update. Every field is optional: create defaults + * what's missing, update leaves anything absent at its stored value. The fields may be + * posted at the top level or nested under `PlayerEvent` — the client posts back the + * same envelope it read — and both forms are accepted. `PlayerEventId`, + * `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned + * here, the creator comes from the bearer token, and RSVPs aren't set by hand. + */ +export const PlayerEventRequest = PlayerEventDto.partial().extend({ + PlayerEvent: z + .unknown() + .optional() + .describe('The event’s fields, if nested rather than posted at the top level'), +}) + /** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */ export const PlayerEventsAll = z.object({ - Created: JsonArray, - Responses: JsonArray, + Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'), + Responses: JsonArray.describe('Events the caller RSVP’d to — always empty, no RSVP storage'), }) /** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */ diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts new file mode 100644 index 0000000..a321986 --- /dev/null +++ b/apps/api/src/routes/events.ts @@ -0,0 +1,345 @@ +import { Hono } from 'hono' +import { describeRoute } from 'hono-openapi' + +import { logger } from '@repo/hono-helpers' + +// The notification-type ids the hub carries (owned by the `notify` worker). Imported +// as a value — the enum has no runtime dependencies. +import { NotificationType } from '../../../notify/src/notification-types' +import { + createEvent, + getEventById, + getEventsByClubs, + getEventsByCreator, + getEventsByIds, + getLiveEvents, + parseEventBody, + searchEvents, + toEventNotification, + toEventResult, + updateEvent, +} from '../events-db' +import { authedId, queryIds, unauthorized } from '../http' +import { + AUTHED, + idParam, + intQuery, + json, + jsonBody, + pageParams, + PlayerEventDto, + PlayerEventRequest, + PlayerEventResultDto, + PlayerEventsAll, + PlayerEventsPage, + stringQuery, + TagFilters, + UNAUTHORIZED_RESPONSE, +} from '../openapi' + +import type { Context } from 'hono' +import type { App } from '../context' +import type { PlayerEvent } from '../events-db' + +/** The notifications hub is a single global DO instance (see the `notify` worker). */ +const HUB_INSTANCE = 'global' + +/** + * Push a `PlayerEventCreated` notification for a freshly scheduled event to its + * creator — what makes the event appear on their own screen without a refetch. + * + * Hub failures are logged and swallowed: the event is already stored, so a hub hiccup + * must not fail the create. Note the frame carries the camelCase + * {@link toEventNotification} projection, not the PascalCase record the response does. + */ +async function notifyEventCreated(c: Context, event: PlayerEvent): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + event.CreatorPlayerId, + NotificationType.PlayerEventCreated, + { ...toEventNotification(event) } + ) + } catch (err) { + logger.error('failed to push PlayerEventCreated notification', { + playerEventId: event.PlayerEventId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +/** + * Player events — scheduled events players and clubs host in a room. + * + * D1-backed (the `event` table, owned by this worker; see events-db.ts). The stored + * blob IS the DTO, so every read here serves it verbatim; only the create/update + * writes wrap it, in the `{ Result, TagModifyResult, PlayerEvent }` envelope. + * + * Watch the response shapes: the two club feeds deliberately differ (bare array for + * the multi-club form, paged envelope for the single-club one) and the client chokes + * if they're unified. + */ +export const eventRoutes = new Hono({ strict: false }) + .get( + '/api/playerevents/v1/all', + describeRoute({ + tags: ['Events'], + summary: 'The caller’s player events', + description: + 'Events the player created and events they have RSVP’d to. `Created` is served ' + + 'from the event table, soonest first. `Responses` is always empty — nothing ' + + 'records an RSVP yet.', + security: AUTHED, + responses: { + 200: json(PlayerEventsAll, 'The caller’s created events, and an empty RSVP list'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] }) + } + ) + + // The tag filter chips on the player-events browse screen. Static: these are the + // categories the client offers when creating an event, so the list doesn't depend on + // what's stored. `TrendingFilters` is null even in the reference — it needs + // recent-activity data we don't keep, and the client renders no trending row for null. + .get( + '/api/playerevents/v1/tagfilters', + describeRoute({ + tags: ['Events'], + summary: 'Player-event filter chips', + description: + 'The filter chips on the player-events browse screen — the event categories the ' + + 'client offers. Static: the same set regardless of what is stored. ' + + '`TrendingFilters` is null even in the reference (it needs recent-activity data), ' + + 'and the client renders no trending row for null.', + security: AUTHED, + responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json({ + PinnedFilters: [ + 'workshops', + 'celebration', + 'game', + 'meetup', + 'performance', + 'coop', + 'grandopening', + 'class', + 'competition', + ], + PopularFilters: [ + 'workshops', + 'celebration', + 'class', + 'coop', + 'competition', + 'game', + 'grandopening', + 'meetup', + 'performance', + ], + TrendingFilters: null, + }) + } + ) + + // Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's + // page. A bare array: the client deserializes this one as a list, and chokes on the + // `{ ContinuationToken, Events }` envelope the single-club form uses. + .get( + '/api/playerevents/v1/clubs', + describeRoute({ + tags: ['Events'], + summary: 'Player events across several clubs', + description: + 'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' + + 'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' + + 'paged envelope the single-club form below uses. Do not unify the two. No ids ' + + 'means an empty shelf, not every event.', + parameters: [intQuery('id', 'Repeatable club id')], + responses: { 200: json(PlayerEventDto.array(), 'The clubs’ events') }, + }), + async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c))) + ) + + // The same feed for a single club (`/club/1`) — the form the reference serves, + // which *does* wrap the events with a paging cursor (empty = no next page). + .get( + '/api/playerevents/v1/club/:clubId{[0-9]+}', + describeRoute({ + tags: ['Events'], + summary: 'Player events for one club', + description: + 'The same feed for a single club — and this form DOES wrap the events with a ' + + 'paging cursor, matching the reference. The cursor is always empty: a club’s event ' + + 'list is small enough to serve in one page.', + parameters: [idParam('clubId', 'Club id')], + responses: { 200: json(PlayerEventsPage, 'The club’s events, in a single page') }, + }), + async (c) => { + const clubId = Number.parseInt(c.req.param('clubId'), 10) + const events = await getEventsByClubs(c.env.DB, [clubId]) + return c.json({ ContinuationToken: '', Events: events }) + } + ) + + // Live player-event search (the "happening now" browse query) — events that have + // started and not yet finished. A bare array, like the multi-club feed. + .get( + '/api/playerevents/v1/searchlive', + describeRoute({ + tags: ['Events'], + summary: 'Live player events', + description: + 'The "happening now" row on the player-events browse screen: events that have ' + + 'started and not yet ended, soonest first. A bare array.', + responses: { 200: json(PlayerEventDto.array(), 'The events running right now') }, + }), + async (c) => c.json(await getLiveEvents(c.env.DB)) + ) + + // Event search — the browse query. Text is matched term by term against name and + // description; finished events are left out (this backs a browse screen). + .get( + '/api/playerevents/v1/search', + describeRoute({ + tags: ['Events'], + summary: 'Search player events', + description: + 'The browse query on the player-events screen. `query` is matched ' + + 'case-insensitively against the event name and description, term by term; an empty ' + + 'query browses everything upcoming. Events that have already finished are left ' + + 'out — a name match on something that ended last month is noise on a browse ' + + 'screen. Soonest first, paginated via skip/take. A bare array.', + parameters: [ + stringQuery('query', 'Search text; every term must match the name or description'), + ...pageParams(50), + ], + responses: { 200: json(PlayerEventDto.array(), 'The matching events') }, + }), + async (c) => { + const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0 + const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50 + return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take)) + } + ) + + // Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already + // holds. Answers in the order asked for; ids with no event are skipped. + .get( + '/api/playerevents/v1/bulk', + describeRoute({ + tags: ['Events'], + summary: 'Several player events by id', + description: + 'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' + + 'in the order the ids were asked for — the client renders them in request order — ' + + 'and skips ids with no event rather than leaving a hole, so the result may be ' + + 'shorter than the request. A bare array.', + parameters: [intQuery('id', 'Repeatable event id')], + responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') }, + }), + async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c))) + ) + + // Create. The creator comes from the bearer token, never the body — posting someone + // else's `CreatorPlayerId` doesn't make it theirs. + .post( + '/api/playerevents/v2', + describeRoute({ + tags: ['Events'], + summary: 'Create a player event', + description: + 'Schedules a new event. The creator is taken from the bearer token, never the ' + + 'body; the id is assigned here. Lenient about the rest, like the other writes ' + + 'here — a missing name becomes “Untitled Event” and a missing time window becomes ' + + 'an hour from now, rather than an error the client can’t render.\n\n' + + '`AttendeeCount` starts at 1 (the creator attends their own event) and `State` at ' + + '0. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT the bare ' + + 'event the read endpoints serve.\n\n' + + 'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' + + 'the event in its camelCase notification projection. A hub failure is logged and ' + + 'swallowed — the event is already stored by then.', + security: AUTHED, + requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'), + responses: { + 200: json(PlayerEventResultDto, 'The created event'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = await c.req.json().catch(() => ({})) + const event = await createEvent(c.env.DB, id, parseEventBody(body)) + await notifyEventCreated(c, event) + return c.json(toEventResult(event)) + } + ) + + // Update. Creator-only, and a partial body only changes what it carries. + .post( + '/api/playerevents/v2/:eventId{[0-9]+}', + describeRoute({ + tags: ['Events'], + summary: 'Update a player event', + description: + 'Edits an event the caller created. Only the fields the body carries change; ' + + 'everything else keeps its stored value, so a partial post can’t blank out the ' + + 'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' + + 'clear it.\n\n' + + '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.', + security: AUTHED, + parameters: [idParam('eventId', 'Event id')], + requestBody: jsonBody(PlayerEventRequest, 'The fields to change'), + responses: { + 200: json(PlayerEventResultDto, 'The updated event'), + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the event’s creator (empty body)' }, + 404: { description: 'No such event (empty body)' }, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const eventId = Number.parseInt(c.req.param('eventId'), 10) + const existing = await getEventById(c.env.DB, eventId) + if (existing === null) return c.body(null, 404) + if (existing.CreatorPlayerId !== id) return c.body(null, 403) + + const body = await c.req.json().catch(() => ({})) + const updated = await updateEvent(c.env.DB, eventId, parseEventBody(body)) + // updateEvent only returns null when the row vanished, which the read above rules out. + return c.json(toEventResult(updated!)) + } + ) + + // A single event. Registered last so the literal `/bulk` and `/search` paths above + // are matched first; the `[0-9]+` constraint keeps them apart regardless. + .get( + '/api/playerevents/v1/:eventId{[0-9]+}', + describeRoute({ + tags: ['Events'], + summary: 'One player event', + description: + 'A single event by id, served as the bare record — no envelope, unlike the ' + + 'create/update writes. 404 when there is no such event.', + parameters: [idParam('eventId', 'Event id')], + responses: { + 200: json(PlayerEventDto, 'The event'), + 404: { description: 'No such event (empty body)' }, + }, + }), + async (c) => { + const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10)) + return event === null ? c.body(null, 404) : c.json(event) + } + ) diff --git a/apps/api/src/routes/gameplay.ts b/apps/api/src/routes/gameplay.ts index d97f82a..8a0ebee 100644 --- a/apps/api/src/routes/gameplay.ts +++ b/apps/api/src/routes/gameplay.ts @@ -6,19 +6,15 @@ import communityBoard from '../../static/community-board.json' import { BareString, idParam, - intQuery, IsPureResponse, json, JsonArray, jsonBody, JsonObject, KeepsakeConfig, - PlayerEventsAll, - PlayerEventsPage, SanitizeRequest, stringParam, SubscriptionResponse, - TagFilters, } from '../openapi' import type { App } from '../context' @@ -128,86 +124,8 @@ export const gameplayRoutes = new Hono({ strict: false }) }), (c) => c.json(communityBoard) ) - .get( - '/api/playerevents/v1/all', - describeRoute({ - tags: ['Gameplay'], - summary: 'The caller’s player events', - description: - 'Events the player created and events they have RSVP’d to. No player-event ' + - 'storage yet, so both lists are empty.', - responses: { 200: json(PlayerEventsAll, 'Two empty lists') }, - }), - (c) => c.json({ Created: [], Responses: [] }) - ) - - // The tag filter chips on the player-events browse screen. Derived from the tags in - // use across events — we store no events, so there are no chips to offer. - // `TrendingFilters` is null even in the reference (it needs recent-activity data). - .get( - '/api/playerevents/v1/tagfilters', - describeRoute({ - tags: ['Gameplay'], - summary: 'Player-event filter chips', - description: - 'The filter chips on the player-events browse screen, derived from the tags in use ' + - 'across events. We store no events, so there are no chips to offer. ' + - '`TrendingFilters` is null even in the reference — it needs recent-activity data.', - responses: { 200: json(TagFilters, 'Empty chip lists') }, - }), - (c) => c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null }) - ) - - // Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's - // page. A bare array: the client deserializes this one as a list, and chokes on the - // `{ ContinuationToken, Events }` envelope the single-club form uses. No - // player-event storage yet, so the feed is empty. - .get( - '/api/playerevents/v1/clubs', - describeRoute({ - tags: ['Gameplay'], - summary: 'Player events across several clubs', - description: - 'The events shelf for a set of clubs (`?id=1&id=2`). This form returns a BARE ' + - 'ARRAY — the client deserializes it as a list and chokes on the paged envelope the ' + - 'single-club form below uses. Do not unify the two. No player-event storage yet, ' + - 'so the feed is empty.', - parameters: [intQuery('id', 'Repeatable club id')], - responses: { 200: json(JsonArray, 'An empty list') }, - }), - (c) => c.json([]) - ) - - // The same feed for a single club (`/club/1`) — the form the reference serves, - // which *does* wrap the events with a paging cursor (empty = no next page). - .get( - '/api/playerevents/v1/club/:clubId{[0-9]+}', - describeRoute({ - tags: ['Gameplay'], - summary: 'Player events for one club', - description: - 'The same feed for a single club — and this form DOES wrap the events with a ' + - 'paging cursor, matching the reference. An empty `ContinuationToken` means no next ' + - 'page.', - parameters: [idParam('clubId', 'Club id')], - responses: { 200: json(PlayerEventsPage, 'An empty page') }, - }), - (c) => c.json({ ContinuationToken: '', Events: [] }) - ) - // Live player-event search (the "happening now" browse query). No player-event - // storage yet, so there's nothing live to return — a bare empty array. - .get( - '/api/playerevents/v1/searchlive', - describeRoute({ - tags: ['Gameplay'], - summary: 'Search live player events', - description: - 'The "happening now" search on the player-events browse screen. No player-event ' + - 'storage yet, so there are no live events — returns an empty list.', - responses: { 200: json(JsonArray, 'An empty list') }, - }), - (c) => c.json([]) - ) + // Player events live in their own controller (routes/events.ts) — they're D1-backed + // now, unlike the stubs around them here. .get( '/api/announcement/v1/get', describeRoute({ diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 74adb02..903e91d 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -11,6 +11,7 @@ import { import '../../api.app' +import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../events-db' import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db' import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db' @@ -18,6 +19,7 @@ import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../repor import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db' import type { Env } from '../../context' +import type { PlayerEvent, PlayerEventResult } from '../../events-db' import type { SavedImage } from '../../images-db' import type { InventionSaveResult, SavedInvention } from '../../inventions-db' @@ -89,6 +91,9 @@ beforeAll(async () => { // Warnings table (owned by the api worker) — moderator-issued warnings land here. for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + + // Player events table (owned by the api worker) — scheduled events live here. + for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -205,17 +210,6 @@ describe('public endpoints', () => { expect(reps.map((r) => r.AccountId)).toEqual([1, 2]) }) - test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => { - // No player-event storage → no tags in use → no chips. Trending is null. - const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - PinnedFilters: [], - PopularFilters: [], - TrendingFilters: null, - }) - }) - test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`) expect(res.status).toBe(200) @@ -225,26 +219,6 @@ describe('public endpoints', () => { expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' }) }) - test('GET /api/playerevents/v1/clubs returns an empty event list', async () => { - // The client deserializes this as a bare array — an envelope here fails with - // "expected:'[', actual:'{'". No player-event storage yet → empty. - const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`) - expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) - - // The single-club form does wrap its events with a paging cursor. - const one = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/club/1`) - expect(one.status).toBe(200) - expect(await one.json()).toEqual({ ContinuationToken: '', Events: [] }) - }) - - test('GET /api/playerevents/v1/searchlive returns an empty list', async () => { - // No player-event storage yet → nothing live to return. - const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/searchlive`) - expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) - }) - test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => { const res = await exports.default.fetch( `${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails` @@ -2252,13 +2226,393 @@ describe('mutual friends', () => { }) test('GET /api/relationships/mutualfriends is auth-gated', async () => { - const res = await exports.default.fetch( - `${ORIGIN}/api/relationships/mutualfriends?id=${OTHER}` - ) + const res = await exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends?id=${OTHER}`) expect(res.status).toBe(401) }) }) +describe('player events', () => { + const HOUR = 60 * 60 * 1000 + /** Seconds precision, no milliseconds — the form the client sends and reads back. */ + const at = (offsetMs: number): string => + new Date(Date.now() + offsetMs).toISOString().replace(/\.\d{3}Z$/, 'Z') + + const post = async (path: string, body: unknown, sub = '42'): Promise => + exports.default.fetch(`${ORIGIN}${path}`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + const create = async (body: unknown, sub = '42'): Promise => { + const res = await post('/api/playerevents/v2', body, sub) + expect(res.status).toBe(200) + return ((await res.json()) as PlayerEventResult).PlayerEvent + } + + const get = async (path: string, sub?: string): Promise => + exports.default.fetch(`${ORIGIN}${path}`, sub ? { headers: await bearer(sub) } : undefined) + + // The fixture set every test below reads. Times are relative to the run so the + // upcoming/live/finished distinction the browse queries make is real. + let upcoming: PlayerEvent + let clubEvent: PlayerEvent + let liveEvent: PlayerEvent + let pastEvent: PlayerEvent + + beforeAll(async () => { + // Posted nested under `PlayerEvent` — the envelope form the client sends back. + upcoming = await create({ + PlayerEvent: { + ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg', + RoomId: 10916706, + SubRoomId: 11195660, + ClubId: null, + Name: 'Building a Better Room Using Trigonometry', + Description: '', + StartTime: at(HOUR), + EndTime: at(2 * HOUR), + State: 0, + Accessibility: 1, + IsMultiInstance: false, + SupportMultiInstanceRoomChat: true, + DefaultBroadcastPermissions: 0, + CanRequestBroadcastPermissions: 0, + }, + }) + // …and this one at the top level, the other form in circulation. + clubEvent = await create({ + RoomId: 23570830, + ClubId: 7, + Name: 'DUNGEONS Escape ROOM', + Description: 'Try and escape the DUNGEONS with upto 4 players!', + StartTime: at(3 * HOUR), + EndTime: at(4 * HOUR), + CanRequestBroadcastPermissions: 2147483647, + }) + liveEvent = await create( + { RoomId: 3, ClubId: 7, Name: 'Live Jam', StartTime: at(-HOUR), EndTime: at(HOUR) }, + '43' + ) + pastEvent = await create({ + RoomId: 3, + Name: 'Trigonometry Retrospective', + StartTime: at(-3 * HOUR), + EndTime: at(-2 * HOUR), + }) + }) + + test('GET /api/playerevents/v1/tagfilters serves the event categories, auth-gated', async () => { + expect((await get('/api/playerevents/v1/tagfilters')).status).toBe(401) + + const res = await get('/api/playerevents/v1/tagfilters', '42') + expect(res.status).toBe(200) + // Static — the categories the client offers, not derived from stored events. + // Trending is null even in the reference: it needs recent-activity data. + expect(await res.json()).toEqual({ + PinnedFilters: [ + 'workshops', + 'celebration', + 'game', + 'meetup', + 'performance', + 'coop', + 'grandopening', + 'class', + 'competition', + ], + PopularFilters: [ + 'workshops', + 'celebration', + 'class', + 'coop', + 'competition', + 'game', + 'grandopening', + 'meetup', + 'performance', + ], + TrendingFilters: null, + }) + }) + + test('POST /api/playerevents/v2 creates an event, auth-gated', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2`, { + method: 'POST', + body: '{}', + }) + expect(res.status).toBe(401) + + // The stored record carries exactly the client's field set — nothing more. + expect(upcoming).toEqual({ + PlayerEventId: upcoming.PlayerEventId, + CreatorPlayerId: 42, + ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg', + RoomId: 10916706, + SubRoomId: 11195660, + ClubId: null, + Name: 'Building a Better Room Using Trigonometry', + Description: '', + StartTime: at(HOUR), + EndTime: at(2 * HOUR), + AttendeeCount: 1, + State: 0, + Accessibility: 1, + IsMultiInstance: false, + SupportMultiInstanceRoomChat: true, + DefaultBroadcastPermissions: 0, + CanRequestBroadcastPermissions: 0, + }) + // Timestamps come back at seconds precision, as the client sends them. + expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/) + }) + + test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => { + const res = await post('/api/playerevents/v2', { Name: 'Enveloped', RoomId: 3 }) + const body = (await res.json()) as PlayerEventResult + expect(body.Result).toBe(0) + // Always null: no event tags are stored, but the field has to be present. + expect(body.TagModifyResult).toBeNull() + expect(body.PlayerEvent.Name).toBe('Enveloped') + }) + + test('POST /api/playerevents/v2 pushes a PlayerEventCreated notification to the creator', async () => { + // The notify DO is stubbed to record its last notifyPlayer call (see vitest.config). + const event = await create({ + RoomId: 58, + Name: 'Open Mic', + Description: 'come hang', + StartTime: at(HOUR), + EndTime: at(3 * HOUR), + }) + const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last') + const last = (await res.json()) as { + playerId: number + notificationType: number + data: Record + } + expect(last.playerId).toBe(42) // the creator + expect(last.notificationType).toBe(80) // NotificationType.PlayerEventCreated + + // camelCase, unlike the PascalCase record the response carries; `tags` and + // `broadcastingRoomInstanceId` don't exist on the record, and `State` is dropped. + // The real hub strips the null values from the frame before it goes on the wire. + expect(last.data).toEqual({ + tags: [], + playerEventId: event.PlayerEventId, + creatorPlayerId: 42, + roomId: 58, + subRoomId: null, + clubId: null, + name: 'Open Mic', + description: 'come hang', + imageName: '', // empty string, not the record's null + startTime: `${event.StartTime.slice(0, -1)}.0000000Z`, + endTime: `${event.EndTime.slice(0, -1)}.0000000Z`, + attendeeCount: 1, + accessibility: 1, + isMultiInstance: false, + supportMultiInstanceRoomChat: false, + defaultBroadcastPermissions: 0, + canRequestBroadcastPermissions: 0, + broadcastingRoomInstanceId: null, + }) + // Tick precision on the frame; the stored record keeps its bare form. + expect(event.StartTime).toMatch(/:\d{2}Z$/) + }) + + test('POST /api/playerevents/v2 takes the creator from the token, not the body', async () => { + const event = await create({ Name: 'Not Yours', RoomId: 3, CreatorPlayerId: 999 }) + expect(event.CreatorPlayerId).toBe(42) + }) + + test('POST /api/playerevents/v2 defaults an empty body rather than rejecting it', async () => { + const event = await create({}) + expect(event).toMatchObject({ + Name: 'Untitled Event', + Description: '', + RoomId: 0, + SubRoomId: null, + ClubId: null, + ImageName: null, + AttendeeCount: 1, + State: 0, + Accessibility: 1, + IsMultiInstance: false, + SupportMultiInstanceRoomChat: false, + DefaultBroadcastPermissions: 0, + CanRequestBroadcastPermissions: 0, + }) + // A start with no end runs for an hour. + expect(Date.parse(event.EndTime) - Date.parse(event.StartTime)).toBe(HOUR) + }) + + test('GET /api/playerevents/v1/:eventId serves the bare event', async () => { + const res = await get(`/api/playerevents/v1/${upcoming.PlayerEventId}`) + expect(res.status).toBe(200) + // No envelope here — unlike the writes. + expect(await res.json()).toEqual(upcoming) + + expect((await get('/api/playerevents/v1/999999')).status).toBe(404) + }) + + test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => { + const res = await get( + `/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}` + ) + expect(res.status).toBe(200) + const events = (await res.json()) as PlayerEvent[] + // Request order, not id order — and the missing id leaves no hole. + expect(events.map((e) => e.PlayerEventId)).toEqual([ + clubEvent.PlayerEventId, + upcoming.PlayerEventId, + ]) + + // No ids is an empty list, not every event. + expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([]) + }) + + test('GET /api/playerevents/v1/search matches name and description, skipping finished events', async () => { + const search = async (qs: string): Promise => + (await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[] + + // Every term has to match, across name OR description. + expect((await search('?query=dungeons+escape')).map((e) => e.PlayerEventId)).toEqual([ + clubEvent.PlayerEventId, + ]) + // …matched case-insensitively, and against the description too. + expect((await search('?query=upto%204%20players')).map((e) => e.PlayerEventId)).toEqual([ + clubEvent.PlayerEventId, + ]) + + // `pastEvent` matches on name but has already ended, so the browse query drops it. + const trig = await search('?query=trigonometry') + expect(trig.map((e) => e.PlayerEventId)).toEqual([upcoming.PlayerEventId]) + expect(trig.map((e) => e.PlayerEventId)).not.toContain(pastEvent.PlayerEventId) + + // Soonest first, and take/skip page through that order. + const all = await search('') + const starts = all.map((e) => e.StartTime) + expect([...starts].sort()).toEqual(starts) + expect(await search('?take=1')).toEqual([all[0]]) + expect(await search('?skip=1&take=1')).toEqual([all[1]]) + }) + + test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => { + const res = await get('/api/playerevents/v1/searchlive') + expect(res.status).toBe(200) + const ids = ((await res.json()) as PlayerEvent[]).map((e) => e.PlayerEventId) + expect(ids).toContain(liveEvent.PlayerEventId) + // Started in an hour / finished already — neither is live. + expect(ids).not.toContain(upcoming.PlayerEventId) + expect(ids).not.toContain(pastEvent.PlayerEventId) + }) + + test('GET /api/playerevents/v1/clubs is a bare array; /club/:id is a paged envelope', async () => { + // The client deserializes the multi-club form as a list — an envelope here fails + // with "expected:'[', actual:'{'". Do not unify the two. + const many = await get('/api/playerevents/v1/clubs?id=7&id=8') + expect(many.status).toBe(200) + const events = (await many.json()) as PlayerEvent[] + expect(events.map((e) => e.PlayerEventId)).toEqual([ + liveEvent.PlayerEventId, // started an hour ago — soonest first + clubEvent.PlayerEventId, + ]) + + // The single-club form does wrap its events with a paging cursor. + const one = await get('/api/playerevents/v1/club/7') + expect(one.status).toBe(200) + expect(await one.json()).toEqual({ ContinuationToken: '', Events: events }) + + // A club with no events, and the no-ids case. + expect(await (await get('/api/playerevents/v1/club/8')).json()).toEqual({ + ContinuationToken: '', + Events: [], + }) + expect(await (await get('/api/playerevents/v1/clubs')).json()).toEqual([]) + }) + + test('GET /api/playerevents/v1/all lists the caller’s own events, auth-gated', async () => { + expect((await get('/api/playerevents/v1/all')).status).toBe(401) + + const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as { + Created: PlayerEvent[] + Responses: unknown[] + } + const ids = mine.Created.map((e) => e.PlayerEventId) + expect(ids).toContain(upcoming.PlayerEventId) + // 43 created that one, not 42. + expect(ids).not.toContain(liveEvent.PlayerEventId) + // Finished events stay in the creator's own list — only the browse queries drop them. + expect(ids).toContain(pastEvent.PlayerEventId) + // Nothing records an RSVP yet. + expect(mine.Responses).toEqual([]) + + const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as { + Created: PlayerEvent[] + } + expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId]) + }) + + test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => { + const event = await create({ + RoomId: 5, + SubRoomId: 6, + ClubId: 9, + Name: 'Original', + Description: 'Original description', + StartTime: at(5 * HOUR), + EndTime: at(6 * HOUR), + }) + const path = `/api/playerevents/v2/${event.PlayerEventId}` + + expect( + (await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status + ).toBe(401) + // 43 didn't create it. + expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403) + expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404) + + const res = await post(path, { Name: 'Renamed' }) + expect(res.status).toBe(200) + const body = (await res.json()) as PlayerEventResult + expect(body.Result).toBe(0) + // Only the name moved; a partial post can't blank out the rest. + expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' }) + + // And it stuck. + expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual( + body.PlayerEvent + ) + }) + + test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => { + const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' }) + const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, { + // Nested form again, and an explicit null — absent leaves the value alone, + // null genuinely clears it. + PlayerEvent: { ClubId: null, ImageName: null }, + }) + const updated = ((await res.json()) as PlayerEventResult).PlayerEvent + expect(updated.ClubId).toBeNull() + expect(updated.ImageName).toBeNull() + expect(updated.SubRoomId).toBe(6) + }) + + test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => { + const event = await create({ RoomId: 5, Name: 'Fixed' }) + const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, { + PlayerEventId: 424242, + CreatorPlayerId: 43, + AttendeeCount: 500, + }) + const updated = ((await res.json()) as PlayerEventResult).PlayerEvent + expect(updated.PlayerEventId).toBe(event.PlayerEventId) + expect(updated.CreatorPlayerId).toBe(42) + expect(updated.AttendeeCount).toBe(1) + }) +}) + describe('openapi', () => { test('GET /openapi.json documents every route', async () => { const res = await exports.default.fetch(`${ORIGIN}/openapi.json`) @@ -2330,10 +2684,13 @@ describe('openapi', () => { 'GET /api/playerReputation/v1/{id}', 'GET /api/playerReputation/v2/bulk', 'GET /api/playerevents/v1/all', + 'GET /api/playerevents/v1/bulk', 'GET /api/playerevents/v1/club/{clubId}', 'GET /api/playerevents/v1/clubs', + 'GET /api/playerevents/v1/search', 'GET /api/playerevents/v1/searchlive', 'GET /api/playerevents/v1/tagfilters', + 'GET /api/playerevents/v1/{eventId}', 'GET /api/players/v1/progression/{id}', 'GET /api/players/v2/progression/bulk', 'GET /api/quickPlay/v1/getandclear', @@ -2368,6 +2725,8 @@ describe('openapi', () => { 'POST /api/messages/v2/send', 'POST /api/playerReputation/v1/bulk', 'POST /api/playerReputation/v2/bulk', + 'POST /api/playerevents/v2', + 'POST /api/playerevents/v2/{eventId}', 'POST /api/players/v1/progression/bulk', 'POST /api/players/v2/progression/bulk', 'POST /api/playerwarnings',