/** * 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 and * 0007_event_attendee.sql, applied under its own `migrations_table` so they don'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. * * RSVPs live alongside in `event_attendee`, one row per player per event. That one is * genuinely columnar (like the relationship/report tables), so it's a normal * relational table rather than a JSON blob. */ import { glyphLength, MAX_EVENT_DESCRIPTION_LENGTH, MAX_EVENT_DURATION_MS, MAX_EVENT_NAME_LENGTH, } from '@repo/domain' /** * Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any * 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_room ON event (room_id)`, `CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`, `CREATE TABLE IF NOT EXISTS event_attendee ( event_id INTEGER NOT NULL, player_id INTEGER NOT NULL, status INTEGER NOT NULL, responded_at TEXT NOT NULL, PRIMARY KEY (event_id, player_id) )`, `CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`, `CREATE TABLE IF NOT EXISTS event_tag ( event_id INTEGER NOT NULL, tag TEXT NOT NULL, type INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (event_id, tag) )`, `CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`, ] /** * How a player answered an event invitation — the `Type` on * `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`. * * Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and * declining is recorded rather than deleted so the client can show the player their own * answer (and so changing your mind is an update, not an insert). */ export const EVENT_RESPONSE = { going: 0, interested: 1, cantGo: 2, } as const /** The response types, for validating an incoming `Type`. */ const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE) /** Whether a number is one of the three response types. */ export function isEventResponseType(value: number): boolean { return EVENT_RESPONSE_VALUES.includes(value) } /** * One player's answer to one event. * * `id` is the row's SQLite `rowid` — the table has a composite primary key, so it's a * rowid table and the implicit id is free. It's what the RSVP list serves as * `PlayerEventResponseId`, and it's stable: a changed answer is an UPDATE through the * composite key (same rowid), and nothing ever deletes an RSVP row. */ export interface EventAttendeeRow { id: number event_id: number player_id: number status: number responded_at: string } /** * One RSVP as `GET /api/playerevents/v1/:eventId/responses` serves it — the PascalCase * projection of an `event_attendee` row. * * `CreatedAt` is the stored `responded_at`, so it's the time of the answer CURRENTLY * recorded, not of the player's first one: changing your mind updates the row in place * (one row per player per event), and the client shows the answer that stands. */ export interface PlayerEventResponse { PlayerEventResponseId: number PlayerEventId: number PlayerId: number CreatedAt: string Type: number } /** Project an RSVP row into the response the RSVP list serves. */ export function toEventResponse(row: EventAttendeeRow): PlayerEventResponse { return { PlayerEventResponseId: row.id, PlayerEventId: row.event_id, PlayerId: row.player_id, CreatedAt: row.responded_at, Type: row.status, } } /** * One tag on an event — the categories the browse screen's filter chips name * (`workshops`, `meetup`, …). `tag` is stored and matched lowercased; `type` is the * client's tag-category int, echoed back as sent (its enum isn't reversed yet). * * Tags live in their own table, NOT on the event blob: the blob is the DTO every read * serves verbatim, and tags surface only behind `includeDetails=True`. */ export interface EventTag { tag: string type: number } /** * 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 } /** * A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored * `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one * tag, and the client parses each in exactly one place. */ export interface PlayerEventEnvelopeTag { Tag: string Type: number } /** * The event as the `v2` envelope carries it: {@link PlayerEventBase} plus `Tags`. Defined * on top of the base rather than beside it, so the feed and the envelope cannot drift * apart on the fields they share. * * `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room * changed it under the same unversioned path rather than minting a `v3`: the 2023 build * parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either * one to the other build leaves the event's tag chips empty — the decoder drops what it * can't read rather than erroring. {@link toEventResult} picks; nothing else should. */ export interface PlayerEventEnvelope extends PlayerEventBase { Tags: string[] | PlayerEventEnvelopeTag[] } /** * The envelope the `v2` routes answer with — the event nested under a status, rather than * the bare record the `v1` reads serve. `Result` is 0 on success. * * `TagModifyResult` reports the tag edit that rides along with a write: its `Result` is 0 * and its `Tags` echo the tags the event now carries, which is what the client redraws its * tag chips from. It is an OBJECT — it used to be served as null, back when no event tags * were stored. */ export interface PlayerEventResult { PlayerEvent: PlayerEventEnvelope Result: number TagModifyResult: { Result: number; Tags: string[] } } /** * Wrap a stored event and its tags in the `v2` envelope. `tags` are the event's stored * tags — pass what `getEventTags` returns, so the answer reflects what was actually * written rather than what was asked for. * * `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see * {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It * changes nothing else — `TagModifyResult.Tags` is a name list to both builds. */ export function toEventResult( event: PlayerEvent, tags: EventTag[] = [], legacyTags = false ): PlayerEventResult { const names = tags.map((t) => t.tag) const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names return { PlayerEvent: { Tags: carried, ...toEventBase(event) }, Result: 0, TagModifyResult: { Result: 0, Tags: names }, } } /** * The envelope a DELETE answers with. Both payload fields are null: the reference reports * only that the delete happened, and the client reads nothing but `Result` — there is no * event left to redraw. Deliberately NOT {@link toEventResult}'s shape, even though both * are the v2 envelope. */ export interface PlayerEventDeletedResult { PlayerEvent: null Result: number TagModifyResult: null } /** The one value {@link PlayerEventDeletedResult} ever takes: a successful delete. */ export const EVENT_DELETED_RESULT: PlayerEventDeletedResult = { PlayerEvent: null, Result: 0, TagModifyResult: null, } /** * 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 } /** * The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`) * serves, and the same thing the v2 envelope carries once `Tags` is added. PascalCase like * the stored record, but not identical to it — don't unify them: * * - it drops `State`, which neither the feed nor the envelope carries; * - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing * broadcasts an event yet, so it is always null); * - its `ImageName` is a string: an event with no image reads `""`, where the record holds * null. * * The by-id / bulk / search reads serve the stored RECORD verbatim instead, `State` and * nullable `ImageName` included. Two shapes; keep them apart. */ export interface PlayerEventBase extends Omit { ImageName: string BroadcastingRoomInstanceId: number | null } /** Project a stored event into the base shape the feed serves and the envelope wraps. */ export function toEventBase(event: PlayerEvent): PlayerEventBase { const { State: _State, ...rest } = event return { ...rest, ImageName: event.ImageName ?? '', BroadcastingRoomInstanceId: 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`. * * `tags` are passed in rather than read from the event: they live in their own table, * and the callers that have them already looked them up. */ export function toEventNotification( event: PlayerEvent, tags: EventTag[] = [] ): 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') } /** * Normalize one posted timestamp into the stored form, or undefined when it isn't a * usable date. * * Exported for the single-field time edit (`PUT …/v2/{id}/time`), which has to tell an * ABSENT bound — leave the stored one alone — from an unusable one, which it refuses. * {@link parseEventBody} collapses the two, since a create/update posting rubbish for a * time is better off defaulting than failing. */ export function parseEventTime(raw: unknown): string | undefined { if (typeof raw !== 'string') return undefined const parsed = Date.parse(raw) return Number.isNaN(parsed) ? undefined : eventTime(parsed) } /** An event's tags, alphabetical so a list read is stable. */ export async function getEventTags(db: D1Database, eventId: number): Promise { const { results } = await db .prepare('SELECT tag, type FROM event_tag WHERE event_id = ?1 ORDER BY tag') .bind(eventId) .all() return results } /** * Replace an event's tags with the given set — the tag edit that rides along with a * create or update. A replace, not a merge: the client posts the whole set it wants, * so an untagging is a post with the tag left out. */ export async function setEventTags( db: D1Database, eventId: number, tags: EventTag[] ): Promise { const statements = [db.prepare('DELETE FROM event_tag WHERE event_id = ?1').bind(eventId)] for (const { tag, type } of tags) { statements.push( db .prepare( `INSERT INTO event_tag (event_id, tag, type) VALUES (?1, ?2, ?3) ON CONFLICT (event_id, tag) DO UPDATE SET type = ?3` ) .bind(eventId, tag, type) ) } await db.batch(statements) } /** * 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 { /** The whole tag set to store; absent leaves the event's tags alone. */ tags?: EventTag[] 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 } /** * The window a write would end up storing, resolved the way {@link createEvent} and * {@link updateEvent} resolve it: a bound the body carries wins, otherwise the stored one * (an edit), otherwise the create defaults — now, and an hour later. * * Exists so the duration rule below and the writes themselves can't drift apart on what * "the event's window" means for a body that moves only one bound. */ function resolvedWindow( input: EventInput, existing?: PlayerEvent, now = Date.now() ): { start: number; end: number } { const start = Date.parse(input.startTime ?? existing?.StartTime ?? eventTime(now)) const stored = input.endTime ?? existing?.EndTime return { start, end: stored === undefined ? start + DEFAULT_DURATION_MS : Date.parse(stored) } } /** * Why a parsed event body can't be stored, or `null` when it's fine. * * Two rules: the stored lengths, and the window. * * Lengths are a cap, not a charset — an event name is a title, not an identifier * ("Building a Better Room Using Trigonometry" is a real one), so the alphanumeric rule * the account and room names carry would be wrong here. Absent fields are skipped: an * update posts only what it changes, and create defaults a missing name rather than * refusing it. The name is measured AFTER trimming, matching what the writes store. * * The window is checked on what the write RESOLVES to rather than on the fields the body * carries, which is why `existing` is passed for an edit: moving the start alone still * has to leave a window that ends after it and runs no longer than * {@link MAX_EVENT_DURATION_MS}. A create resolves against the same defaults * {@link createEvent} applies, so a body naming neither bound — or only a start — can * never fail this. * * A backwards window is refused here too. It isn't a duration rule as such, but it's the * hole in one: `end - start` on a window running a month backwards is negative, which * would sail past a "no longer than a day" check. */ export function eventInputRejection(input: EventInput, existing?: PlayerEvent): string | null { const name = input.name?.trim() if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) { return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.` } if ( input.description !== undefined && glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH ) { return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.` } const { start, end } = resolvedWindow(input, existing) // Unparseable can't happen from `parseEventBody` (it drops what it can't read) but can // from a stored blob edited by hand; skip the rule rather than refusing an edit that // says nothing about the times. if (Number.isNaN(start) || Number.isNaN(end)) return null if (end < start) return 'An event cannot end before it starts.' if (end - start > MAX_EVENT_DURATION_MS) { return `An event can run for at most ${MAX_EVENT_DURATION_MS / (60 * 60 * 1000)} hours.` } return null } /** * Read the `Tags` a create/update body carries, or undefined when it carries none (an * update that says nothing about tags leaves them alone; `[]` genuinely clears them). * * Both forms in circulation are accepted — a bare string (`"workshops"`) and the * `{ tag, type }` object the notification frame carries — since the browse chips are * plain names while the client's own event model pairs each with a category int. Tags * are lowercased (the search matches them lowercased, and `#Workshops` and `#workshops` * are the same chip), a leading `#` is stripped, and blanks/duplicates are dropped. */ export function parseEventTags(raw: unknown): EventTag[] | undefined { if (!Array.isArray(raw)) return undefined const byTag = new Map() for (const entry of raw) { const source = (typeof entry === 'object' && entry !== null ? entry : {}) as Record< string, unknown > const name = typeof entry === 'string' ? entry : (source.tag ?? source.Tag) if (typeof name !== 'string') continue const tag = name.trim().replace(/^#/, '').toLowerCase() if (tag === '') continue byTag.set(tag, { tag, type: asInt(source.type ?? source.Type) ?? 0 }) } return [...byTag.values()] } /** * 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 => parseEventTime(obj[key]) 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 { tags: parseEventTags(obj.Tags ?? obj.tags), 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. `State` starts * at 0 (scheduled). The creator comes from the bearer token, never the body. * * The creator is recorded as Going in `event_attendee`, which is what makes * `AttendeeCount` start at 1: the count is derived from that table, so the creator * needs a row there for the number to stay right once other players respond. */ 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.batch([ db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)), db .prepare( `INSERT INTO event_attendee (event_id, player_id, status, responded_at) VALUES (?1, ?2, ?3, ?4)` ) .bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)), ]) // Tags ride along with the write but live in their own table — they are not part of // the stored blob, since that blob is the DTO every read serves verbatim. if (input.tags !== undefined) await setEventTags(db, event.PlayerEventId, input.tags) return event } /** * Record a player's answer to an event, replacing whatever they said before — one row * per player per event, so changing your mind is an update rather than a second RSVP. * The event's `AttendeeCount` is recomputed from the table afterwards. * * Returns the updated event, or null when there's no such event. Anyone who can see an * event may respond to it, the creator included (they're already Going from create, and * nothing stops them declining their own event). */ export async function setEventResponse( db: D1Database, eventId: number, playerId: number, status: number ): Promise { const event = await getEventById(db, eventId) if (event === null) return null await db .prepare( `INSERT INTO event_attendee (event_id, player_id, status, responded_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4` ) .bind(eventId, playerId, status, eventTime(Date.now())) .run() const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) } await writeEvent(db, updated) return updated } /** * Add invited players to an event as Going — the bulk invite. Returns the updated * event (with its recounted `AttendeeCount`) and the rows actually created, or null * when there's no such event. * * An invite only ever INSERTS: a player who already has a row keeps the answer they * gave, so being invited can't flip a decline back to Going, and re-inviting the same * player is a no-op rather than a reset. Since the rows land as Going, the invited * count toward `AttendeeCount` from the moment they're invited — see the route. * * `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict * inserts nothing and returns nothing. That's what the route notifies on — a player * whose existing answer was left alone gets no frame, because nothing changed for them. * * Ids are deduplicated by the composite primary key; an empty list is a no-op that * still returns the event. */ export async function inviteToEvent( db: D1Database, eventId: number, playerIds: number[] ): Promise<{ event: PlayerEvent; added: EventAttendeeRow[] } | null> { const event = await getEventById(db, eventId) if (event === null) return null if (playerIds.length === 0) return { event, added: [] } const at = eventTime(Date.now()) const inserts = await db.batch( playerIds.map((playerId) => db .prepare( `INSERT INTO event_attendee (event_id, player_id, status, responded_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (event_id, player_id) DO NOTHING RETURNING rowid AS id, *` ) .bind(eventId, playerId, EVENT_RESPONSE.going, at) ) ) const added = inserts.flatMap((r) => r.results) const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) } await writeEvent(db, updated) return { event: updated, added } } /** How many players said they're Going — an event's `AttendeeCount`. */ export async function countGoing(db: D1Database, eventId: number): Promise { const row = await db .prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2') .bind(eventId, EVENT_RESPONSE.going) .first<{ going: number }>() return row?.going ?? 0 } /** One player's answer to one event, or null when they haven't responded. */ export async function getEventResponse( db: D1Database, eventId: number, playerId: number ): Promise { return db .prepare('SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2') .bind(eventId, playerId) .first() } /** * Everyone who answered an event, in the order they responded — the guest list behind * `GET /api/playerevents/v1/:eventId/responses`. Ties on the timestamp (the creator's * own Going row shares its second with a fast first RSVP) break on the player id, so * the order is stable. */ export async function getEventAttendees( db: D1Database, eventId: number ): Promise { const { results } = await db .prepare( `SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id` ) .bind(eventId) .all() return results } /** 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) // A body that says nothing about tags leaves them alone, like every other field // here; an explicit `[]` clears them. if (input.tags !== undefined) await setEventTags(db, eventId, input.tags) return updated } /** * Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags * (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the * attendee counts and the `#tag` search would still find. Event ids are assigned in * sequence and never reused, but orphan rows would still be counted against whatever id * they name. * * Answers the event as it was, so the caller can report what it deleted; `null` when there * was no such event. */ export async function deleteEvent(db: D1Database, eventId: number): Promise { const event = await getEventById(db, eventId) if (event === null) return null await db.batch([ db.prepare('DELETE FROM event_attendee WHERE event_id = ?1').bind(eventId), db.prepare('DELETE FROM event_tag WHERE event_id = ?1').bind(eventId), db.prepare('DELETE FROM event WHERE id = ?1').bind(eventId), ]) return event } /** 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) } /** * A room's events — what is happening in this room and what is coming up, soonest first. * Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`). * * FINISHED events are left out, like the browse feed's: this answers "what can I still turn * up to in this room", and an event that ended last month is not that. Running events count * as current — the filter is on the END time, so an event stays listed until it is over * rather than disappearing the moment it starts. * * Selected on the indexed room_id column, with the time bound in SQL too: end_time is a * generated column of an ISO-8601 UTC string, so it compares lexicographically. */ export async function getEventsByRoom( db: D1Database, roomId: number, now = Date.now() ): Promise { const { results } = await db .prepare('SELECT data FROM event WHERE room_id = ?1 AND end_time >= ?2') .bind(roomId, eventTime(now)) .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. Term by term, an empty * query browsing everything upcoming; paginated via skip/take, soonest first. * * A term is matched one of two ways, and the `#` decides which: * * - `#workshops` is a TAG term — it matches only an event tagged `workshops`, and never * the word appearing in a name or description. That's what the browse screen's filter * chips send. * - `workshops` is a TEXT term, matched case-insensitively against the name and the * description, as before. * * Every term has to match, and the two kinds combine: `#workshops trigonometry` is the * workshops-tagged events whose text also mentions trigonometry. * * 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 { const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean) // A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#` // is dropped rather than treated as a tag nothing can carry. const tags = terms.filter((t) => t.startsWith('#')).map((t) => t.slice(1)) const textTerms = terms.filter((t) => !t.startsWith('#')) // end_time is a generated column of an ISO-8601 UTC string, so it compares // lexicographically — that filter stays in SQL, and so does the tag one: an event // has to carry EVERY tag asked for, which is the count of matching tag rows. const wanted = tags.filter(Boolean) const sql = wanted.length === 0 ? 'SELECT data FROM event WHERE end_time >= ?1' : `SELECT data FROM event WHERE end_time >= ?1 AND ( SELECT COUNT(DISTINCT tag) FROM event_tag WHERE event_tag.event_id = event.id AND tag IN (${wanted.map((_, i) => `?${i + 2}`).join(', ')}) ) = ${wanted.length}` const { results } = await db .prepare(sql) .bind(eventTime(Date.now()), ...wanted) .all() let events = results.map((r) => JSON.parse(r.data) as PlayerEvent) for (const term of textTerms) { events = events.filter( (e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term) ) } return events.sort(bySoonest).slice(skip, skip + take) }