[api] add image moderation and v6 endpoint

This commit is contained in:
Devin Zuczek
2026-10-02 18:10:08 -04:00
parent 625cec113b
commit 41d0b0543a
7 changed files with 241 additions and 118 deletions
+16
View File
@@ -0,0 +1,16 @@
-- Reporting a saved IMAGE (`POST /api/images/v1/{id}/report`) reuses the report table, as
-- the event, invention, custom-avatar-item and chat-message reports beside it do: same
-- fields, same moderation life. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `image_id` names the reported image (`image.id`). NULL on every other kind of report, and
-- mutually exclusive with `event_id`, `invention_id`, `custom_avatar_item_id` and
-- `chat_message_id`.
--
-- The request has no body, so the row is three facts: the reporter (the caller), the image,
-- and `reported_player_id` — the image's `PlayerId`, who took it, read from the image.
-- `report_category` stays 0 and `details` NULL: the client sends neither.
--
-- NOT indexed, like the other kind columns: written on every report of this kind and read by
-- nothing. Add an index with the query that needs it.
ALTER TABLE report ADD COLUMN image_id INTEGER;
-23
View File
@@ -1596,29 +1596,6 @@ export const SavedImageDto = z.object({
CommentCount: z.int(), CommentCount: z.int(),
}) })
/**
* `GET /api/images/v6` — an image's metadata by bucket key. A third projection of the same
* row: renamed like `ImagesPlayer` (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`)
* but carrying `ClubId`, and with no nullable fields — `RoomId`, `PlayerEventId` and
* `ClubId` are 0 where the row holds null, `Description` is `""`. Don't unify it with the
* other two.
*/
export const ImageMetadataDto = z.object({
SavedImageId: z.int(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
PlayerId: z.int(),
RoomId: z.int().describe('0 when the photo was not taken in a room'),
PlayerEventId: z.int().describe('0 when it belongs to no event'),
ClubId: z.int().describe('Always 0 — nothing here associates an image with a club'),
Description: z.string().describe('Empty string, never null'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
SavedImageType: z.int().describe('1 = share camera, 3 = room, 4 = profile, …'),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/** /**
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`, * The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed * `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
+23 -9
View File
@@ -8,8 +8,8 @@
* *
* 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, 0011_report_event.sql, 0016_report_invention.sql, * 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql and * 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql,
* 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash * 0025_report_chat_message.sql and 0034_report_image.sql, applied under its own `migrations_table` so it doesn't clash
* with the other workers' migrations that share the database). * with the other workers' migrations that share the database).
* *
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` — * The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
@@ -24,7 +24,9 @@
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and * CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
* `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind: * `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind:
* `chat_message_id`, with the message's SENDER as the reported player — see * `chat_message_id`, with the message's SENDER as the reported player — see
* `POST /api/chatreport/createChatReport`. The four id columns are mutually exclusive; a row * `POST /api/chatreport/createChatReport`. A reported IMAGE is the fifth: `image_id`, with
* the player who took it as the reported player — see `POST /api/images/v1/{id}/report`.
* The five id columns are mutually exclusive; a row
* with none of them is an ordinary player report. They are separate columns rather than one * with none of them is an ordinary player report. They are separate columns rather than one
* polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key. * polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key.
* *
@@ -42,9 +44,10 @@
/** /**
* 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 + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql + * 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql +
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql). * 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql +
* 0034_report_image.sql).
* *
* None of `event_id`, `invention_id`, `custom_avatar_item_id` or `chat_message_id` is * None of `event_id`, `invention_id`, `custom_avatar_item_id`, `chat_message_id` or `image_id` is
* indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them, * indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them,
* and the reads that do exist go by player or by the ban flag. 0011's partial index over * 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 * `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that
@@ -71,7 +74,8 @@ export const SCHEMA_DDL: string[] = [
banned_at TEXT, banned_at TEXT,
chat_message_id INTEGER, chat_message_id INTEGER,
unbanned_by_player_id INTEGER, unbanned_by_player_id INTEGER,
unbanned_at TEXT unbanned_at TEXT,
image_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)`,
@@ -150,6 +154,13 @@ export interface ReportRow {
unbanned_by_player_id: number | null unbanned_by_player_id: number | null
/** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */ /** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */
unbanned_at: string | null unbanned_at: string | null
/**
* The saved image (photo) this report is against, or NULL for any other kind — mutually
* exclusive with the four id columns above. See `POST /api/images/v1/{id}/report`: the
* request has no body, so `reported_player_id` is the image's `PlayerId` (who took it),
* read from the image, and there is no category or description to store.
*/
image_id: number | null
} }
/** /**
@@ -175,6 +186,8 @@ export interface NewReport {
customAvatarItemId?: string | null customAvatarItemId?: string | null
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */ /** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
chatMessageId?: number | null chatMessageId?: number | null
/** Set only when reporting an IMAGE; never set alongside the four above. */
imageId?: 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). */
@@ -184,8 +197,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, invention_id, custom_avatar_item_id, chat_message_id event_id, invention_id, custom_avatar_item_id, chat_message_id, image_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
RETURNING *` RETURNING *`
) )
.bind( .bind(
@@ -201,7 +214,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
input.eventId ?? null, input.eventId ?? null,
input.inventionId ?? null, input.inventionId ?? null,
input.customAvatarItemId ?? null, input.customAvatarItemId ?? null,
input.chatMessageId ?? null input.chatMessageId ?? null,
input.imageId ?? 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
+92 -12
View File
@@ -5,6 +5,7 @@ import {
createImage, createImage,
deleteImage, deleteImage,
getCheeredImageIds, getCheeredImageIds,
getImageById,
getImageByName, getImageByName,
getImagesByIds, getImagesByIds,
getImagesByPlayer, getImagesByPlayer,
@@ -19,7 +20,6 @@ import {
setImageDescription, setImageDescription,
SLIDESHOW_LIMIT, SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT, SLIDESHOW_MAX_LIMIT,
toImageMetadata,
toImagesPlayer, toImagesPlayer,
} from '@repo/domain' } from '@repo/domain'
@@ -33,7 +33,6 @@ import {
ErrorResponse, ErrorResponse,
form, form,
idParam, idParam,
ImageMetadataDto,
ImagesPlayerDto, ImagesPlayerDto,
intQuery, intQuery,
json, json,
@@ -47,11 +46,13 @@ import {
SavedImageDto, SavedImageDto,
SlideshowResponse, SlideshowResponse,
stringQuery, stringQuery,
SuccessErrorEnvelope,
SuccessResponse, SuccessResponse,
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
UploadImageRequest, UploadImageRequest,
UploadImageResponse, UploadImageResponse,
} from '../openapi' } from '../openapi'
import { createReport } from '../reports-db'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit' import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono' import type { Context } from 'hono'
@@ -642,23 +643,24 @@ export const imageRoutes = new Hono<App>({ strict: false })
} }
) )
// Image metadata by filename. Returns the stored SavedImage record, or 404 when // Image metadata by filename, or 404 when there's no metadata row for that name.
// there's no metadata row for that name. //
// Serves the RAW 13-key `SavedImage`, the same type the client decodes `v6/:id` into.
// This once answered a projection of its own (`SavedImageId`/`SavedImageType`, a
// `ClubId`, no `TaggedPlayerIds`, nulls as 0/""): the client's DTO has `Id` and `Type`
// and no `ClubId`, so it was silently dropping most of that.
.get( .get(
'/api/images/v6', '/api/images/v6',
describeRoute({ describeRoute({
tags: ['Images'], tags: ['Images'],
summary: 'Image metadata by filename', summary: 'Image metadata by filename',
description: description:
'An image’s metadata for a bucket key. 404s when the object exists but has no ' + 'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' +
'metadata row.\n\n' + '`v6/{id}` and `v5/bulk` serve, with `Description`, `RoomId` and `PlayerEventId` ' +
'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' + 'null where the row holds null. 404s when the object exists but has no metadata row.',
'no `TaggedPlayerIds`) but carrying `ClubId`, and with nothing nullable — `RoomId`, ' +
'`PlayerEventId` and `ClubId` read 0 where the row holds null, `Description` reads ' +
'`""`. Three shapes of one row; keep them straight.',
parameters: [stringQuery('name', 'The image name (bucket key); required')], parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: { responses: {
200: json(ImageMetadataDto, 'The image’s metadata'), 200: json(SavedImageDto, 'The image record'),
400: json(ErrorResponse, 'No name given'), 400: json(ErrorResponse, 'No name given'),
404: { description: 'No metadata for that name' }, 404: { description: 'No metadata for that name' },
}, },
@@ -667,7 +669,85 @@ export const imageRoutes = new Hono<App>({ strict: false })
const name = c.req.query('name') ?? '' const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400) if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name) const image = await getImageByName(c.env.DB, name)
return image ? c.json(toImageMetadata(image)) : c.notFound() return image ? c.json(image) : c.notFound()
}
)
// One image by id — the client opening a single photo it holds only the id of.
//
// Serves the RAW 13-key `SavedImage` (`Id`/`Type`, `TaggedPlayerIds`, nulls left null),
// which is the type the client decodes this route into — the same record `v6?name=`
// serves, and `v5/bulk` a list of. NOT the `ImagesPlayer` projection of the player lists.
//
// Public, or the caller's own: ids are sequential, so anyone else's private photo is a
// 404 here, the same as an id that doesn't exist.
.get(
'/api/images/v6/:id{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'One image by id',
description:
'The stored `SavedImage` record for an image id — the raw record, as `v5/bulk` ' +
'serves, with `Description`, `RoomId` and `PlayerEventId` null where the row holds ' +
'null. A private image is served only to the player who took it; to anyone else ' +
'it is a 404, like an unknown id.',
parameters: [idParam('id', 'Image id')],
responses: {
200: json(SavedImageDto, 'The image record'),
404: { description: 'No such image, or not the caller’s to see' },
},
}),
async (c) => {
const image = await getImageById(c.env.DB, Number.parseInt(c.req.param('id'), 10))
if (image === null) return c.notFound()
if (image.Accessibility !== 1 && image.PlayerId !== (await authedId(c))) return c.notFound()
return c.json(image)
}
)
// Report a saved image. Stored in the `report` table the player, event, invention, item
// and chat reports use — same moderation life — with `image_id` set. See
// migrations/0034_report_image.sql.
//
// The request has NO body: the path names the image and the token names the reporter,
// and that is the whole report. The reported player is whoever took the image, read from
// the image. The `{ success, error: "" }` reply is the envelope the other reports use,
// which is an ASSUMPTION here: what the client does with this response has not been
// observed.
.post(
'/api/images/v1/:id{[0-9]+}/report',
describeRoute({
tags: ['Images', 'Moderation'],
summary: 'Report a photo',
description:
'Files a report against a saved image, as a row in the same `report` table the ' +
'player, event, invention, custom-avatar-item and chat reports go to. There is no ' +
'request body: the row is the reporter (the caller), the image (`image_id`), and ' +
'`reported_player_id` — the image’s `PlayerId`, who took it. No category or ' +
'description is stored, since none is sent. Nothing dedupes the rows, and an ' +
'unknown image is refused rather than filed against nobody.',
security: AUTHED,
parameters: [idParam('id', 'Image id')],
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such image'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const imageId = Number.parseInt(c.req.param('id'), 10)
const image = await getImageById(c.env.DB, imageId)
if (image === null) return c.json({ success: false, error: 'No such image' }, 404)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: image.PlayerId,
imageId,
})
return c.json({ success: true, error: '' })
} }
) )
+97 -24
View File
@@ -6681,11 +6681,11 @@ describe('images', () => {
// A metadata row was created, and it's readable by name via /api/images/v6. // A metadata row was created, and it's readable by name via /api/images/v6.
const meta = (await ( const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`) await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as { ImageName: string; PlayerId: number; SavedImageId: number; CheerCount: number } ).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
expect(meta.ImageName).toBe(ImageName) expect(meta.ImageName).toBe(ImageName)
expect(meta.PlayerId).toBe(42) expect(meta.PlayerId).toBe(42)
// `SavedImageId`, not `Id` — v6 renames like the player lists do. // `Id`, not `SavedImageId` — v6 serves the raw record, unlike the player lists.
expect(typeof meta.SavedImageId).toBe('number') expect(typeof meta.Id).toBe('number')
expect(meta.CheerCount).toBe(0) expect(meta.CheerCount).toBe(0)
}) })
@@ -7030,7 +7030,7 @@ describe('images', () => {
expect(entries.filter((e) => e.IsCheered)).toHaveLength(1) expect(entries.filter((e) => e.IsCheered)).toHaveLength(1)
}) })
test('GET /api/images/v6 serves the metadata projection, nothing nullable', async () => { test('GET /api/images/v6 serves the raw SavedImage by name', async () => {
const img = await createImage(env.DB, { const img = await createImage(env.DB, {
imageName: 'v6shape.jpg', imageName: 'v6shape.jpg',
playerId: 7301, playerId: 7301,
@@ -7038,24 +7038,98 @@ describe('images', () => {
}) })
const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`) const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
// The client's 13-key DTO: `Id` and `Type`, `TaggedPlayerIds`, nulls left null — the
// same record `v6/:id` serves.
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
SavedImageId: img.Id, Id: img.Id,
ImageName: 'v6shape.jpg', ImageName: 'v6shape.jpg',
Description: null,
PlayerId: 7301, PlayerId: 7301,
// Nulls on the row come out as 0 / "" — the client's DTO has no null to put there. RoomId: null,
RoomId: 0, PlayerEventId: null,
PlayerEventId: 0,
ClubId: 0,
Description: '',
Accessibility: 1, Accessibility: 1,
AccessibilityLocked: false, AccessibilityLocked: false,
SavedImageType: 1, Type: 1,
CreatedAt: img.CreatedAt, CreatedAt: img.CreatedAt,
TaggedPlayerIds: [],
CheerCount: 0, CheerCount: 0,
CommentCount: 0, CommentCount: 0,
}) })
}) })
test('POST /api/images/v1/:id/report files a bodiless report against the photo’s author', async () => {
const img = await createImage(env.DB, { imageName: 'reported.jpg', playerId: 7310 })
const report = async (id: number, sub?: string) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/${id}/report`, {
method: 'POST',
headers: sub ? await bearer(sub) : undefined,
})
expect((await report(img.Id)).status).toBe(401)
const unknown = await report(99999999, '7311')
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such image' })
const res = await report(img.Id, '7311')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const { results } = await env.DB.prepare('SELECT * FROM report WHERE image_id = ?1')
.bind(img.Id)
.all()
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
reporter_player_id: 7311,
reported_player_id: 7310,
image_id: img.Id,
report_category: 0,
details: null,
event_id: null,
invention_id: null,
custom_avatar_item_id: null,
chat_message_id: null,
})
})
test('GET /api/images/v6/:id serves the raw SavedImage, private ones to their owner only', async () => {
const img = await createImage(env.DB, { imageName: 'v6byid.jpg', playerId: 7302 })
const get = async (id: number, sub?: string) =>
exports.default.fetch(
`${ORIGIN}/api/images/v6/${id}`,
sub ? { headers: await bearer(sub) } : undefined
)
// The 13-key record, nulls left null.
const res = await get(img.Id)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Id: img.Id,
ImageName: 'v6byid.jpg',
Description: null,
PlayerId: 7302,
RoomId: null,
PlayerEventId: null,
Accessibility: 1,
AccessibilityLocked: false,
Type: 1,
CreatedAt: img.CreatedAt,
TaggedPlayerIds: [],
CheerCount: 0,
CommentCount: 0,
})
expect((await get(99999999)).status).toBe(404)
// Made private, it is its owner's alone — ids are sequential.
await env.DB.prepare(
"UPDATE image SET data = json_set(data, '$.Accessibility', 0) WHERE id = ?1"
)
.bind(img.Id)
.run()
expect((await get(img.Id)).status).toBe(404)
expect((await get(img.Id, '7303')).status).toBe(404)
expect((await get(img.Id, '7302')).status).toBe(200)
})
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => { test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400) expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
expect( expect(
@@ -7087,23 +7161,20 @@ describe('images', () => {
const meta = (await ( const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`) await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as { ).json()) as {
SavedImageType: number Type: number
RoomId: number RoomId: number
Accessibility: number Accessibility: number
PlayerEventId: number PlayerEventId: number | null
ClubId: number TaggedPlayerIds: number[]
Description: string
} }
expect(meta.SavedImageType).toBe(1) expect(meta.Type).toBe(1)
expect(meta.RoomId).toBe(777) expect(meta.RoomId).toBe(777)
expect(meta.Accessibility).toBe(2) expect(meta.Accessibility).toBe(2)
// v6 carries no TaggedPlayerIds — the upload still records them, which the stored row // v6 serves the stored record as it is: the tagged players ride along, a "none"
// below proves. Nothing on this projection is nullable: a "none" event reads 0, not // event is null rather than 0, and there is no `ClubId`.
// null, and the club (which nothing here sets) reads 0 too. expect(meta.TaggedPlayerIds).toEqual([5, 6])
expect(meta).not.toHaveProperty('TaggedPlayerIds') expect(meta.PlayerEventId).toBeNull()
expect(meta.PlayerEventId).toBe(0) expect(meta).not.toHaveProperty('ClubId')
expect(meta.ClubId).toBe(0)
expect(meta.Description).toBe('')
// The tagged players and the null event id, as actually stored. // The tagged players and the null event id, as actually stored.
const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1') const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1')
@@ -7114,7 +7185,7 @@ describe('images', () => {
PlayerEventId: number | null PlayerEventId: number | null
} }
expect(stored.TaggedPlayerIds).toEqual([5, 6]) expect(stored.TaggedPlayerIds).toEqual([5, 6])
// playerEventId 0 means "none" → stored as null, and serialized back out as 0. // playerEventId 0 means "none" → stored as null.
expect(stored.PlayerEventId).toBeNull() expect(stored.PlayerEventId).toBeNull()
}) })
@@ -10011,6 +10082,7 @@ describe('openapi', () => {
'GET /api/images/v5/cheered/bulk', 'GET /api/images/v5/cheered/bulk',
'GET /api/images/v5/player/{playerId}', 'GET /api/images/v5/player/{playerId}',
'GET /api/images/v6', 'GET /api/images/v6',
'GET /api/images/v6/{id}',
'GET /api/inventions/v1', 'GET /api/inventions/v1',
'GET /api/inventions/v1/details', 'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured', 'GET /api/inventions/v1/featured',
@@ -10095,6 +10167,7 @@ describe('openapi', () => {
'POST /api/images/v1/cheer', 'POST /api/images/v1/cheer',
'POST /api/images/v1/deletesaved', 'POST /api/images/v1/deletesaved',
'POST /api/images/v1/modifydescription', 'POST /api/images/v1/modifydescription',
'POST /api/images/v1/{id}/report',
'POST /api/images/v2/modifyaccessibility', 'POST /api/images/v2/modifyaccessibility',
'POST /api/images/v4/uploadsaved', 'POST /api/images/v4/uploadsaved',
'POST /api/images/v5/cheered/bulk', 'POST /api/images/v5/cheered/bulk',
+3 -1
View File
@@ -94,6 +94,7 @@ interface ReportRow {
banned_by_player_id: number | null banned_by_player_id: number | null
banned_at: string | null banned_at: string | null
chat_message_id: number | null chat_message_id: number | null
image_id: number | null
/** /**
* Who lifted the ban that once stood on this row, and when — null until one is lifted, * Who lifted the ban that once stood on this row, and when — null until one is lifted,
* and cleared again by a re-ban. A lift wipes the four ban columns, so this pair is the * and cleared again by a re-ban. A lift wipes the four ban columns, so this pair is the
@@ -230,7 +231,7 @@ function LiftedBan({ report, names }: { report: ReportRow; names: Map<number, Pu
} }
/** /**
* What kind of thing a report is against. The four id columns are mutually exclusive and * What kind of thing a report is against. The five id columns are mutually exclusive and
* a row with none of them is an ordinary player report (see reports-db) — which is the * a row with none of them is an ordinary player report (see reports-db) — which is the
* only way to tell the kinds apart, so it is worth a column of its own. * only way to tell the kinds apart, so it is worth a column of its own.
*/ */
@@ -239,6 +240,7 @@ function reportKind(report: ReportRow): string {
if (report.invention_id !== null) return 'Invention' if (report.invention_id !== null) return 'Invention'
if (report.custom_avatar_item_id !== null) return 'Avatar item' if (report.custom_avatar_item_id !== null) return 'Avatar item'
if (report.chat_message_id !== null) return 'Chat message' if (report.chat_message_id !== null) return 'Chat message'
if (report.image_id != null) return 'Photo'
return 'Player' return 'Player'
} }
+10 -49
View File
@@ -203,6 +203,16 @@ export async function getImageByName(db: D1Database, name: string): Promise<Save
return row ? (JSON.parse(row.data) as SavedImage) : null return row ? (JSON.parse(row.data) as SavedImage) : null
} }
/**
* Look up one image record by id, or null. Whatever its accessibility — unlike
* {@link getImagesByIds} this does not filter, so the caller decides who may see a
* private one.
*/
export async function getImageById(db: D1Database, id: number): Promise<SavedImage | null> {
const row = await db.prepare('SELECT data FROM image WHERE id = ?1').bind(id).first<ImageRow>()
return row ? (JSON.parse(row.data) as SavedImage) : null
}
/** /**
* Look up image records by name (the R2 key), returned keyed by ImageName. One query * Look up image records by name (the R2 key), returned keyed by ImageName. One query
* for the whole set; names with no record are simply absent from the map. * for the whole set; names with no record are simply absent from the map.
@@ -417,55 +427,6 @@ export async function getImagesByPlayer(
.slice(skip, skip + take) .slice(skip, skip + take)
} }
/**
* An image's metadata as `GET /api/images/v6` serves it — the by-name lookup's shape.
*
* A THIRD projection of the same row, and deliberately not either of the other two: it
* renames like `ImagesPlayer` (`Id` → `SavedImageId`, `Type` → `SavedImageType`, no
* `TaggedPlayerIds`) but adds `ClubId`, and its numbers and strings are never null —
* `RoomId`, `PlayerEventId` and `ClubId` come out as 0 and `Description` as `""` where the
* row holds null. The reference's DTO declares them non-nullable, so a null is a decode
* failure rather than "none".
*
* `ClubId` is always 0: nothing here associates an image with a club.
*/
export interface ImageMetadata {
SavedImageId: number
ImageName: string
PlayerId: number
RoomId: number
PlayerEventId: number
ClubId: number
Description: string
Accessibility: number
AccessibilityLocked: boolean
SavedImageType: number
CreatedAt: string
CheerCount: number
CommentCount: number
}
/** Project a stored image into the {@link ImageMetadata} shape `/api/images/v6` answers. */
export function toImageMetadata(img: SavedImage): ImageMetadata {
return {
SavedImageId: img.Id,
ImageName: img.ImageName,
PlayerId: img.PlayerId,
// Null means "not taken in a room" / "no event"; the client's DTO has no null to put
// there, and 0 is the id it treats as none.
RoomId: img.RoomId ?? 0,
PlayerEventId: img.PlayerEventId ?? 0,
ClubId: 0,
Description: img.Description ?? '',
Accessibility: img.Accessibility,
AccessibilityLocked: img.AccessibilityLocked,
SavedImageType: img.Type,
CreatedAt: img.CreatedAt,
CheerCount: img.CheerCount,
CommentCount: img.CommentCount,
}
}
/** /**
* The client-facing projection of a saved image for the player photo lists (the * The client-facing projection of a saved image for the player photo lists (the
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type * reference's `ImagesPlayer`). Same data as the stored record, but the id and type