mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
1008 lines
39 KiB
TypeScript
1008 lines
39 KiB
TypeScript
/**
|
|
* 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<PlayerEvent, 'State' | 'ImageName'> {
|
|
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<EventTag[]> {
|
|
const { results } = await db
|
|
.prepare('SELECT tag, type FROM event_tag WHERE event_id = ?1 ORDER BY tag')
|
|
.bind(eventId)
|
|
.all<EventTag>()
|
|
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<void> {
|
|
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<string, EventTag>()
|
|
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<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 => 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<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.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<PlayerEvent | null> {
|
|
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<EventAttendeeRow>(
|
|
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<number> {
|
|
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<EventAttendeeRow | null> {
|
|
return db
|
|
.prepare('SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
|
|
.bind(eventId, playerId)
|
|
.first<EventAttendeeRow>()
|
|
}
|
|
|
|
/**
|
|
* 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<EventAttendeeRow[]> {
|
|
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<EventAttendeeRow>()
|
|
return results
|
|
}
|
|
|
|
/** 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)
|
|
// 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<PlayerEvent | null> {
|
|
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<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)
|
|
}
|
|
|
|
/**
|
|
* 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<PlayerEvent[]> {
|
|
const { results } = await db
|
|
.prepare('SELECT data FROM event WHERE room_id = ?1 AND end_time >= ?2')
|
|
.bind(roomId, eventTime(now))
|
|
.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. 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<PlayerEvent[]> {
|
|
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<EventRow>()
|
|
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)
|
|
}
|