[events] event tagging and reporting

This commit is contained in:
Devin Zuczek
2026-08-12 17:42:22 -04:00
parent 3aad586153
commit d129900762
7 changed files with 425 additions and 30 deletions
+25
View File
@@ -0,0 +1,25 @@
-- Player-event tags: the categories an event is filed under (`workshops`, `meetup`, …),
-- one row per tag per event. Owned by the `api` worker; generated from src/events-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- A separate table rather than a field on the event blob, for a reason that isn't
-- storage taste: the stored blob IS the event DTO every read serves verbatim, and the
-- event reads do NOT carry tags — they surface only behind
-- `GET /api/playerevents/v1/{id}?includeDetails=True`. Putting them in the blob would
-- leak a `Tags` key into every other read.
--
-- `tag` is stored lowercased and is the search key: `?query=%23workshops` (a `#`-prefixed
-- term) filters on this table, while a bare term still matches the name/description.
-- `type` is the client's tag-category int, echoed back as sent — its enum isn't reversed
-- yet, and nothing here interprets it.
--
-- The primary key is (event_id, tag): an event can't carry the same tag twice, and a tag
-- edit REPLACES the event's set rather than accumulating.
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);
+20
View File
@@ -0,0 +1,20 @@
-- Reporting a player EVENT (`POST /api/playerevents/v1/report`) reuses the report
-- table rather than getting one of its own: it is the same submission with the same
-- fields (category, free-text details, the reporter from the token) and the same
-- moderation life — a moderator acting on it sets `banned` on the row exactly as they
-- would for a player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in
-- sync.
--
-- `event_id` names the reported event; NULL on every ordinary player report, which is
-- what tells the two kinds apart. The row's other columns are still filled in from the
-- event: `reported_player_id` is its CREATOR (the person a moderator would act
-- against — the column is NOT NULL, and "who is answerable for this event" is the only
-- honest answer), and `room_id` the room it runs in, read from the event table so the
-- client doesn't have to send either.
--
-- Partial index: event reports are a small minority of rows, so indexing only the ones
-- that name an event keeps "reports against this event" off a full scan without paying
-- for the NULLs.
ALTER TABLE report ADD COLUMN event_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL;
+130 -10
View File
@@ -49,6 +49,13 @@ export const SCHEMA_DDL: string[] = [
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)`,
]
/**
@@ -116,6 +123,19 @@ export function toEventResponse(row: EventAttendeeRow): PlayerEventResponse {
}
}
/**
* 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.
@@ -237,10 +257,16 @@ function toTickPrecision(iso: string): string {
* 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): PlayerEventNotification {
export function toEventNotification(
event: PlayerEvent,
tags: EventTag[] = []
): PlayerEventNotification {
return {
tags: [],
tags,
playerEventId: event.PlayerEventId,
creatorPlayerId: event.CreatorPlayerId,
roomId: event.RoomId,
@@ -270,6 +296,39 @@ function eventTime(ms: number): string {
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
/** 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 —
@@ -277,6 +336,8 @@ function eventTime(ms: number): string {
* 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
@@ -337,6 +398,33 @@ export function eventInputRejection(input: EventInput): string | null {
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.
*/
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()]
}
export function parseEventBody(body: unknown): EventInput {
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
const nested = outer.PlayerEvent
@@ -372,6 +460,7 @@ export function parseEventBody(body: unknown): EventInput {
}
return {
tags: parseEventTags(obj.Tags ?? obj.tags),
imageName: nullableString('ImageName'),
roomId: asInt(obj.RoomId),
subRoomId: nullableInt('SubRoomId'),
@@ -443,6 +532,9 @@ export async function createEvent(
)
.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
}
@@ -609,6 +701,9 @@ export async function updateEvent(
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
}
@@ -691,9 +786,19 @@ function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
}
/**
* 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.
* 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
@@ -705,16 +810,31 @@ export async function searchEvents(
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 — the filter stays in SQL.
// 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('SELECT data FROM event WHERE end_time >= ?1')
.bind(eventTime(Date.now()))
.prepare(sql)
.bind(eventTime(Date.now()), ...wanted)
.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) {
for (const term of textTerms) {
events = events.filter(
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
)
+14
View File
@@ -542,6 +542,20 @@ export const PlayerEventRespondRequest = z.object({
Type: z.int().describe('0 Going, 1 Interested, 2 Cant go'),
})
/**
* `POST /api/playerevents/v1/report` JSON body — a report against an event. JSON, note,
* where the player report next to it is form-encoded. The reporter is NOT in the body:
* it's the bearer token's player.
*/
export const PlayerEventReportRequest = z.object({
PlayerEventId: z.int().describe('The event being reported'),
ReportCategory: z
.int()
.optional()
.describe('The reason picked in the report UI, e.g. `101`. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
})
/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */
export const PlayerEventBulkInviteRequest = z.object({
PlayerEventId: z.int(),
+28 -8
View File
@@ -6,9 +6,14 @@
* normal relational table. Rows are append-only in the sense that nothing rewrites
* what a player submitted: the table is a log of exactly what was reported.
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql and
* 0009_report_ban.sql, applied under its own `migrations_table` so it doesn't clash
* with the other workers' migrations that share the database).
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* 0009_report_ban.sql and 0011_report_event.sql, applied under its own
* `migrations_table` so it doesn't clash with the other workers' migrations that share
* the database).
*
* A reported player EVENT lands here too, rather than in a table of its own: same
* fields, same moderation life. Such a row carries `event_id`, and its
* `reported_player_id` is the event's creator — see `POST /api/playerevents/v1/report`.
*
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
@@ -21,7 +26,10 @@
* answers "not blocked" unconditionally.
*/
/** Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql). */
/**
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
* 0011_report_event.sql).
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -35,11 +43,13 @@ export const SCHEMA_DDL: string[] = [
room_instance_type TEXT,
created_at TEXT NOT NULL,
banned INTEGER NOT NULL DEFAULT 0,
ban_expires TEXT
ban_expires TEXT,
event_id INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1`,
`CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL`,
]
/** A stored report row (snake_case columns, one row per submission). */
@@ -60,6 +70,12 @@ export interface ReportRow {
banned: number
/** ISO-8601 UTC instant the ban lifts; NULL means it never does. */
ban_expires: string | null
/**
* The player event this report is against, or NULL for an ordinary player report —
* which is what tells the two kinds apart. See `POST /api/playerevents/v1/report`:
* `reported_player_id` and `room_id` are filled in from the event itself.
*/
event_id: number | null
}
/**
@@ -77,6 +93,8 @@ export interface NewReport {
heightReported?: number | null
roomId?: number | null
roomInstanceType?: string | null
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
eventId?: number | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
@@ -85,8 +103,9 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
.prepare(
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
height_reporter, height_reported, room_id, room_instance_type, created_at,
event_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
RETURNING *`
)
.bind(
@@ -98,7 +117,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
input.heightReported ?? null,
input.roomId ?? null,
input.roomInstanceType ?? null,
new Date().toISOString()
new Date().toISOString(),
input.eventId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
+95 -12
View File
@@ -15,6 +15,7 @@ import {
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getEventTags,
getLiveEvents,
inviteToEvent,
isEventResponseType,
@@ -39,6 +40,7 @@ import {
PlayerEventDetailsDto,
PlayerEventDto,
PlayerEventListingDto,
PlayerEventReportRequest,
PlayerEventRequest,
PlayerEventRespondRequest,
PlayerEventResponseDto,
@@ -46,14 +48,16 @@ import {
PlayerEventsAll,
PlayerEventsPage,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import { createReport } from '../reports-db'
import type { Context } from 'hono'
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { EventAttendeeRow, PlayerEvent } from '../events-db'
import type { EventAttendeeRow, EventTag, PlayerEvent } from '../events-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -66,12 +70,16 @@ const HUB_INSTANCE = 'global'
* 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> {
async function notifyEventCreated(
c: Context<App>,
event: PlayerEvent,
tags: EventTag[]
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
event.CreatorPlayerId,
NotificationType.PlayerEventCreated,
{ ...toEventNotification(event) }
{ ...toEventNotification(event, tags) }
)
} catch (err) {
logger.error('failed to push PlayerEventCreated notification', {
@@ -301,13 +309,23 @@ export const eventRoutes = new Hono<App>({ strict: false })
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.',
'The browse query on the player-events screen, term by term; an empty query ' +
'browses everything upcoming. A `#` decides how a term is matched: `#workshops` is ' +
'a TAG term, matching only events tagged `workshops` and never the word in a name ' +
'or description, which is what the filter chips send; a bare `workshops` is TEXT, ' +
'matched case-insensitively against the name and description. Every term must ' +
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
'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'),
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
stringQuery(
'sort',
'Accepted and echoed by the client as `StartTime`, which is the only order ' +
'served (soonest first); any other value sorts the same way'
),
...pageParams(50),
],
responses: { 200: json(PlayerEventDto.array(), 'The matching events') },
@@ -385,6 +403,69 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// Report an event. Stored in the `report` table the player reports use — same fields,
// same moderation life — with `event_id` set. See migrations/0011_report_event.sql.
.post(
'/api/playerevents/v1/report',
describeRoute({
tags: ['Events', 'Moderation'],
summary: 'Report a player event',
description:
'Files a report against an event. Stored as a row in the same `report` table a ' +
'player report goes to (`POST /api/PlayerReporting/v3/create`) — it is the same ' +
'submission with the same moderation life, and a moderator converts either into a ' +
'ban the same way. What marks it as an event report is `event_id`; the rows ' +
'`reported_player_id` is the events CREATOR (who a moderator would act against) ' +
'and its `room_id` the room the event runs in, both read from the event rather ' +
'than sent by the client.\n\n' +
'The reporter is the caller (from the bearer token), never a body field. Note this ' +
'body is JSON, where the player reports is form-encoded. `ReportCategory` is ' +
'stored verbatim — the enum is not mapped here. Nothing dedupes the rows: ' +
'reporting the same event twice files two reports.\n\n' +
'Answers the same `{ success, error }` envelope as the player report, `error` ' +
'being an empty string rather than null, on the rejected branches too so there is ' +
'only one shape to parse.',
security: AUTHED,
requestBody: jsonBody(PlayerEventReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable `PlayerEventId` in the body'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such event'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req
.json<{ PlayerEventId?: unknown; ReportCategory?: unknown; Details?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const eventId = Number(body.PlayerEventId)
if (!Number.isInteger(eventId)) {
return c.json({ success: false, error: 'PlayerEventId is required' }, 400)
}
// The event supplies the two columns the client doesn't send. An unknown event is
// refused rather than filed against nobody: the row's reported player has to be
// someone, and a report naming an event that never existed isn't actionable.
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.json({ success: false, error: 'No such event' }, 404)
const category = Number(body.ReportCategory)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: event.CreatorPlayerId,
reportCategory: Number.isInteger(category) ? category : 0,
details: typeof body.Details === 'string' ? body.Details : null,
roomId: event.RoomId > 0 ? event.RoomId : null,
eventId,
})
return c.json({ success: true, error: '' })
}
)
// Bulk invite — the "invite friends" button on an event. Adds the invited players to
// the same `event_attendee` table an RSVP writes to, as Going.
.post(
@@ -491,7 +572,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
// description silently is worse than refusing it.
if (eventInputRejection(input) !== null) return c.body(null, 400)
const event = await createEvent(c.env.DB, id, input)
await notifyEventCreated(c, event)
await notifyEventCreated(c, event, input.tags ?? [])
return c.json(toEventResult(event))
}
)
@@ -586,10 +667,12 @@ export const eventRoutes = new Hono<App>({ strict: false })
},
}),
async (c) => {
const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10))
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.body(null, 404)
// The client sends `True`; accepted case-insensitively, and `1` alongside it.
const details = /^(true|1)$/i.test(c.req.query('includeDetails') ?? '')
return c.json(details ? { ...event, tags: [] } : event)
if (!details) return c.json(event)
return c.json({ ...event, tags: await getEventTags(c.env.DB, eventId) })
}
)
+113
View File
@@ -3099,6 +3099,71 @@ describe('player events', () => {
expect(await (await get('/api/playerevents/v1?skip=1&take=1')).json()).toEqual([feed[1]])
})
test('GET /api/playerevents/v1/search matches `#tag` terms against tags, not text', async () => {
const search = async (qs: string): Promise<PlayerEvent[]> =>
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
// Two tagged events, one of which only MENTIONS the word in its description.
const tagged = await create({
RoomId: 3,
Name: 'Sawdust Session',
StartTime: at(HOUR),
// Both forms in circulation: a bare name and the `{ tag, type }` pair.
Tags: ['#Workshops', { tag: 'meetup', type: 2 }],
})
const textOnly = await create({
RoomId: 3,
Name: 'Talking About Workshops',
Description: 'we discuss workshops, untagged',
StartTime: at(HOUR),
})
// `#workshops` is the tag alone — the untagged event that says "workshops" twice
// doesn't match.
const byTag = await search('?query=%23workshops&sort=StartTime')
expect(byTag.map((e) => e.PlayerEventId)).toEqual([tagged.PlayerEventId])
// …and the bare word is the mirror image: a text search, which finds the event that
// says "workshops" and NOT the one merely tagged with it.
const byText = await search('?query=workshops')
expect(byText.map((e) => e.PlayerEventId)).toEqual([textOnly.PlayerEventId])
// Tag terms combine with text terms, and with each other (every one must match).
expect((await search('?query=%23workshops+sawdust')).map((e) => e.PlayerEventId)).toEqual([
tagged.PlayerEventId,
])
expect(await search('?query=%23workshops+%23meetup')).toHaveLength(1)
expect(await search('?query=%23workshops+%23celebration')).toEqual([])
expect(await search('?query=%23nosuchtag')).toEqual([])
// The tags are what `includeDetails` serves — lowercased, `#` stripped, and the
// type kept (defaulting to 0 for the bare-string form).
const details = (await (
await get(`/api/playerevents/v1/${tagged.PlayerEventId}?includeDetails=True`)
).json()) as { tags: Array<{ tag: string; type: number }> }
expect(details.tags).toEqual([
{ tag: 'meetup', type: 2 },
{ tag: 'workshops', type: 0 },
])
// …and they are NOT on the plain record, which every other read serves verbatim.
expect(
await (await get(`/api/playerevents/v1/${tagged.PlayerEventId}`)).json()
).not.toHaveProperty('tags')
// An update REPLACES the set; a body that says nothing about tags leaves it alone.
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Tags: ['celebration'] })
expect((await search('?query=%23celebration')).map((e) => e.PlayerEventId)).toEqual([
tagged.PlayerEventId,
])
expect(await search('?query=%23workshops')).toEqual([])
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Name: 'Sawdust Session II' })
expect((await search('?query=%23celebration')).map((e) => e.PlayerEventId)).toEqual([
tagged.PlayerEventId,
])
// An explicit empty list does clear them.
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Tags: [] })
expect(await search('?query=%23celebration')).toEqual([])
})
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)
@@ -3263,6 +3328,53 @@ describe('player events', () => {
).toBe(404)
})
test('POST /api/playerevents/v1/report files a report row against the event', async () => {
const event = await create({ RoomId: 58, Name: 'Reportable', StartTime: at(HOUR) }, '43')
const res = await post(
'/api/playerevents/v1/report',
{ ReportCategory: 101, PlayerEventId: event.PlayerEventId, Details: 'bad event' },
'42'
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One row in the shared report table, marked as an event report by `event_id` —
// with the reported player and the room filled in FROM the event, not the body.
const row = await env.DB.prepare('SELECT * FROM report WHERE event_id = ?1')
.bind(event.PlayerEventId)
.first<Record<string, unknown>>()
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 43, // the event's creator
report_category: 101,
details: 'bad event',
room_id: 58,
event_id: event.PlayerEventId,
banned: 0, // filed unbanned, like any report
})
// A body with no usable event id, and one naming an event that doesn't exist —
// both answer the same envelope shape as the success branch.
expect(await (await post('/api/playerevents/v1/report', { Details: 'x' })).json()).toEqual({
success: false,
error: 'PlayerEventId is required',
})
const unknown = await post('/api/playerevents/v1/report', { PlayerEventId: 999999 })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such event' })
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
expect(
(
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/report`, {
method: 'POST',
body: JSON.stringify({ PlayerEventId: event.PlayerEventId }),
})
).status
).toBe(401)
})
test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => {
const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) })
const id = event.PlayerEventId
@@ -3552,6 +3664,7 @@ describe('openapi', () => {
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/bulkInvite',
'POST /api/playerevents/v1/report',
'POST /api/playerevents/v1/respond',
'POST /api/playerevents/v2',
'POST /api/playerevents/v2/{eventId}',