mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[api] invention reports
This commit is contained in:
@@ -15,6 +15,10 @@
|
|||||||
-- Partial index: event reports are a small minority of rows, so indexing only the ones
|
-- 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
|
-- that name an event keeps "reports against this event" off a full scan without paying
|
||||||
-- for the NULLs.
|
-- 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;
|
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;
|
CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -966,6 +966,21 @@ export const PlayerEventReportRequest = z.object({
|
|||||||
Details: z.string().optional().describe('The free-text description the reporter typed'),
|
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. */
|
/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */
|
||||||
export const PlayerEventBulkInviteRequest = z.object({
|
export const PlayerEventBulkInviteRequest = z.object({
|
||||||
PlayerEventId: z.int(),
|
PlayerEventId: z.int(),
|
||||||
|
|||||||
+29
-12
@@ -7,13 +7,15 @@
|
|||||||
* what a player submitted: the table is a log of exactly what was reported.
|
* 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,
|
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||||
* 0009_report_ban.sql and 0011_report_event.sql, applied under its own
|
* 0009_report_ban.sql, 0011_report_event.sql and 0016_report_invention.sql, applied under
|
||||||
* `migrations_table` so it doesn't clash with the other workers' migrations that share
|
* its own `migrations_table` so it doesn't clash with the other workers' migrations that
|
||||||
* the database).
|
* share the database).
|
||||||
*
|
*
|
||||||
* A reported player EVENT lands here too, rather than in a table of its own: same
|
* A reported player EVENT or INVENTION lands here too, rather than in a table of its own:
|
||||||
* fields, same moderation life. Such a row carries `event_id`, and its
|
* same fields, same moderation life. Such a row carries `event_id` or `invention_id`, and
|
||||||
* `reported_player_id` is the event's creator — see `POST /api/playerevents/v1/report`.
|
* 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`
|
* 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
|
* 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 +
|
* 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[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS report (
|
`CREATE TABLE IF NOT EXISTS report (
|
||||||
@@ -44,12 +51,12 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
banned INTEGER NOT NULL DEFAULT 0,
|
banned INTEGER NOT NULL DEFAULT 0,
|
||||||
ban_expires TEXT,
|
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_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_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_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). */
|
/** 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.
|
* `reported_player_id` and `room_id` are filled in from the event itself.
|
||||||
*/
|
*/
|
||||||
event_id: number | null
|
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
|
roomInstanceType?: string | null
|
||||||
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
|
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
|
||||||
eventId?: number | null
|
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). */
|
/** 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<Re
|
|||||||
`INSERT INTO report (
|
`INSERT INTO report (
|
||||||
reporter_player_id, reported_player_id, report_category, details,
|
reporter_player_id, reported_player_id, report_category, details,
|
||||||
height_reporter, height_reported, room_id, room_instance_type, created_at,
|
height_reporter, height_reported, room_id, room_instance_type, created_at,
|
||||||
event_id
|
event_id, invention_id
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||||
RETURNING *`
|
RETURNING *`
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
@@ -118,7 +134,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
|||||||
input.roomId ?? null,
|
input.roomId ?? null,
|
||||||
input.roomInstanceType ?? null,
|
input.roomInstanceType ?? null,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
input.eventId ?? null
|
input.eventId ?? null,
|
||||||
|
input.inventionId ?? null
|
||||||
)
|
)
|
||||||
.first<ReportRow>()
|
.first<ReportRow>()
|
||||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
InventionDetails,
|
InventionDetails,
|
||||||
InventionDto,
|
InventionDto,
|
||||||
InventionPersonalDetails,
|
InventionPersonalDetails,
|
||||||
|
InventionReportRequest,
|
||||||
InventionSaveResult,
|
InventionSaveResult,
|
||||||
InventionVersionDto,
|
InventionVersionDto,
|
||||||
json,
|
json,
|
||||||
@@ -78,12 +79,14 @@ import {
|
|||||||
SetTagsResponse,
|
SetTagsResponse,
|
||||||
stringParam,
|
stringParam,
|
||||||
stringQuery,
|
stringQuery,
|
||||||
|
SuccessErrorEnvelope,
|
||||||
SuccessValueEnvelope,
|
SuccessValueEnvelope,
|
||||||
TagFilters,
|
TagFilters,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
UpdateCustomAvatarItemRequest,
|
UpdateCustomAvatarItemRequest,
|
||||||
UpdatePriceRequest,
|
UpdatePriceRequest,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
import { createReport } from '../reports-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
@@ -1298,6 +1301,70 @@ export const avatarRoutes = new Hono<App>({ 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<string, unknown>)
|
||||||
|
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
|
// Save an invention's metadata. The data file itself is uploaded separately
|
||||||
// through the `storage` worker and referenced here by `inventionDataFilename` —
|
// through the `storage` worker and referenced here by `inventionDataFilename` —
|
||||||
// the one required field, since an invention with no data blob is unusable. An
|
// the one required field, since an invention with no data blob is unusable. An
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>()
|
||||||
|
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 () => {
|
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -5767,6 +5828,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/images/v1/cheer',
|
'POST /api/images/v1/cheer',
|
||||||
'POST /api/images/v4/uploadsaved',
|
'POST /api/images/v4/uploadsaved',
|
||||||
'POST /api/images/v5/cheered/bulk',
|
'POST /api/images/v5/cheered/bulk',
|
||||||
|
'POST /api/inventions/v1/report',
|
||||||
'POST /api/inventions/v1/settags',
|
'POST /api/inventions/v1/settags',
|
||||||
'POST /api/inventions/v1/update',
|
'POST /api/inventions/v1/update',
|
||||||
'POST /api/inventions/v1/updateprice',
|
'POST /api/inventions/v1/updateprice',
|
||||||
|
|||||||
Reference in New Issue
Block a user