From d129900762874ec33c440bf663fd7a92c2895d84 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 12 Aug 2026 17:42:22 -0400 Subject: [PATCH] [events] event tagging and reporting --- apps/api/migrations/0010_event_tag.sql | 25 ++++ apps/api/migrations/0011_report_event.sql | 20 ++++ apps/api/src/events-db.ts | 140 ++++++++++++++++++++-- apps/api/src/openapi.ts | 14 +++ apps/api/src/reports-db.ts | 36 ++++-- apps/api/src/routes/events.ts | 107 +++++++++++++++-- apps/api/src/test/integration/api.test.ts | 113 +++++++++++++++++ 7 files changed, 425 insertions(+), 30 deletions(-) create mode 100644 apps/api/migrations/0010_event_tag.sql create mode 100644 apps/api/migrations/0011_report_event.sql diff --git a/apps/api/migrations/0010_event_tag.sql b/apps/api/migrations/0010_event_tag.sql new file mode 100644 index 0000000..796a943 --- /dev/null +++ b/apps/api/migrations/0010_event_tag.sql @@ -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); diff --git a/apps/api/migrations/0011_report_event.sql b/apps/api/migrations/0011_report_event.sql new file mode 100644 index 0000000..49354be --- /dev/null +++ b/apps/api/migrations/0011_report_event.sql @@ -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; diff --git a/apps/api/src/events-db.ts b/apps/api/src/events-db.ts index e6865a9..9039db9 100644 --- a/apps/api/src/events-db.ts +++ b/apps/api/src/events-db.ts @@ -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 { + 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 — @@ -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() + 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 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 { + 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() 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) ) diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 7897ad4..93ada2b 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -542,6 +542,20 @@ export const PlayerEventRespondRequest = z.object({ Type: z.int().describe('0 Going, 1 Interested, 2 Can’t 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(), diff --git a/apps/api/src/reports-db.ts b/apps/api/src/reports-db.ts index 4a16982..41d5e16 100644 --- a/apps/api/src/reports-db.ts +++ b/apps/api/src/reports-db.ts @@ -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() // RETURNING always yields the inserted row; the non-null assert keeps the caller diff --git a/apps/api/src/routes/events.ts b/apps/api/src/routes/events.ts index 62db3ac..008dfb5 100644 --- a/apps/api/src/routes/events.ts +++ b/apps/api/src/routes/events.ts @@ -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, event: PlayerEvent): Promise { +async function notifyEventCreated( + c: Context, + event: PlayerEvent, + tags: EventTag[] +): Promise { 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({ 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({ 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 row’s ' + + '`reported_player_id` is the event’s 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 report’s 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) + 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({ 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({ 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) }) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 095512f..8b6b8c8 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 => + (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>() + 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}',