mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[api] add CAI reporting, CircuitChipList path
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
-- Reporting a CUSTOM AVATAR ITEM (`POST /api/customAvatarItems/v1/{id}/report`) reuses the
|
||||||
|
-- report table, exactly as the event and invention reports beside it do: same fields, same
|
||||||
|
-- moderation life — a moderator acting on one sets `banned` on the row as they would for a
|
||||||
|
-- player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- `custom_avatar_item_id` names the reported item. TEXT, not INTEGER, because a custom
|
||||||
|
-- avatar item is keyed by a GUID (`aeef6bfa-09b1-4859-a77c-47a6e7523543`) where an event and
|
||||||
|
-- an invention are keyed by a number — the three columns are the same idea in three key
|
||||||
|
-- types, which is why they are three columns rather than one polymorphic id.
|
||||||
|
--
|
||||||
|
-- NULL on every other kind of report. It joins `event_id` and `invention_id`, and all three
|
||||||
|
-- are mutually exclusive: a row names an event, an invention, an item, or none of them (an
|
||||||
|
-- ordinary player report), which is what tells the kinds apart.
|
||||||
|
--
|
||||||
|
-- The row's `reported_player_id` is the item's CREATOR, read from the item rather than sent
|
||||||
|
-- by the client. The client sends `ReportedPlayerId: null` on this route — it does not know
|
||||||
|
-- who made the item — and the column is NOT NULL, so deriving it is the only way to fill it,
|
||||||
|
-- and "who is answerable for this item" is the only honest answer anyway.
|
||||||
|
--
|
||||||
|
-- No `room_id`: an item is not tied to one room the way an event is.
|
||||||
|
--
|
||||||
|
-- NOT indexed, for the same reason 0016 gave and then dropped 0011's index over `event_id`:
|
||||||
|
-- it is written on every report of this kind and read by nothing. Every moderation read goes
|
||||||
|
-- by player (`idx_report_reported`) or by the ban flag. Add an index with the query needing it.
|
||||||
|
|
||||||
|
ALTER TABLE report ADD COLUMN custom_avatar_item_id TEXT;
|
||||||
@@ -249,6 +249,103 @@ export async function listHotCustomAvatarItems(
|
|||||||
return results.map(toDto)
|
return results.map(toDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Coach" system account — this server's stock content is authored by it, the same id the
|
||||||
|
* `econ` worker attributes a self-buy or an anonymous gift to.
|
||||||
|
*/
|
||||||
|
export const COACH_ACCOUNT_ID = 1
|
||||||
|
|
||||||
|
/** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */
|
||||||
|
export interface CustomAvatarItemSearch {
|
||||||
|
/** Free text, matched against an item's NAME or its DESCRIPTION. Blank means no filter. */
|
||||||
|
searchQuery?: string
|
||||||
|
/**
|
||||||
|
* `OutfitType`s to include. EMPTY means no filter rather than no results: the client sends
|
||||||
|
* the full set of types it can render, so an absent parameter is "everything", not "nothing".
|
||||||
|
*/
|
||||||
|
outfitTypes?: number[]
|
||||||
|
/** Whether items authored by the Coach — this server's stock content — are included. */
|
||||||
|
includeCoachItems?: boolean
|
||||||
|
/** Lowest price to include, inclusive. */
|
||||||
|
minPrice?: number
|
||||||
|
/** Highest price to include, inclusive. */
|
||||||
|
maxPrice?: number
|
||||||
|
/** Rows to skip, for paging. */
|
||||||
|
skip?: number
|
||||||
|
/** Rows to return. Capped at {@link SEARCH_MAX_TAKE}. */
|
||||||
|
take?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most rows one search returns, whatever `take` asks for. The client asks for 100. */
|
||||||
|
export const SEARCH_MAX_TAKE = 200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The store's item search (`GET /api/customAvatarItems/v1/search`), newest first.
|
||||||
|
*
|
||||||
|
* PUBLISHED items only — `Accessibility` 0 is the unpublished state, and this is the browse
|
||||||
|
* surface everyone shares, so an unpublished item must not appear here even to its creator (who
|
||||||
|
* has `fromCreator` for that).
|
||||||
|
*
|
||||||
|
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring.
|
||||||
|
* Both sides are lowered rather than relying on `LIKE`, which folds case for ASCII only and
|
||||||
|
* would miss half of what players type. `%` and `_` in the needle are escaped, so searching for
|
||||||
|
* a literal one finds it instead of matching everything.
|
||||||
|
*
|
||||||
|
* `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of
|
||||||
|
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
|
||||||
|
* an absent parameter as "match nothing" would empty the store.
|
||||||
|
*
|
||||||
|
* Ordered by recency because there is nothing else to order by — no purchase counts, no wear
|
||||||
|
* counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The
|
||||||
|
* `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a
|
||||||
|
* `created_at` can swap places between pages and one is served twice while the other is missed.
|
||||||
|
*/
|
||||||
|
export async function searchCustomAvatarItems(
|
||||||
|
db: D1Database,
|
||||||
|
search: CustomAvatarItemSearch = {}
|
||||||
|
): Promise<CustomAvatarItem[]> {
|
||||||
|
const take = Math.min(Math.max(search.take ?? 50, 0), SEARCH_MAX_TAKE)
|
||||||
|
const skip = Math.max(search.skip ?? 0, 0)
|
||||||
|
if (take === 0) return []
|
||||||
|
|
||||||
|
const where = ['accessibility != 0']
|
||||||
|
const binds: Array<number | string> = []
|
||||||
|
/** Bind a value and get its placeholder, so the numbering can't drift as clauses are added. */
|
||||||
|
const bind = (value: number | string): string => `?${binds.push(value)}`
|
||||||
|
|
||||||
|
const needle = search.searchQuery?.trim() ?? ''
|
||||||
|
if (needle !== '') {
|
||||||
|
// Escaped so a needle of LIKE metacharacters matches them literally rather than everything.
|
||||||
|
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
|
||||||
|
const pattern = bind(`%${escaped}%`)
|
||||||
|
where.push(
|
||||||
|
`(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outfitTypes = search.outfitTypes ?? []
|
||||||
|
if (outfitTypes.length > 0) {
|
||||||
|
where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`)
|
||||||
|
}
|
||||||
|
if (search.includeCoachItems === false) {
|
||||||
|
where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`)
|
||||||
|
}
|
||||||
|
if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`)
|
||||||
|
if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`)
|
||||||
|
|
||||||
|
const limit = bind(take)
|
||||||
|
const offset = bind(skip)
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC, custom_avatar_item_id
|
||||||
|
LIMIT ${limit} OFFSET ${offset}`
|
||||||
|
)
|
||||||
|
.bind(...binds)
|
||||||
|
.all<Row>()
|
||||||
|
return results.map(toDto)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What an account has authored (`GET /api/customAvatarItems/v2/fromCreator/:id`), newest
|
* What an account has authored (`GET /api/customAvatarItems/v2/fromCreator/:id`), newest
|
||||||
* first, with the total for the client's paginated envelope. `includeUnpublished` is for
|
* first, with the total for the client's paginated envelope. `includeUnpublished` is for
|
||||||
|
|||||||
@@ -344,12 +344,30 @@ export async function ownsAllInventions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invention search — the browse/search list the client shows when picking an
|
* Invention search — the browse/search list the client shows when picking an invention to
|
||||||
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
* spawn. Only published, non-hidden inventions are visible here (a player's own unpublished
|
||||||
* player's own unpublished ones come from `getInventionsByCreator`). `value` is
|
* ones come from `getInventionsByCreator`). Newest first, paginated via skip/take. Returns a
|
||||||
* matched case-insensitively against the name and description, term by term; an
|
* bare array — the shape the client expects from v2/search.
|
||||||
* empty `value` browses everything published. Paginated via skip/take, newest
|
*
|
||||||
* first. Returns a bare array — the shape the client expects from v2/search.
|
* `value` is split into terms on whitespace and `+`, and EVERY term must match (AND, not OR),
|
||||||
|
* which is what makes typing more words narrow the list. Each is matched case-insensitively
|
||||||
|
* against the NAME and the DESCRIPTION.
|
||||||
|
*
|
||||||
|
* Filtered, ordered and paged entirely IN SQL. It used to read every published invention into
|
||||||
|
* memory, filter there and slice — which meant the cost of a search grew with the whole
|
||||||
|
* catalogue no matter how narrow the query or how small the page, and a browse screen asking
|
||||||
|
* for 100 rows paid for all of them. `Name`, `Description` and `CreatedAt` live inside the JSON
|
||||||
|
* blob, so they are reached with `json_extract`; `is_published`/`hide_from_player` are already
|
||||||
|
* generated columns.
|
||||||
|
*
|
||||||
|
* Both sides of the comparison are lowered rather than leaning on `LIKE`, which folds case for
|
||||||
|
* ASCII only — and invention names are full of things it would not fold. `%` and `_` in a term
|
||||||
|
* are escaped so a player searching for one finds it instead of matching everything.
|
||||||
|
*
|
||||||
|
* A term starting with `#` is NOT special here: the browse screen's filter chips send `#small`,
|
||||||
|
* and a tag appears in no name or description, so those searches find nothing. Matching tags
|
||||||
|
* needs them out of the JSON blob and into something indexable first; until then this stays a
|
||||||
|
* text search rather than one that scans every row to look at its tags.
|
||||||
*/
|
*/
|
||||||
export async function searchInventions(
|
export async function searchInventions(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -357,22 +375,39 @@ export async function searchInventions(
|
|||||||
skip: number,
|
skip: number,
|
||||||
take: number
|
take: number
|
||||||
): Promise<SavedInvention[]> {
|
): Promise<SavedInvention[]> {
|
||||||
let inventions = await publicInventions(db)
|
const limit = Math.max(take, 0)
|
||||||
|
const offset = Math.max(skip, 0)
|
||||||
|
if (limit === 0) return []
|
||||||
|
|
||||||
const terms = value
|
const where = ['is_published = 1', 'hide_from_player = 0']
|
||||||
|
const binds: Array<string | number> = []
|
||||||
|
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
|
||||||
|
const bind = (v: string | number): string => `?${binds.push(v)}`
|
||||||
|
|
||||||
|
for (const term of value
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split(/[\s+]+/)
|
.split(/[\s+]+/)
|
||||||
.filter(Boolean)
|
.filter(Boolean)) {
|
||||||
for (const term of terms) {
|
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
|
||||||
inventions = inventions.filter(
|
const pattern = bind(`%${escaped}%`)
|
||||||
(i) => i.Name.toLowerCase().includes(term) || i.Description.toLowerCase().includes(term)
|
where.push(
|
||||||
|
`(lower(json_extract(data, '$.Name')) LIKE ${pattern} ESCAPE '\\'` +
|
||||||
|
` OR lower(json_extract(data, '$.Description')) LIKE ${pattern} ESCAPE '\\')`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return inventions
|
const limitAt = bind(limit)
|
||||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
const offsetAt = bind(offset)
|
||||||
.slice(skip, skip + take)
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT data FROM invention WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
|
||||||
|
LIMIT ${limitAt} OFFSET ${offsetAt}`
|
||||||
|
)
|
||||||
|
.bind(...binds)
|
||||||
|
.all<InventionRow>()
|
||||||
|
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -690,6 +725,14 @@ export async function getInventionsByRoom(
|
|||||||
.slice(skip, skip + take)
|
.slice(skip, skip + take)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `version` that means "whichever is current" rather than a version number to match.
|
||||||
|
*
|
||||||
|
* Zero is not a version any invention has — a fresh save is version 1 — so a caller sending
|
||||||
|
* it does not know which version it wants, and reading it literally finds nothing.
|
||||||
|
*/
|
||||||
|
const CURRENT_INVENTION_VERSION = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single version of an invention (`v1/version?inventionId=…&version=…`), which
|
* A single version of an invention (`v1/version?inventionId=…&version=…`), which
|
||||||
* is how the client resolves the blob to download for a given version number.
|
* is how the client resolves the blob to download for a given version number.
|
||||||
@@ -698,6 +741,13 @@ export async function getInventionsByRoom(
|
|||||||
* (there's no `v4/addversion` yet), and a fresh save is always version 1. So this
|
* (there's no `v4/addversion` yet), and a fresh save is always version 1. So this
|
||||||
* answers for the current version number and reports null for any other, rather
|
* answers for the current version number and reports null for any other, rather
|
||||||
* than inventing a version whose blob doesn't exist.
|
* than inventing a version whose blob doesn't exist.
|
||||||
|
*
|
||||||
|
* VERSION 0 is the exception: it means "whichever version is current" rather than a
|
||||||
|
* version number to match, and gets {@link CURRENT_INVENTION_VERSION}. The client asks
|
||||||
|
* for 0 when it has an invention id but no version to go with it — a discovery row or a
|
||||||
|
* spawn that carries the id alone — and there is no version 0 to find, so matching it
|
||||||
|
* literally 404s and the invention silently fails to load. Answering with the current
|
||||||
|
* version is what it would have asked for had it known the number.
|
||||||
*/
|
*/
|
||||||
export async function getInventionVersion(
|
export async function getInventionVersion(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -707,7 +757,12 @@ export async function getInventionVersion(
|
|||||||
): Promise<InventionVersion | null> {
|
): Promise<InventionVersion | null> {
|
||||||
const invention = await getInventionById(db, inventionId)
|
const invention = await getInventionById(db, inventionId)
|
||||||
if (invention === null) return null
|
if (invention === null) return null
|
||||||
if (invention.CurrentVersionNumber !== versionNumber) return null
|
if (
|
||||||
|
versionNumber !== CURRENT_INVENTION_VERSION &&
|
||||||
|
invention.CurrentVersionNumber !== versionNumber
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// A version saved before its blob finished uploading (or before we hashed on
|
// A version saved before its blob finished uploading (or before we hashed on
|
||||||
// save at all) carries no hash. Hash it now and keep the result, so the other
|
// save at all) carries no hash. Hash it now and keep the result, so the other
|
||||||
|
|||||||
+19
-1
@@ -577,7 +577,7 @@ export const BulkCustomAvatarItemsRequest = z.object({
|
|||||||
.describe('The ids to resolve; repeat the field once per id'),
|
.describe('The ids to resolve; repeat the field once per id'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
|
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
|
||||||
export const CustomAvatarItemsPage = z.object({
|
export const CustomAvatarItemsPage = z.object({
|
||||||
Results: CustomAvatarItemList,
|
Results: CustomAvatarItemList,
|
||||||
TotalResults: z.int(),
|
TotalResults: z.int(),
|
||||||
@@ -972,6 +972,24 @@ export const PlayerEventReportRequest = z.object({
|
|||||||
* body: it's the bearer token's player, and neither is the invention's creator, who is
|
* body: it's the bearer token's player, and neither is the invention's creator, who is
|
||||||
* read from the invention.
|
* read from the invention.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* `POST /api/customAvatarItems/v1/{id}/report` JSON body. The item is named by the PATH, not
|
||||||
|
* the body, and `ReportedPlayerId` arrives NULL — the client does not know who made the item,
|
||||||
|
* so the creator is read off the item instead.
|
||||||
|
*/
|
||||||
|
export const CustomAvatarItemReportRequest = z.object({
|
||||||
|
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'),
|
||||||
|
ReportedPlayerId: z
|
||||||
|
.int()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.describe('Sent as null and IGNORED — the reported player is the item’s creator'),
|
||||||
|
})
|
||||||
|
|
||||||
export const InventionReportRequest = z.object({
|
export const InventionReportRequest = z.object({
|
||||||
InventionId: z.int().describe('The invention being reported'),
|
InventionId: z.int().describe('The invention being reported'),
|
||||||
ReportCategory: z
|
ReportCategory: z
|
||||||
|
|||||||
+33
-17
@@ -7,15 +7,17 @@
|
|||||||
* 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, 0011_report_event.sql and 0016_report_invention.sql, applied under
|
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and
|
||||||
* its own `migrations_table` so it doesn't clash with the other workers' migrations that
|
* 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it
|
||||||
* share the database).
|
* doesn't clash with the other workers' migrations that share the database).
|
||||||
*
|
*
|
||||||
* A reported player EVENT or INVENTION lands here too, rather than in a table of its own:
|
* A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a
|
||||||
* same fields, same moderation life. Such a row carries `event_id` or `invention_id`, and
|
* table of its own: same fields, same moderation life. Such a row carries `event_id`,
|
||||||
* its `reported_player_id` is that thing's CREATOR — see
|
* `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's
|
||||||
* `POST /api/playerevents/v1/report` and `POST /api/inventions/v1/report`. The two id
|
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
|
||||||
* columns are mutually exclusive; a row with neither is an ordinary player report.
|
* `POST /api/customAvatarItems/v1/{id}/report`. The three id columns are mutually exclusive;
|
||||||
|
* a row with none of them is an ordinary player report. They are three columns rather than
|
||||||
|
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
|
||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -30,12 +32,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql).
|
||||||
*
|
*
|
||||||
* Neither `event_id` nor `invention_id` is indexed: both are written on every report of
|
* None of `event_id`, `invention_id` or `custom_avatar_item_id` is indexed: each is written
|
||||||
* their kind and read by nothing — no query here filters on either, and the reads that do
|
* on every report of its kind and read by nothing — no query here filters on any of them,
|
||||||
* exist go by player or by the ban flag. 0011's partial index over `event_id` was dropped
|
* and the reads that do exist go by player or by the ban flag. 0011's partial index over
|
||||||
* in 0016 rather than mirrored. Add one back alongside the query that needs it.
|
* `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 (
|
||||||
@@ -52,7 +55,8 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
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
|
invention_id INTEGER,
|
||||||
|
custom_avatar_item_id TEXT
|
||||||
)`,
|
)`,
|
||||||
`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)`,
|
||||||
@@ -90,6 +94,15 @@ export interface ReportRow {
|
|||||||
* invention isn't tied to one room the way an event is.
|
* invention isn't tied to one room the way an event is.
|
||||||
*/
|
*/
|
||||||
invention_id: number | null
|
invention_id: number | null
|
||||||
|
/**
|
||||||
|
* The custom avatar item this report is against, or NULL for any other kind — mutually
|
||||||
|
* exclusive with the two above. TEXT because such an item is keyed by a GUID where an
|
||||||
|
* event and an invention are keyed by numbers. See
|
||||||
|
* `POST /api/customAvatarItems/v1/{id}/report`: `reported_player_id` is the item's
|
||||||
|
* creator, read from the item, because the client sends `ReportedPlayerId: null` here —
|
||||||
|
* it does not know who made it.
|
||||||
|
*/
|
||||||
|
custom_avatar_item_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,6 +124,8 @@ export interface NewReport {
|
|||||||
eventId?: number | null
|
eventId?: number | null
|
||||||
/** Set only when reporting an INVENTION; never set alongside `eventId`. */
|
/** Set only when reporting an INVENTION; never set alongside `eventId`. */
|
||||||
inventionId?: number | null
|
inventionId?: number | null
|
||||||
|
/** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */
|
||||||
|
customAvatarItemId?: string | 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). */
|
||||||
@@ -120,8 +135,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
|
event_id, invention_id, custom_avatar_item_id
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||||
RETURNING *`
|
RETURNING *`
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
@@ -135,7 +150,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
|||||||
input.roomInstanceType ?? null,
|
input.roomInstanceType ?? null,
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
input.eventId ?? null,
|
input.eventId ?? null,
|
||||||
input.inventionId ?? null
|
input.inventionId ?? null,
|
||||||
|
input.customAvatarItemId ?? 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
|
||||||
|
|||||||
+288
-21
@@ -16,9 +16,11 @@ import {
|
|||||||
createCustomAvatarItem,
|
createCustomAvatarItem,
|
||||||
deleteCustomAvatarItem,
|
deleteCustomAvatarItem,
|
||||||
getCustomAvatarItem,
|
getCustomAvatarItem,
|
||||||
|
getCustomAvatarItems,
|
||||||
listCustomAvatarItemsByCreator,
|
listCustomAvatarItemsByCreator,
|
||||||
listFeaturedCustomAvatarItems,
|
listFeaturedCustomAvatarItems,
|
||||||
listHotCustomAvatarItems,
|
listHotCustomAvatarItems,
|
||||||
|
searchCustomAvatarItems,
|
||||||
updateCustomAvatarItem,
|
updateCustomAvatarItem,
|
||||||
} from '../custom-avatar-items-db'
|
} from '../custom-avatar-items-db'
|
||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
@@ -49,6 +51,7 @@ import {
|
|||||||
BulkCustomAvatarItemsRequest,
|
BulkCustomAvatarItemsRequest,
|
||||||
CreateCustomAvatarItemRequest,
|
CreateCustomAvatarItemRequest,
|
||||||
CustomAvatarItemList,
|
CustomAvatarItemList,
|
||||||
|
CustomAvatarItemReportRequest,
|
||||||
CustomAvatarItemResponse,
|
CustomAvatarItemResponse,
|
||||||
CustomAvatarItemsPage,
|
CustomAvatarItemsPage,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
@@ -92,6 +95,42 @@ import type { Context } from 'hono'
|
|||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
import type { SavedInvention } from '../inventions-db'
|
import type { SavedInvention } from '../inventions-db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
|
||||||
|
* EMPTY rather than being truncated.
|
||||||
|
*
|
||||||
|
* Empty rather than the first 100, because a truncated answer is indistinguishable from the
|
||||||
|
* items simply not existing — the client reads the items it got back, not the ids it asked
|
||||||
|
* about, so it cannot tell a cut-off batch from a batch of misses and would cache the
|
||||||
|
* difference. Nothing renders this many custom items at once, so a batch this size is the
|
||||||
|
* client doing something other than filling a screen.
|
||||||
|
*/
|
||||||
|
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
|
||||||
|
* `customAvatarItemIds` form fields, and the same spelling is read off the query string
|
||||||
|
* too — the client's exact encoding here has not been pinned down, so both are accepted
|
||||||
|
* rather than guessing one and answering nothing when it's the other.
|
||||||
|
*
|
||||||
|
* Each value may itself be a comma-separated list, and blanks are dropped rather than
|
||||||
|
* failing the request: a stray id must not cost the caller the rest of the batch. The order
|
||||||
|
* asked for is preserved, since `getCustomAvatarItems` answers in it.
|
||||||
|
*/
|
||||||
|
async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
||||||
|
const raw = [...(c.req.queries('customAvatarItemIds') ?? [])]
|
||||||
|
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||||
|
const key = Object.keys(body).find((k) => k.toLowerCase() === 'customavataritemids')
|
||||||
|
const posted = key === undefined ? [] : body[key]
|
||||||
|
for (const value of Array.isArray(posted) ? posted : [posted]) {
|
||||||
|
if (typeof value === 'string') raw.push(value)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
.flatMap((value) => value.split(','))
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v !== '')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The gate every invention write runs through: the caller must be signed in, the
|
* The gate every invention write runs through: the caller must be signed in, the
|
||||||
* invention must exist, and it must be theirs. Yields the loaded invention, or the
|
* invention must exist, and it must be theirs. Yields the loaded invention, or the
|
||||||
@@ -450,6 +489,122 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
|
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The store's item search. The client sends the full set of `outfitTypes` it can render
|
||||||
|
// plus paging, and expects a BARE ARRAY of items back — not the `{ Results, TotalResults }`
|
||||||
|
// envelope `fromCreator` uses.
|
||||||
|
//
|
||||||
|
// Several parameters are accepted and not yet acted on; they are listed in the description
|
||||||
|
// rather than dropped silently, because a caller cannot tell the difference between a filter
|
||||||
|
// that was applied and one that was ignored by looking at the results.
|
||||||
|
.get(
|
||||||
|
'/api/customAvatarItems/v1/search',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Avatar'],
|
||||||
|
summary: 'Search custom avatar items',
|
||||||
|
description: [
|
||||||
|
'The store’s item search: published items (`Accessibility` 0 is unpublished and is',
|
||||||
|
'left out, from its creator too — `fromCreator` is where they see their own),',
|
||||||
|
'newest first, as a BARE ARRAY.',
|
||||||
|
'`searchQuery` matches an item’s NAME or its DESCRIPTION, case-insensitively, as a',
|
||||||
|
'substring; `%` and `_` in it are literal.',
|
||||||
|
'`outfitTypes` may repeat and acts as a whitelist; sending none means no filter',
|
||||||
|
'rather than no results, since the client sends every type it can render.',
|
||||||
|
'`minPrice`/`maxPrice` bound the price, inclusive.',
|
||||||
|
'`skip`/`take` page the results, `take` capped at 200.',
|
||||||
|
'`includeCoachItems=false` leaves out this server’s stock content.',
|
||||||
|
'`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and',
|
||||||
|
'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target',
|
||||||
|
'asset variants are stored, and custom avatar items are the only item type there is.',
|
||||||
|
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
|
||||||
|
'whatever it says.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'searchQuery',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Free text matched against the item’s name or description',
|
||||||
|
schema: { type: 'string' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'outfitTypes',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'OutfitType to include; repeat for several. None means all.',
|
||||||
|
schema: { type: 'array', items: { type: 'integer' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'skip',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Rows to skip (default 0)',
|
||||||
|
schema: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'take',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Rows to return (default 50, capped at 200)',
|
||||||
|
schema: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'minPrice',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Lowest price to include, inclusive',
|
||||||
|
schema: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'maxPrice',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Highest price to include, inclusive',
|
||||||
|
schema: { type: 'integer', minimum: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'includeCoachItems',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Include the Coach’s stock items (default true)',
|
||||||
|
schema: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
// `?outfitTypes=0&outfitTypes=2&…` — repeated, so read every value. A non-numeric one is
|
||||||
|
// dropped rather than turned into NaN, which would match nothing and quietly empty a
|
||||||
|
// filter the caller believes they set.
|
||||||
|
const outfitTypes = c.req
|
||||||
|
.queries('outfitTypes')
|
||||||
|
?.map((v) => Number.parseInt(v, 10))
|
||||||
|
.filter((n) => Number.isInteger(n))
|
||||||
|
|
||||||
|
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
|
||||||
|
// before comparing; anything that isn't recognisably false leaves the default alone.
|
||||||
|
const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false'
|
||||||
|
|
||||||
|
const int = (name: string): number | undefined => {
|
||||||
|
const raw = c.req.query(name)
|
||||||
|
if (raw === undefined) return undefined
|
||||||
|
const n = Number.parseInt(raw, 10)
|
||||||
|
return Number.isInteger(n) ? n : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
await searchCustomAvatarItems(c.env.DB, {
|
||||||
|
searchQuery: c.req.query('searchQuery'),
|
||||||
|
outfitTypes,
|
||||||
|
includeCoachItems,
|
||||||
|
minPrice: int('minPrice'),
|
||||||
|
maxPrice: int('maxPrice'),
|
||||||
|
skip: int('skip'),
|
||||||
|
take: int('take'),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0)
|
// The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0)
|
||||||
// item from the `custom_avatar_item` table. There is nothing to rank a trend from yet,
|
// item from the `custom_avatar_item` table. There is nothing to rank a trend from yet,
|
||||||
// so it is the accessible items, newest first.
|
// so it is the accessible items, newest first.
|
||||||
@@ -467,13 +622,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
async (c) => c.json(await listHotCustomAvatarItems(c.env.DB))
|
async (c) => c.json(await listHotCustomAvatarItems(c.env.DB))
|
||||||
)
|
)
|
||||||
|
|
||||||
// A batch lookup of custom avatar items by id. The reference filters a static catalog
|
// A batch lookup of custom avatar items by id, out of the `custom_avatar_item` table.
|
||||||
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
|
// The reference filters its catalog down to the posted ids and returns the MATCHES AS A
|
||||||
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
|
// BARE ARRAY — not the `{ Results, TotalResults }` page that catalog is written in, and
|
||||||
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
|
// not a 404 for ids it doesn't hold.
|
||||||
// ships empty too), so every id misses and the array is empty.
|
//
|
||||||
|
// This is how a `1.<guid>` entity in a GENERIC discovery row (`lists`
|
||||||
|
// `/algorithmiclists/:list?type=5`) gets resolved, so a row naming a custom item renders
|
||||||
|
// nothing at all when this doesn't answer. It stubbed out `[]` while nothing stored custom
|
||||||
|
// items; the table has existed since migration 0015 and the stub outlived it.
|
||||||
//
|
//
|
||||||
// Auth-gated, and the token is checked before anything else, as the reference does.
|
// Auth-gated, and the token is checked before anything else, as the reference does.
|
||||||
|
//
|
||||||
|
// A batch over {@link BULK_CUSTOM_AVATAR_ITEM_CAP} ids answers EMPTY. The client has been
|
||||||
|
// seen posting far more ids than a screen could draw, and serving those is both a large
|
||||||
|
// query and a large response for a request that is already not what it looks like. Empty is
|
||||||
|
// the safe answer because a miss here is not an error: unknown ids are simply absent, so the
|
||||||
|
// client already handles getting back fewer items than it asked about.
|
||||||
.post(
|
.post(
|
||||||
'/api/customAvatarItems/v1/bulk',
|
'/api/customAvatarItems/v1/bulk',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -481,25 +646,46 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
summary: 'Custom avatar items in bulk',
|
summary: 'Custom avatar items in bulk',
|
||||||
description:
|
description:
|
||||||
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
|
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
|
||||||
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
|
'`customAvatarItemIds` filtered against the `custom_avatar_item` table, returned ' +
|
||||||
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
|
'as a BARE ARRAY of the ones that matched, in the order they were asked for. Not ' +
|
||||||
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
|
'the `{ Results, TotalResults }` page the sibling custom-item reads serve — the ' +
|
||||||
'answers this route with the filtered array alone.\n\n' +
|
'reference keeps its catalog in that shape but answers this route with the ' +
|
||||||
|
'filtered array alone.\n\n' +
|
||||||
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
|
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
|
||||||
'client reads the items it got back rather than the ids it asked for. Nothing ' +
|
'client reads the items it got back rather than the ids it asked for. Unpublished ' +
|
||||||
'stores custom items here, so every id misses and this is always `[]` — which is ' +
|
'items (`Accessibility` 0) miss for everyone but their creator, the same rule the ' +
|
||||||
'why the posted ids are not parsed.',
|
'feeds and the creator shelf apply.\n\n' +
|
||||||
|
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
|
||||||
|
'and the same spelling on the query string are both accepted, since the client’s ' +
|
||||||
|
'exact encoding here has not been pinned down.\n\n' +
|
||||||
|
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
|
||||||
|
'client has been seen posting more than a screen could draw, and a miss is already ' +
|
||||||
|
'not an error here.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
|
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(JsonArray, 'The matching items — always empty here'),
|
200: json(CustomAvatarItemList, 'The items that matched, in request order'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
return c.json([])
|
|
||||||
|
const ids = await bulkCustomAvatarItemIds(c)
|
||||||
|
|
||||||
|
// Over the cap: empty, and the table is not touched. Answering the batch would be a
|
||||||
|
// large query and a large response for a request that is already not what it looks
|
||||||
|
// like — a screen does not draw this many items.
|
||||||
|
if (ids.length > BULK_CUSTOM_AVATAR_ITEM_CAP) return c.json([])
|
||||||
|
|
||||||
|
const items = await getCustomAvatarItems(c.env.DB, ids)
|
||||||
|
// Unpublished items are held back from everyone but their creator — the same rule
|
||||||
|
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
|
||||||
|
// through this route that the feeds hide.
|
||||||
|
return c.json(
|
||||||
|
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -872,7 +1058,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
// RRInventionVersion, which carries the blob name the client downloads and the
|
// RRInventionVersion, which carries the blob name the client downloads and the
|
||||||
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
|
||||||
// version history yet), so any other version number 404s rather than naming a
|
// version history yet), so any other version number 404s rather than naming a
|
||||||
// blob that isn't there.
|
// blob that isn't there — except `version=0`, which means "whichever is current"
|
||||||
|
// rather than a number to match. Nothing has a version 0, so a caller sending it
|
||||||
|
// doesn't know which version it wants, and matching it literally 404s an invention
|
||||||
|
// that exists.
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v1/version',
|
'/api/inventions/v1/version',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -883,15 +1072,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
|
||||||
'never uploaded). Only the current version exists — nothing writes version ' +
|
'never uploaded). Only the current version exists — nothing writes version ' +
|
||||||
'history yet — so any other version number 404s rather than naming a blob that ' +
|
'history yet — so any other version number 404s rather than naming a blob that ' +
|
||||||
'is not there.',
|
'is not there.\n\n' +
|
||||||
|
'`version=0` is the exception: it means “whichever is current” rather than a ' +
|
||||||
|
'number to match, and gets the current version. No invention has a version 0 — a ' +
|
||||||
|
'fresh save is version 1 — so a caller sending it does not know which version it ' +
|
||||||
|
'wants, and matching it literally 404s an invention that exists.',
|
||||||
parameters: [
|
parameters: [
|
||||||
intQuery('inventionId', 'Invention id; required'),
|
intQuery('inventionId', 'Invention id; required'),
|
||||||
intQuery('version', 'Version number; required'),
|
intQuery('version', 'Version number; required. `0` means the current version'),
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: json(InventionVersionDto, 'The version'),
|
200: json(InventionVersionDto, 'The version'),
|
||||||
400: json(ErrorResponse, 'Missing inventionId or version'),
|
400: json(ErrorResponse, 'Missing inventionId or version'),
|
||||||
404: { description: 'No such invention, or not the current version' },
|
404: { description: 'No such invention, or a version number that is not the current one' },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -1251,15 +1444,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
// Invention search/browse: published inventions matching `value` (matched against
|
// Invention search/browse: published inventions matching `value` (matched against
|
||||||
// name + description; absent → browse everything published), newest first.
|
// name + description; absent → browse everything published), newest first.
|
||||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||||
|
//
|
||||||
|
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
|
||||||
|
// answer one page.
|
||||||
.get(
|
.get(
|
||||||
'/api/inventions/v2/search',
|
'/api/inventions/v2/search',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Inventions'],
|
tags: ['Inventions'],
|
||||||
summary: 'Search / browse inventions',
|
summary: 'Search / browse inventions',
|
||||||
description:
|
description:
|
||||||
'Published inventions matching `value` (matched against name and description), ' +
|
'Published inventions matching `value`, newest first. `value` is split into terms ' +
|
||||||
'newest first. An absent `value` browses everything published — that is the ' +
|
'and every term must match, each against the name and the description. An absent ' +
|
||||||
'browse screen’s initial request.',
|
'`value` browses everything published — that is the browse screen’s initial ' +
|
||||||
|
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
|
||||||
|
'chips is treated as text and matches nothing.',
|
||||||
parameters: [
|
parameters: [
|
||||||
stringQuery('value', 'Search text; absent browses everything'),
|
stringQuery('value', 'Search text; absent browses everything'),
|
||||||
...pageParams(100),
|
...pageParams(100),
|
||||||
@@ -1301,6 +1499,75 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Report a custom avatar item. Stored in the `report` table the player, event and invention
|
||||||
|
// reports use — same fields, same moderation life — with `custom_avatar_item_id` set. See
|
||||||
|
// migrations/0017_report_custom_avatar_item.sql.
|
||||||
|
//
|
||||||
|
// The item is named by the PATH, not the body, which is what distinguishes this from its
|
||||||
|
// siblings; the body's `ReportedPlayerId` arrives NULL and is ignored, since the client does
|
||||||
|
// not know who made the item.
|
||||||
|
.post(
|
||||||
|
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}/report',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Avatar', 'Moderation'],
|
||||||
|
summary: 'Report a custom avatar item',
|
||||||
|
description:
|
||||||
|
'Files a report against a custom avatar item, named by the PATH. Stored as a row in ' +
|
||||||
|
'the same `report` table a player report goes to (`POST /api/PlayerReporting/v3/create`), ' +
|
||||||
|
'an event report and an invention report — the same submission with the same ' +
|
||||||
|
'moderation life, which a moderator converts into a ban the same way. What marks it ' +
|
||||||
|
'as an item report is `custom_avatar_item_id`; the row’s `reported_player_id` is the ' +
|
||||||
|
'item’s CREATOR, read from the item. The body’s `ReportedPlayerId` is sent as null ' +
|
||||||
|
'and IGNORED even when set — the client does not know who made the item, and letting ' +
|
||||||
|
'a client name who a report is against would let it point one at anybody. Nothing ' +
|
||||||
|
'fills `room_id`: an item 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 item twice files two reports, and reporting your own ' +
|
||||||
|
'is allowed rather than being a special case.\n\n' +
|
||||||
|
'Answers the `{ success, error }` envelope the event and invention reports use, ' +
|
||||||
|
'`error` being an empty string rather than null, on the rejected branches too so ' +
|
||||||
|
'there is only one shape to parse.',
|
||||||
|
security: AUTHED,
|
||||||
|
parameters: [idParam('id', 'The custom avatar item’s guid')],
|
||||||
|
requestBody: jsonBody(CustomAvatarItemReportRequest, 'The report'),
|
||||||
|
responses: {
|
||||||
|
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
404: json(SuccessErrorEnvelope, 'No such custom avatar item'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const reporterId = await authedId(c)
|
||||||
|
if (reporterId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const customAvatarItemId = c.req.param('id')
|
||||||
|
|
||||||
|
// The item supplies the reported player. An unknown item is refused rather than filed
|
||||||
|
// against nobody: the row's reported player has to be someone, and a report naming an
|
||||||
|
// item that never existed isn't actionable.
|
||||||
|
const item = await getCustomAvatarItem(c.env.DB, customAvatarItemId)
|
||||||
|
if (item === null) return c.json({ success: false, error: 'No such item' }, 404)
|
||||||
|
|
||||||
|
// A body that won't parse is not a reason to lose the report: the path already names
|
||||||
|
// what is being reported and the token names who reported it, so an unreadable body
|
||||||
|
// costs the category and the description, not the row.
|
||||||
|
const body = await c.req
|
||||||
|
.json<{ ReportCategory?: unknown; Details?: unknown }>()
|
||||||
|
.catch(() => ({}) as Record<string, unknown>)
|
||||||
|
const category = Number(body.ReportCategory)
|
||||||
|
await createReport(c.env.DB, {
|
||||||
|
reporterPlayerId: reporterId,
|
||||||
|
reportedPlayerId: item.CreatorAccountId,
|
||||||
|
reportCategory: Number.isInteger(category) ? category : 0,
|
||||||
|
details: typeof body.Details === 'string' ? body.Details : null,
|
||||||
|
customAvatarItemId,
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.json({ success: true, error: '' })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Report an invention. Stored in the `report` table the player and event reports use —
|
// Report an invention. Stored in the `report` table the player and event reports use —
|
||||||
// same fields, same moderation life — with `invention_id` set. See
|
// same fields, same moderation life — with `invention_id` set. See
|
||||||
// migrations/0016_report_invention.sql.
|
// migrations/0016_report_invention.sql.
|
||||||
|
|||||||
@@ -227,6 +227,32 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.json(communityBoard)
|
(c) => c.json(communityBoard)
|
||||||
)
|
)
|
||||||
|
// Circuit chip lists — the palettes the Maker Pen's circuit board groups its chips into
|
||||||
|
// (`/api/CircuitChipLists/Favorites`, `/api/CircuitChipLists/Recent`, and so on). The path
|
||||||
|
// segment names the list; nothing here records which chips a player has used or favourited,
|
||||||
|
// so every one of them is empty.
|
||||||
|
//
|
||||||
|
// EMPTY rather than 404 for a name this server doesn't know: the client asks for whichever
|
||||||
|
// palettes its build has, and an unknown one is a palette this server has no opinion about
|
||||||
|
// rather than an error — a 404 shows as a palette that failed to load, where an empty list
|
||||||
|
// shows as one with nothing in it, which is the truth for all of them.
|
||||||
|
.get(
|
||||||
|
'/api/CircuitChipLists/:list',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Gameplay'],
|
||||||
|
summary: 'One circuit chip list',
|
||||||
|
description:
|
||||||
|
'A palette on the Maker Pen’s circuit board, named by the path (`Favorites`, ' +
|
||||||
|
'`Recent`, …). Always empty: nothing records which chips a player has used or ' +
|
||||||
|
'favourited yet. An unknown name is empty too rather than a 404 — the client asks ' +
|
||||||
|
'for whichever palettes its build has, and a 404 renders as a palette that failed ' +
|
||||||
|
'to load rather than one with nothing in it.',
|
||||||
|
parameters: [stringParam('list', 'The palette name, e.g. `Favorites`')],
|
||||||
|
responses: { 200: json(JsonArray, 'An empty list') },
|
||||||
|
}),
|
||||||
|
(c) => c.json([])
|
||||||
|
)
|
||||||
|
|
||||||
// Player events live in their own controller (routes/events.ts) — they're D1-backed
|
// Player events live in their own controller (routes/events.ts) — they're D1-backed
|
||||||
// now, unlike the stubs around them here.
|
// now, unlike the stubs around them here.
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -1001,6 +1001,27 @@ describe('public endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/CircuitChipLists/:list is empty for any name', async () => {
|
||||||
|
// A palette on the Maker Pen's circuit board, named by the path. Nothing records which
|
||||||
|
// chips a player has used or favourited, so every one of them is empty — including names
|
||||||
|
// this server has never heard of, which the client will ask for as its build changes.
|
||||||
|
// An unknown name being a 404 would render as a palette that FAILED to load rather than
|
||||||
|
// one with nothing in it.
|
||||||
|
for (const list of [
|
||||||
|
'Favorites',
|
||||||
|
'Recent',
|
||||||
|
'All',
|
||||||
|
'SomePaletteThisServerHasNeverHeardOf',
|
||||||
|
// Path-segment oddities: a name that needs escaping, and a numeric one.
|
||||||
|
encodeURIComponent('Weird Name/With Slash'),
|
||||||
|
'42',
|
||||||
|
]) {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/CircuitChipLists/${list}`)
|
||||||
|
expect(res.status, list).toBe(200)
|
||||||
|
expect(await res.json(), list).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v1/featureddormskins returns []', async () => {
|
test('GET /api/inventions/v1/featureddormskins returns []', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -1052,6 +1073,209 @@ describe('public endpoints', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/customAvatarItems/v1/search filters, pages and excludes unpublished', async () => {
|
||||||
|
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||||
|
|
||||||
|
const search = async (query: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
|
||||||
|
expect(res.status, query).toBe(200)
|
||||||
|
return (await res.json()) as Array<{
|
||||||
|
CustomAvatarItemId: string
|
||||||
|
Name: string
|
||||||
|
OutfitType: number
|
||||||
|
PurchaseInfo: null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// A BARE ARRAY, not the `{ Results, TotalResults }` envelope `fromCreator` uses.
|
||||||
|
expect(await search('')).toEqual([])
|
||||||
|
|
||||||
|
const made: Record<string, string> = {}
|
||||||
|
// Six published items across three outfit types, plus one unpublished and one Coach's.
|
||||||
|
// Creation times ascend with the index so "newest first" is unambiguous.
|
||||||
|
const spec: Array<[name: string, outfitType: number, accessibility: number, creator: number]> =
|
||||||
|
[
|
||||||
|
['Hat A', 0, 1, 205],
|
||||||
|
['Shirt A', 2, 1, 205],
|
||||||
|
['Shirt B', 2, 1, 205],
|
||||||
|
['Trousers A', 3, 1, 205],
|
||||||
|
['Coach Hat', 0, 1, 1],
|
||||||
|
['Hidden', 0, 0, 205],
|
||||||
|
]
|
||||||
|
for (const [i, [name, outfitType, accessibility, creator]] of spec.entries()) {
|
||||||
|
const created = await createCustomAvatarItem(
|
||||||
|
env.DB,
|
||||||
|
{
|
||||||
|
customAvatarItemId: crypto.randomUUID(),
|
||||||
|
creatorAccountId: creator,
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
price: 0,
|
||||||
|
baseAvatarItemId: 1,
|
||||||
|
baseAvatarItemColor: '#fff',
|
||||||
|
accessibility,
|
||||||
|
designFilename: 'design_x.bin',
|
||||||
|
thumbnailImageFilename: 'thumb_x.png',
|
||||||
|
},
|
||||||
|
new Date(Date.UTC(2026, 7, 1 + i))
|
||||||
|
)
|
||||||
|
made[name] = created.CustomAvatarItemId
|
||||||
|
// Nothing sets outfit_type on creation yet, so set it straight in the table.
|
||||||
|
await env.DB.prepare(
|
||||||
|
'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1'
|
||||||
|
)
|
||||||
|
.bind(created.CustomAvatarItemId, outfitType)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first, and `Hidden` never appears: Accessibility 0 is unpublished, and this is
|
||||||
|
// the shared browse surface — its creator sees it through `fromCreator`, not here.
|
||||||
|
const all = await search('')
|
||||||
|
expect(all.map((i) => i.Name)).toEqual([
|
||||||
|
'Coach Hat',
|
||||||
|
'Trousers A',
|
||||||
|
'Shirt B',
|
||||||
|
'Shirt A',
|
||||||
|
'Hat A',
|
||||||
|
])
|
||||||
|
|
||||||
|
// `outfitTypes` repeats and acts as a whitelist — the real client sends a dozen of them.
|
||||||
|
expect((await search('?outfitTypes=2')).map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A'])
|
||||||
|
expect((await search('?outfitTypes=0&outfitTypes=3')).map((i) => i.Name)).toEqual([
|
||||||
|
'Coach Hat',
|
||||||
|
'Trousers A',
|
||||||
|
'Hat A',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Sending NONE means no filter, not no results: the client sends every type it can render,
|
||||||
|
// so reading an absent parameter as an empty `IN ()` would empty the store.
|
||||||
|
expect((await search('?skip=0&take=100')).map((i) => i.Name)).toEqual(all.map((i) => i.Name))
|
||||||
|
|
||||||
|
// A non-numeric value is dropped rather than becoming NaN, which would match nothing and
|
||||||
|
// quietly empty a filter the caller believes they set.
|
||||||
|
expect((await search('?outfitTypes=2&outfitTypes=nonsense')).map((i) => i.Name)).toEqual([
|
||||||
|
'Shirt B',
|
||||||
|
'Shirt A',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Paging, and it is STABLE: consecutive pages must not repeat or skip a row, which the
|
||||||
|
// id tiebreak in the ordering is what guarantees when timestamps collide.
|
||||||
|
const page1 = await search('?skip=0&take=2')
|
||||||
|
const page2 = await search('?skip=2&take=2')
|
||||||
|
expect(page1.map((i) => i.Name)).toEqual(['Coach Hat', 'Trousers A'])
|
||||||
|
expect(page2.map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A'])
|
||||||
|
expect(
|
||||||
|
page1.some((i) => page2.some((j) => j.CustomAvatarItemId === i.CustomAvatarItemId))
|
||||||
|
).toBe(false)
|
||||||
|
expect(await search('?skip=99&take=10')).toEqual([])
|
||||||
|
expect(await search('?take=0')).toEqual([])
|
||||||
|
|
||||||
|
// The client capitalises its booleans (`includeCoachItems=True`), so the comparison folds
|
||||||
|
// case; only a recognisable "false" turns the stock content off.
|
||||||
|
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toContain('Coach Hat')
|
||||||
|
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).not.toContain('Coach Hat')
|
||||||
|
|
||||||
|
// The whole query the client actually sends, unchanged — the parameters that aren't acted
|
||||||
|
// on yet must be accepted rather than 400 or throw.
|
||||||
|
const real = await search(
|
||||||
|
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
|
||||||
|
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
|
||||||
|
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||||
|
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
|
||||||
|
)
|
||||||
|
expect(real.map((i) => i.Name)).toEqual(all.map((i) => i.Name))
|
||||||
|
// `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so
|
||||||
|
// the field is null on every item and the parameter changes nothing.
|
||||||
|
expect(real.every((i) => i.PurchaseInfo === null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /api/customAvatarItems/v1/search matches name or description, and bounds price', async () => {
|
||||||
|
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||||
|
|
||||||
|
const search = async (query: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
|
||||||
|
expect(res.status, query).toBe(200)
|
||||||
|
return ((await res.json()) as Array<{ Name: string }>).map((i) => i.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const make = async (name: string, description: string, price: number, i: number) =>
|
||||||
|
createCustomAvatarItem(
|
||||||
|
env.DB,
|
||||||
|
{
|
||||||
|
customAvatarItemId: crypto.randomUUID(),
|
||||||
|
creatorAccountId: 205,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
price,
|
||||||
|
baseAvatarItemId: 1,
|
||||||
|
baseAvatarItemColor: '#fff',
|
||||||
|
accessibility: 1,
|
||||||
|
designFilename: 'design_x.bin',
|
||||||
|
thumbnailImageFilename: 'thumb_x.png',
|
||||||
|
},
|
||||||
|
new Date(Date.UTC(2026, 7, 1 + i))
|
||||||
|
)
|
||||||
|
|
||||||
|
await make('Room Hat', '', 100, 0)
|
||||||
|
// Matched on DESCRIPTION, not name — the two are searched together.
|
||||||
|
await make('Cosy Beanie', 'Warm in any ROOM', 500, 1)
|
||||||
|
// Matched case-insensitively and as a SUBSTRING, mid-word.
|
||||||
|
await make('Ballroom Shoes', '', 9000, 2)
|
||||||
|
await make('Unrelated Cap', 'nothing to do with it', 250, 3)
|
||||||
|
// A name holding LIKE metacharacters, for the escaping below.
|
||||||
|
await make('100% Wool', 'a_b', 50, 4)
|
||||||
|
|
||||||
|
// Newest first throughout, so the order also proves the query didn't disturb the ordering.
|
||||||
|
expect(await search('?searchQuery=room')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||||
|
// Case folds both ways: SQLite's own LIKE only folds ASCII, so both sides are lowered.
|
||||||
|
expect(await search('?searchQuery=ROOM')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||||
|
expect(await search('?searchQuery=beanie')).toEqual(['Cosy Beanie'])
|
||||||
|
expect(await search('?searchQuery=nothing%20to%20do')).toEqual(['Unrelated Cap'])
|
||||||
|
expect(await search('?searchQuery=zzzz')).toEqual([])
|
||||||
|
|
||||||
|
// Blank or absent is NO filter, not an empty result — a cleared search box must show the
|
||||||
|
// store rather than nothing.
|
||||||
|
expect(await search('?searchQuery=')).toHaveLength(5)
|
||||||
|
expect(await search('?searchQuery=%20%20')).toHaveLength(5)
|
||||||
|
|
||||||
|
// A needle of LIKE metacharacters matches them LITERALLY. Unescaped, `%` would match every
|
||||||
|
// item and `_` any single character, so a player searching for "100%" would get the lot.
|
||||||
|
expect(await search('?searchQuery=%25')).toEqual(['100% Wool'])
|
||||||
|
expect(await search('?searchQuery=a_b')).toEqual(['100% Wool'])
|
||||||
|
|
||||||
|
// Price bounds, inclusive at both ends.
|
||||||
|
expect(await search('?minPrice=250&maxPrice=9000')).toEqual([
|
||||||
|
'Unrelated Cap',
|
||||||
|
'Ballroom Shoes',
|
||||||
|
'Cosy Beanie',
|
||||||
|
])
|
||||||
|
expect(await search('?maxPrice=100')).toEqual(['100% Wool', 'Room Hat'])
|
||||||
|
expect(await search('?minPrice=9000')).toEqual(['Ballroom Shoes'])
|
||||||
|
expect(await search('?minPrice=100000')).toEqual([])
|
||||||
|
|
||||||
|
// Combined with the text search, since the client sends both together.
|
||||||
|
expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat'])
|
||||||
|
|
||||||
|
// The whole query the client actually sends. `itemTypes` and the unity asset parameters are
|
||||||
|
// accepted and not acted on; `outfitTypes=105` matches nothing, so this is empty — which is
|
||||||
|
// the filter working, not the search failing.
|
||||||
|
expect(
|
||||||
|
await search(
|
||||||
|
'?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' +
|
||||||
|
'&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' +
|
||||||
|
'&unityAssetTarget=0&unityAssetVersion=3'
|
||||||
|
)
|
||||||
|
).toEqual([])
|
||||||
|
// Same query with the outfit-type filter dropped: the rest of it does match.
|
||||||
|
expect(
|
||||||
|
await search(
|
||||||
|
'?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +
|
||||||
|
'&includeCoachItems=False&ordering=0&skip=0&take=1000&unityAssetTarget=0' +
|
||||||
|
'&unityAssetVersion=3'
|
||||||
|
)
|
||||||
|
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
|
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
|
||||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||||
const base = {
|
const base = {
|
||||||
@@ -1131,29 +1355,110 @@ describe('public endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
|
/** Create a custom avatar item and return it, so a bulk lookup has something to find. */
|
||||||
// the sibling custom-item reads serve. Nothing stores custom items, so every id
|
async function createCustomItem(
|
||||||
// misses, and a miss is an absent entry rather than an error.
|
creator: string,
|
||||||
test('POST /api/customAvatarItems/v1/bulk returns the matching items as an array', async () => {
|
metadata: Record<string, unknown>
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
): Promise<{ CustomAvatarItemId: string; Name: string }> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.set(
|
||||||
|
'metadata',
|
||||||
|
JSON.stringify({
|
||||||
|
Name: 'bulk item',
|
||||||
|
Description: '',
|
||||||
|
Price: 0,
|
||||||
|
BaseAvatarItemId: 2184,
|
||||||
|
BaseAvatarItemColor: '#F55C1A',
|
||||||
|
Accessibility: 1,
|
||||||
|
...metadata,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
form.set('thumbnailImage', new File([new Uint8Array([1])], 'f.bin', { type: 'image/png' }))
|
||||||
|
form.set('design', new File([new Uint8Array([2])], 'f.bin', { type: 'image/png' }))
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: await bearer(creator),
|
||||||
'content-type': 'application/x-www-form-urlencoded',
|
body: form,
|
||||||
...(await bearer()),
|
|
||||||
},
|
|
||||||
// Repeated form field, as `[FromForm] List<string>` binds it.
|
|
||||||
body: new URLSearchParams([
|
|
||||||
['customAvatarItemIds', 'a'],
|
|
||||||
['customAvatarItemIds', 'b'],
|
|
||||||
]),
|
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([])
|
return ((await res.json()) as { Value: { CustomAvatarItemId: string; Name: string } }).Value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST the bulk lookup with `ids` as repeated form fields, as the client binds them. */
|
||||||
|
async function bulkLookup(
|
||||||
|
ids: string[],
|
||||||
|
as = '42'
|
||||||
|
): Promise<Array<{ CustomAvatarItemId: string; Name: string }>> {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer(as)) },
|
||||||
|
// Repeated form field, as `[FromForm] List<string>` binds it.
|
||||||
|
body: new URLSearchParams(ids.map((id) => ['customAvatarItemIds', id])),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
return (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
|
||||||
|
// the sibling custom-item reads serve — resolved out of the `custom_avatar_item` table.
|
||||||
|
// This is what a `1.<guid>` entity in a Generic discovery row resolves through, so it
|
||||||
|
// answering `[]` (as it did while it was a stub) renders that row's items as nothing.
|
||||||
|
test('POST /api/customAvatarItems/v1/bulk resolves the posted ids against the table', async () => {
|
||||||
|
const first = await createCustomItem('205', { Name: 'bulk one' })
|
||||||
|
const second = await createCustomItem('205', { Name: 'bulk two' })
|
||||||
|
|
||||||
|
// In REQUEST order, not creation order — the client reads the array positionally.
|
||||||
|
const items = await bulkLookup([second.CustomAvatarItemId, first.CustomAvatarItemId])
|
||||||
|
expect(items.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||||
|
second.CustomAvatarItemId,
|
||||||
|
first.CustomAvatarItemId,
|
||||||
|
])
|
||||||
|
expect(items[0]).toMatchObject({ Name: 'bulk two', CreatorAccountId: 205, Accessibility: 1 })
|
||||||
|
|
||||||
|
// A miss is an absent entry, not an error: the client reads the items it got back
|
||||||
|
// rather than the ids it asked for, so an unknown id must not cost it the rest.
|
||||||
|
const mixed = await bulkLookup([
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
first.CustomAvatarItemId,
|
||||||
|
])
|
||||||
|
expect(mixed.map((i) => i.CustomAvatarItemId)).toEqual([first.CustomAvatarItemId])
|
||||||
|
|
||||||
|
// Ids also ride comma-separated inside one field, and on the query string — the
|
||||||
|
// client's exact encoding here isn't pinned down, so all three spellings are read.
|
||||||
|
const commas = await bulkLookup([`${first.CustomAvatarItemId},${second.CustomAvatarItemId}`])
|
||||||
|
expect(commas).toHaveLength(2)
|
||||||
|
const queried = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/customAvatarItems/v1/bulk?customAvatarItemIds=${first.CustomAvatarItemId}`,
|
||||||
|
{ method: 'POST', headers: await bearer() }
|
||||||
|
)
|
||||||
|
expect(((await queried.json()) as unknown[]).length).toBe(1)
|
||||||
|
|
||||||
|
// Over 100 ids answers EMPTY without touching the table. The client has been seen posting
|
||||||
|
// far more than a screen could draw, and empty is safe precisely because a miss here is
|
||||||
|
// already not an error. Empty rather than the first 100: the client reads the items it got
|
||||||
|
// back, not the ids it asked about, so it cannot tell a truncated batch from a batch of
|
||||||
|
// misses and would cache the difference.
|
||||||
|
const padding = Array.from({ length: 99 }, () => '00000000-0000-0000-0000-000000000000')
|
||||||
|
expect(await bulkLookup([first.CustomAvatarItemId, ...padding])).toHaveLength(1)
|
||||||
|
expect(
|
||||||
|
await bulkLookup([first.CustomAvatarItemId, second.CustomAvatarItemId, ...padding])
|
||||||
|
).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
// The ids are never parsed (nothing could match), so a missing body is still a 200
|
// Unpublished items are held back from everyone but their creator — the same rule the
|
||||||
// rather than the 400 a body-reading handler would produce.
|
// featured/hot feeds and the creator shelf apply, so this route can't surface an item
|
||||||
test('POST /api/customAvatarItems/v1/bulk ignores the body', async () => {
|
// the feeds hide.
|
||||||
|
test('POST /api/customAvatarItems/v1/bulk hides unpublished items from everyone but the creator', async () => {
|
||||||
|
const hidden = await createCustomItem('206', { Name: 'unpublished', Accessibility: 0 })
|
||||||
|
|
||||||
|
expect(await bulkLookup([hidden.CustomAvatarItemId], '42')).toEqual([])
|
||||||
|
const own = await bulkLookup([hidden.CustomAvatarItemId], '206')
|
||||||
|
expect(own.map((i) => i.Name)).toEqual(['unpublished'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// A missing body is a 200 with an empty array rather than a 400: nothing was asked for,
|
||||||
|
// so nothing matched — the same shape as asking for ids that all miss.
|
||||||
|
test('POST /api/customAvatarItems/v1/bulk answers an empty array for an empty body', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: await bearer(),
|
headers: await bearer(),
|
||||||
@@ -1546,6 +1851,87 @@ describe('public endpoints', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /api/customAvatarItems/v1/:id/report files a report against the item’s creator', async () => {
|
||||||
|
// 205 makes an item; 42 reports it. The creator is derived FROM the item — the client
|
||||||
|
// sends `ReportedPlayerId: null` because it does not know who made it.
|
||||||
|
const item = await createCustomItem('205', { Name: 'Reportable Hat' })
|
||||||
|
|
||||||
|
const res = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ReportCategory: 2,
|
||||||
|
Details: 'tesfsfsdf',
|
||||||
|
ReportedPlayerId: null,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||||
|
|
||||||
|
// One row in the shared report table, marked as an item report by `custom_avatar_item_id`.
|
||||||
|
// `room_id` stays null — an item isn't tied to one room the way an event is — and the
|
||||||
|
// other two id columns stay null, which is what tells the kinds apart.
|
||||||
|
const row = await env.DB.prepare('SELECT * FROM report WHERE custom_avatar_item_id = ?1')
|
||||||
|
.bind(item.CustomAvatarItemId)
|
||||||
|
.first<Record<string, unknown>>()
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
reporter_player_id: 42,
|
||||||
|
reported_player_id: 205, // the item's creator
|
||||||
|
report_category: 2,
|
||||||
|
details: 'tesfsfsdf',
|
||||||
|
custom_avatar_item_id: item.CustomAvatarItemId,
|
||||||
|
invention_id: null,
|
||||||
|
event_id: null,
|
||||||
|
room_id: null,
|
||||||
|
banned: 0, // filed unbanned, like any report
|
||||||
|
})
|
||||||
|
|
||||||
|
// A body naming SOMEONE ELSE is ignored: the reported player is read off the item either
|
||||||
|
// way. Letting a client name who a report is against would let it point one at anybody.
|
||||||
|
await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ReportCategory: 1, ReportedPlayerId: 999 }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const reported = await env.DB.prepare(
|
||||||
|
'SELECT reported_player_id FROM report WHERE custom_avatar_item_id = ?1'
|
||||||
|
)
|
||||||
|
.bind(item.CustomAvatarItemId)
|
||||||
|
.all<{ reported_player_id: number }>()
|
||||||
|
// Nothing dedupes: two reports of the same item are two rows, both against the creator.
|
||||||
|
expect(reported.results.map((r) => r.reported_player_id)).toEqual([205, 205])
|
||||||
|
|
||||||
|
// An item that does not exist is refused rather than filed against nobody — the row's
|
||||||
|
// reported player has to be someone.
|
||||||
|
const unknown = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/customAvatarItems/v1/00000000-0000-0000-0000-000000000000/report`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ReportCategory: 0 }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(unknown.status).toBe(404)
|
||||||
|
expect(await unknown.json()).toEqual({ success: false, error: 'No such item' })
|
||||||
|
|
||||||
|
// Auth-gated: the reporter comes from the token, so there is no filing one signed out.
|
||||||
|
const anon = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ReportCategory: 0 }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
expect(anon.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/inventions/v1/report files a report row against the invention', async () => {
|
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,
|
// 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.
|
// so the reporter never gets to name who the report is against.
|
||||||
@@ -1859,6 +2245,82 @@ describe('public endpoints', () => {
|
|||||||
expect(await miss.json()).toEqual([])
|
expect(await miss.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => {
|
||||||
|
const published = (id: number, name: string, description: string, tags: string[]) =>
|
||||||
|
({
|
||||||
|
InventionId: id,
|
||||||
|
ReplicationId: crypto.randomUUID(),
|
||||||
|
CreatorPlayerId: 8081,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
ImageName: '',
|
||||||
|
CurrentVersionNumber: 1,
|
||||||
|
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
|
||||||
|
IsPublished: true,
|
||||||
|
HideFromPlayer: false,
|
||||||
|
CreatedAt: `2026-09-0${id - 200}T00:00:00Z`,
|
||||||
|
Tags: tags.map((Tag) => ({ Tag, Type: 2 })),
|
||||||
|
}) as unknown as SavedInvention
|
||||||
|
|
||||||
|
for (const inv of [
|
||||||
|
published(201, 'Devin Cube', 'i dont even know lol', ['small']),
|
||||||
|
published(202, 'Devin Cube 2', 'No description yet', ['small', 'dormanchor']),
|
||||||
|
published(203, 'Recflarian Flag', 'idk..... lol', ['medium']),
|
||||||
|
published(204, 'Smallest Table', 'a small table', ['medium']),
|
||||||
|
// Name holds LIKE metacharacters, for the escaping below.
|
||||||
|
published(205, '100% Cube_Thing', '', []),
|
||||||
|
]) {
|
||||||
|
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||||
|
.bind(JSON.stringify(inv))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = async (query: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?${query}`)
|
||||||
|
expect(res.status, query).toBe(200)
|
||||||
|
return ((await res.json()) as SavedInvention[])
|
||||||
|
.map((i) => i.InventionId)
|
||||||
|
.filter((id) => id >= 201)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first, matched against name OR description, case-insensitively.
|
||||||
|
expect(await ids('value=cube&skip=0&take=100')).toEqual([205, 202, 201])
|
||||||
|
expect(await ids('value=CUBE&skip=0&take=100')).toEqual([205, 202, 201])
|
||||||
|
expect(await ids('value=small&skip=0&take=100')).toEqual([204])
|
||||||
|
expect(await ids('value=lol&skip=0&take=100')).toEqual([203, 201])
|
||||||
|
|
||||||
|
// Terms are ANDed, so more words narrow rather than widen.
|
||||||
|
expect(await ids(`value=${encodeURIComponent('devin cube')}&skip=0&take=100`)).toEqual([
|
||||||
|
202, 201,
|
||||||
|
])
|
||||||
|
expect(await ids(`value=${encodeURIComponent('devin flag')}&skip=0&take=100`)).toEqual([])
|
||||||
|
|
||||||
|
// LIKE metacharacters are escaped: unescaped, `%` would match everything and `_` any
|
||||||
|
// single character, so searching for "100%" would return the whole catalogue.
|
||||||
|
expect(await ids('value=%25&skip=0&take=100')).toEqual([205])
|
||||||
|
expect(await ids('value=cube_thing&skip=0&take=100')).toEqual([205])
|
||||||
|
|
||||||
|
// TAGS ARE NOT SEARCHED. The browse screen's chips send `#small`, and no name or
|
||||||
|
// description contains it, so the term matches nothing — the tag is on 201 and 202, and a
|
||||||
|
// tag search would have returned them. Deliberate for now: matching tags needs them out of
|
||||||
|
// the JSON blob and into something indexable, and doing it in memory would mean reading
|
||||||
|
// every row to answer one page.
|
||||||
|
expect(await ids(`value=${encodeURIComponent('#small')}&skip=0&take=100`)).toEqual([])
|
||||||
|
|
||||||
|
// Paged in SQL: consecutive pages neither repeat nor skip a row. The `id` tiebreak in the
|
||||||
|
// ordering is what guarantees that when two inventions share a `CreatedAt`.
|
||||||
|
const page1 = await ids('value=cube&skip=0&take=2')
|
||||||
|
const page2 = await ids('value=cube&skip=2&take=2')
|
||||||
|
expect(page1).toEqual([205, 202])
|
||||||
|
expect(page2).toEqual([201])
|
||||||
|
expect(page1.some((id) => page2.includes(id))).toBe(false)
|
||||||
|
expect(await ids('value=cube&skip=99&take=10')).toEqual([])
|
||||||
|
|
||||||
|
// Cleaned up: the feeds and the tag-filter chips are derived from EVERY published
|
||||||
|
// invention, so rows left behind here would change what those tests see.
|
||||||
|
await env.DB.prepare('DELETE FROM invention WHERE id >= 201 AND id <= 205').run()
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => {
|
test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => {
|
||||||
// Two published inventions tagged `furniture`, one `bed` — plus a tagged draft,
|
// Two published inventions tagged `furniture`, one `bed` — plus a tagged draft,
|
||||||
// whose tags must not leak into the public filter chips.
|
// whose tags must not leak into the public filter chips.
|
||||||
@@ -2071,7 +2533,27 @@ describe('public endpoints', () => {
|
|||||||
InstantiationCost: 42,
|
InstantiationCost: 42,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only the current version exists; anything else 404s, as does an unknown id.
|
// `version=0` means "whichever is current" rather than a number to match, and gets the
|
||||||
|
// same version 1 back. Nothing has a version 0 — a fresh save is version 1 — so a caller
|
||||||
|
// sending it does not know which version it wants, and matching it literally would 404 an
|
||||||
|
// invention that exists.
|
||||||
|
const v0 = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=0`
|
||||||
|
)
|
||||||
|
expect(v0.status).toBe(200)
|
||||||
|
expect(await v0.json()).toMatchObject({
|
||||||
|
InventionId: Invention.InventionId,
|
||||||
|
VersionNumber: 1,
|
||||||
|
BlobName: '2026-07-12/lamp.inv',
|
||||||
|
})
|
||||||
|
|
||||||
|
// The 0 shortcut does NOT make up an invention: an unknown id still 404s at 0.
|
||||||
|
const zeroUnknown = await exports.default.fetch(
|
||||||
|
`${ORIGIN}/api/inventions/v1/version?inventionId=999999&version=0`
|
||||||
|
)
|
||||||
|
expect(zeroUnknown.status).toBe(404)
|
||||||
|
|
||||||
|
// Only the current version exists; any other NUMBER still 404s, as does an unknown id.
|
||||||
const v2 = await exports.default.fetch(
|
const v2 = await exports.default.fetch(
|
||||||
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2`
|
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2`
|
||||||
)
|
)
|
||||||
@@ -5722,6 +6204,7 @@ describe('openapi', () => {
|
|||||||
'DELETE /api/customAvatarItems/v1/{id}',
|
'DELETE /api/customAvatarItems/v1/{id}',
|
||||||
'DELETE /api/images/v1/deletesaved',
|
'DELETE /api/images/v1/deletesaved',
|
||||||
'DELETE /api/playerevents/v2/delete/{eventId}',
|
'DELETE /api/playerevents/v2/delete/{eventId}',
|
||||||
|
'GET /api/CircuitChipLists/{list}',
|
||||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
'GET /api/PlayerReporting/v1/voteToKickReasons',
|
'GET /api/PlayerReporting/v1/voteToKickReasons',
|
||||||
'GET /api/activities/charades/v1/words/{activity}',
|
'GET /api/activities/charades/v1/words/{activity}',
|
||||||
@@ -5738,6 +6221,7 @@ describe('openapi', () => {
|
|||||||
'GET /api/customAvatarItems/v1/isCreationEnabled',
|
'GET /api/customAvatarItems/v1/isCreationEnabled',
|
||||||
'GET /api/customAvatarItems/v1/isRenderingEnabled',
|
'GET /api/customAvatarItems/v1/isRenderingEnabled',
|
||||||
'GET /api/customAvatarItems/v1/minPriceForPublicItem',
|
'GET /api/customAvatarItems/v1/minPriceForPublicItem',
|
||||||
|
'GET /api/customAvatarItems/v1/search',
|
||||||
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
|
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
|
||||||
'GET /api/equipment/v2/getUnlocked',
|
'GET /api/equipment/v2/getUnlocked',
|
||||||
'GET /api/gameconfigs/v1/all',
|
'GET /api/gameconfigs/v1/all',
|
||||||
@@ -5824,6 +6308,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||||
'POST /api/customAvatarItems/v1',
|
'POST /api/customAvatarItems/v1',
|
||||||
'POST /api/customAvatarItems/v1/bulk',
|
'POST /api/customAvatarItems/v1/bulk',
|
||||||
|
'POST /api/customAvatarItems/v1/{id}/report',
|
||||||
'POST /api/gamesight/event',
|
'POST /api/gamesight/event',
|
||||||
'POST /api/images/v1/cheer',
|
'POST /api/images/v1/cheer',
|
||||||
'POST /api/images/v4/uploadsaved',
|
'POST /api/images/v4/uploadsaved',
|
||||||
|
|||||||
Reference in New Issue
Block a user