mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[events] event tagging and reporting
This commit is contained in:
@@ -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);
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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<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) })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user