diff --git a/apps/api/migrations/0011_report_event.sql b/apps/api/migrations/0011_report_event.sql index 49354be..7f3b060 100644 --- a/apps/api/migrations/0011_report_event.sql +++ b/apps/api/migrations/0011_report_event.sql @@ -15,6 +15,10 @@ -- 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. +-- +-- SUPERSEDED by 0016_report_invention.sql, which DROPS that index: nothing ever queried +-- `event_id`, so it only cost writes. Left here so an unmigrated database still applies +-- the migrations in order and ends up in the same place. 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/migrations/0016_report_invention.sql b/apps/api/migrations/0016_report_invention.sql new file mode 100644 index 0000000..38b06c5 --- /dev/null +++ b/apps/api/migrations/0016_report_invention.sql @@ -0,0 +1,22 @@ +-- Reporting an INVENTION (`POST /api/inventions/v1/report`) reuses the report table, as +-- the event report next to it does: same fields, same moderation life — a moderator +-- acting on one sets `banned` on the row exactly as they would for a player report. +-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- `invention_id` names the reported invention; NULL on every other kind of report. It +-- sits beside `event_id` and the two are mutually exclusive: a row names an event, or an +-- invention, or neither (an ordinary player report), which is what tells the kinds apart. +-- +-- The row's `reported_player_id` is the invention's CREATOR, read from the invention +-- rather than sent by the client — the column is NOT NULL, and "who is answerable for +-- this invention" is the only honest answer. No `room_id`: an invention is not tied to +-- one room the way an event is, so there is nothing to fill it in from. +-- +-- Neither id column is INDEXED. Both are written on every report of their kind and read +-- by nothing — no query in any worker filters on either, and the moderation reads that do +-- exist go by player (`idx_report_reported`) or by the ban flag. So the partial index +-- 0011 built over `event_id` is dropped here rather than being mirrored for +-- `invention_id`: it only cost writes. Add one back with the query that needs it. + +ALTER TABLE report ADD COLUMN invention_id INTEGER; +DROP INDEX IF EXISTS idx_report_event; diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 5991635..e5ff5ab 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -966,6 +966,21 @@ export const PlayerEventReportRequest = z.object({ Details: z.string().optional().describe('The free-text description the reporter typed'), }) +/** + * `POST /api/inventions/v1/report` JSON body — a report against an invention. JSON, like + * the event report and unlike the form-encoded player report. The reporter is NOT in the + * body: it's the bearer token's player, and neither is the invention's creator, who is + * read from the invention. + */ +export const InventionReportRequest = z.object({ + InventionId: z.int().describe('The invention being reported'), + ReportCategory: z + .int() + .optional() + .describe('The reason picked in the report UI. 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 41d5e16..a5428ce 100644 --- a/apps/api/src/reports-db.ts +++ b/apps/api/src/reports-db.ts @@ -7,13 +7,15 @@ * 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, - * 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). + * 0009_report_ban.sql, 0011_report_event.sql and 0016_report_invention.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 reported player EVENT or INVENTION lands here too, rather than in a table of its own: + * same fields, same moderation life. Such a row carries `event_id` or `invention_id`, and + * its `reported_player_id` is that thing's CREATOR — see + * `POST /api/playerevents/v1/report` and `POST /api/inventions/v1/report`. The two id + * columns are mutually exclusive; a row with neither is an ordinary player 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 @@ -28,7 +30,12 @@ /** * Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql + - * 0011_report_event.sql). + * 0011_report_event.sql + 0016_report_invention.sql). + * + * Neither `event_id` nor `invention_id` is indexed: both are written on every report of + * their kind and read by nothing — no query here filters on either, and the reads that do + * exist go by player or by the ban flag. 0011's partial index over `event_id` was dropped + * in 0016 rather than mirrored. Add one back alongside the query that needs it. */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS report ( @@ -44,12 +51,12 @@ export const SCHEMA_DDL: string[] = [ created_at TEXT NOT NULL, banned INTEGER NOT NULL DEFAULT 0, ban_expires TEXT, - event_id INTEGER + event_id INTEGER, + invention_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). */ @@ -76,6 +83,13 @@ export interface ReportRow { * `reported_player_id` and `room_id` are filled in from the event itself. */ event_id: number | null + /** + * The invention this report is against, or NULL for any other kind — mutually exclusive + * with `event_id`. See `POST /api/inventions/v1/report`: `reported_player_id` is the + * invention's creator, read from the invention itself. No `room_id` comes with it; an + * invention isn't tied to one room the way an event is. + */ + invention_id: number | null } /** @@ -95,6 +109,8 @@ export interface NewReport { roomInstanceType?: string | null /** Set only when reporting a player EVENT; absent on an ordinary player report. */ eventId?: number | null + /** Set only when reporting an INVENTION; never set alongside `eventId`. */ + inventionId?: number | null } /** Record a submitted report, returning the stored row (with its assigned id). */ @@ -104,8 +120,8 @@ 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/avatar.ts b/apps/api/src/routes/avatar.ts index 35202fd..647dfa6 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -60,6 +60,7 @@ import { InventionDetails, InventionDto, InventionPersonalDetails, + InventionReportRequest, InventionSaveResult, InventionVersionDto, json, @@ -78,12 +79,14 @@ import { SetTagsResponse, stringParam, stringQuery, + SuccessErrorEnvelope, SuccessValueEnvelope, TagFilters, UNAUTHORIZED_RESPONSE, UpdateCustomAvatarItemRequest, UpdatePriceRequest, } from '../openapi' +import { createReport } from '../reports-db' import type { Context } from 'hono' import type { App } from '../context' @@ -1298,6 +1301,70 @@ export const avatarRoutes = new Hono({ strict: false }) } ) + // Report an invention. Stored in the `report` table the player and event reports use — + // same fields, same moderation life — with `invention_id` set. See + // migrations/0016_report_invention.sql. + .post( + '/api/inventions/v1/report', + describeRoute({ + tags: ['Inventions', 'Moderation'], + summary: 'Report an invention', + description: + 'Files a report against an invention. Stored as a row in the same `report` table a ' + + 'player report goes to (`POST /api/PlayerReporting/v3/create`) and an event report ' + + '(`POST /api/playerevents/v1/report`) — it is the same submission with the same ' + + 'moderation life, and a moderator converts any of them into a ban the same way. ' + + 'What marks it as an invention report is `invention_id`; the row’s ' + + '`reported_player_id` is the invention’s CREATOR — who a moderator would act ' + + 'against — read from the invention rather than sent by the client. Nothing fills ' + + '`room_id`: an invention isn’t tied to one room the way an event is.\n\n' + + 'The reporter is the caller (from the bearer token), never a body field. ' + + '`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing ' + + 'dedupes the rows: reporting the same invention twice files two reports, and ' + + 'reporting your own is allowed rather than being a special case.\n\n' + + 'Answers the same `{ success, error }` envelope as the event 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(InventionReportRequest, 'The report'), + responses: { + 200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'), + 400: json(SuccessErrorEnvelope, 'No usable `InventionId` in the body'), + 401: UNAUTHORIZED_RESPONSE, + 404: json(SuccessErrorEnvelope, 'No such invention'), + }, + }), + async (c) => { + const reporterId = await authedId(c) + if (reporterId === null) return unauthorized(c) + + const body = await c.req + .json<{ InventionId?: unknown; ReportCategory?: unknown; Details?: unknown }>() + .catch(() => ({}) as Record) + const inventionId = Number(body.InventionId) + if (!Number.isInteger(inventionId)) { + return c.json({ success: false, error: 'InventionId is required' }, 400) + } + + // The invention supplies the reported player. An unknown invention is refused rather + // than filed against nobody: the row's reported player has to be someone, and a + // report naming an invention that never existed isn't actionable. + const invention = await getInventionById(c.env.DB, inventionId) + if (invention === null) return c.json({ success: false, error: 'No such invention' }, 404) + + const category = Number(body.ReportCategory) + await createReport(c.env.DB, { + reporterPlayerId: reporterId, + reportedPlayerId: invention.CreatorPlayerId, + reportCategory: Number.isInteger(category) ? category : 0, + details: typeof body.Details === 'string' ? body.Details : null, + inventionId, + }) + + return c.json({ success: true, error: '' }) + } + ) + // Save an invention's metadata. The data file itself is uploaded separately // through the `storage` worker and referenced here by `inventionDataFilename` — // the one required field, since an invention with no data blob is unusable. An diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index d399c8a..f495cb7 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1546,6 +1546,67 @@ describe('public endpoints', () => { ]) }) + test('POST /api/inventions/v1/report files a report row against the invention', async () => { + // 5150 saves an invention; 42 reports it. The creator is derived from the invention, + // so the reporter never gets to name who the report is against. + const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { + method: 'POST', + headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Reportable', inventionDataFilename: 'blob' }), + }) + const inventionId = ((await save.json()) as InventionSaveResult).Invention.InventionId + + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: inventionId, Details: 'test', ReportCategory: 0 }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ success: true, error: '' }) + + // One row in the shared report table, marked as an invention report by `invention_id`, + // with the reported player filled in FROM the invention. `room_id` stays null: an + // invention isn't tied to one room the way an event is, so there is nothing to read. + const row = await env.DB.prepare('SELECT * FROM report WHERE invention_id = ?1') + .bind(inventionId) + .first>() + expect(row).toMatchObject({ + reporter_player_id: 42, + reported_player_id: 5150, // the invention's creator + report_category: 0, + details: 'test', + invention_id: inventionId, + event_id: null, // the two id columns are mutually exclusive + room_id: null, + banned: 0, // filed unbanned, like any report + }) + + // A body with no usable invention id, and one naming an invention that doesn't exist — + // both answer the same envelope shape as the success branch. + const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ Details: 'x' }), + }) + expect(noId.status).toBe(400) + expect(await noId.json()).toEqual({ success: false, error: 'InventionId is required' }) + + const unknown = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, { + method: 'POST', + headers: { ...(await bearer('42')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ InventionId: 999999 }), + }) + expect(unknown.status).toBe(404) + expect(await unknown.json()).toEqual({ success: false, error: 'No such invention' }) + + // Auth-gated: the reporter comes from the token, so there's no filing one signed out. + const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, { + method: 'POST', + body: JSON.stringify({ InventionId: inventionId }), + }) + expect(anon.status).toBe(401) + }) + test('POST /api/inventions/v6/save 401s without a bearer token', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, { method: 'POST', @@ -5767,6 +5828,7 @@ describe('openapi', () => { 'POST /api/images/v1/cheer', 'POST /api/images/v4/uploadsaved', 'POST /api/images/v5/cheered/bulk', + 'POST /api/inventions/v1/report', 'POST /api/inventions/v1/settags', 'POST /api/inventions/v1/update', 'POST /api/inventions/v1/updateprice',