(wip) events

This commit is contained in:
Devin Zuczek
2026-08-04 18:44:11 -04:00
parent 65611c15d8
commit 10eb89ac12
7 changed files with 1284 additions and 123 deletions
+25
View File
@@ -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);
+5 -3
View File
@@ -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<App>({ 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',
+463
View File
@@ -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<string, unknown>
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<PlayerEvent> {
// 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<void> {
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<PlayerEvent | null> {
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<PlayerEvent | null> {
const row = await db
.prepare('SELECT data FROM event WHERE id = ?1')
.bind(eventId)
.first<EventRow>()
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<PlayerEvent[]> {
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<EventRow>()
const byId = new Map<number, PlayerEvent>()
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<PlayerEvent[]> {
const { results } = await db
.prepare('SELECT data FROM event WHERE creator_player_id = ?1')
.bind(creatorPlayerId)
.all<EventRow>()
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<PlayerEvent[]> {
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<EventRow>()
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<PlayerEvent[]> {
const at = eventTime(now)
const { results } = await db
.prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1')
.bind(at)
.all<EventRow>()
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<PlayerEvent[]> {
// 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<EventRow>()
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)
}
+51 -2
View File
@@ -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 doesnt pin a subroom'),
ClubId: z.int().nullable().describe('Null when the event isnt a clubs'),
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 events 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 RSVPd to — always empty, no RSVP storage'),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
+345
View File
@@ -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<App>, event: PlayerEvent): Promise<void> {
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<App>({ strict: false })
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Events'],
summary: 'The callers player events',
description:
'Events the player created and events they have RSVPd 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 callers 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 clubs event ' +
'list is small enough to serve in one page.',
parameters: [idParam('clubId', 'Club id')],
responses: { 200: json(PlayerEventsPage, 'The clubs 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 cant 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<unknown>().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 cant 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 doesnt ' +
'transfer and RSVPs arent 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 events 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<unknown>().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)
}
)
+2 -84
View File
@@ -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<App>({ strict: false })
}),
(c) => c.json(communityBoard)
)
.get(
'/api/playerevents/v1/all',
describeRoute({
tags: ['Gameplay'],
summary: 'The callers player events',
description:
'Events the player created and events they have RSVPd 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({
+393 -34
View File
@@ -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<Response> =>
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<PlayerEvent> => {
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<Response> =>
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<string, unknown>
}
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<PlayerEvent[]> =>
(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 callers 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',