mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[api,econ,img] shirts fix #35
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Custom avatar items — player-designed items built on a base catalog item — on the
|
||||
* shared `recflare` D1 database. One column per field of the client's `CustomAvatarItem`
|
||||
* DTO, so a row maps straight onto the response.
|
||||
*
|
||||
* The two uploads that accompany a creation (the design blob and the thumbnail PNG) live
|
||||
* in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
|
||||
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; the two filename columns hold
|
||||
* those bucket keys, which the `img` worker serves back by key.
|
||||
*
|
||||
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql,
|
||||
* applied under its own `migrations_table`).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0015_custom_avatar_item.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||
custom_avatar_item_id TEXT PRIMARY KEY,
|
||||
creator_account_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price INTEGER NOT NULL DEFAULT 0,
|
||||
accessibility INTEGER NOT NULL DEFAULT 0,
|
||||
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
|
||||
is_featured INTEGER NOT NULL DEFAULT 0,
|
||||
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
|
||||
base_avatar_item_id INTEGER NOT NULL,
|
||||
base_avatar_item_color TEXT NOT NULL,
|
||||
design_filename TEXT NOT NULL,
|
||||
thumbnail_image_filename TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
preview_orientation INTEGER NOT NULL DEFAULT 0,
|
||||
outfit_type INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||
]
|
||||
|
||||
/** The client's `CustomAvatarItem` record (PascalCase, as served). */
|
||||
export interface CustomAvatarItem {
|
||||
CustomAvatarItemId: string
|
||||
CreatorAccountId: number
|
||||
Name: string
|
||||
Description: string
|
||||
Price: number
|
||||
Accessibility: number
|
||||
ForceCannotPublish: boolean
|
||||
IsFeatured: boolean
|
||||
IsRecRoomApproved: boolean
|
||||
BaseAvatarItemId: number
|
||||
BaseAvatarItemColor: string
|
||||
DesignFilename: string
|
||||
ThumbnailImageFilename: string
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
PreviewOrientation: number
|
||||
RankingContext: null
|
||||
OutfitType: number
|
||||
CurrentSaves: never[]
|
||||
PurchaseInfo: null
|
||||
}
|
||||
|
||||
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
|
||||
export interface CreateCustomAvatarItemInput {
|
||||
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
|
||||
customAvatarItemId: string
|
||||
creatorAccountId: number
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
baseAvatarItemId: number
|
||||
baseAvatarItemColor: string
|
||||
accessibility: number
|
||||
designFilename: string
|
||||
thumbnailImageFilename: string
|
||||
}
|
||||
|
||||
interface Row {
|
||||
custom_avatar_item_id: string
|
||||
creator_account_id: number
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
accessibility: number
|
||||
force_cannot_publish: number
|
||||
is_featured: number
|
||||
is_rec_room_approved: number
|
||||
base_avatar_item_id: number
|
||||
base_avatar_item_color: string
|
||||
design_filename: string
|
||||
thumbnail_image_filename: string
|
||||
created_at: string
|
||||
modified_at: string
|
||||
preview_orientation: number
|
||||
outfit_type: number
|
||||
}
|
||||
|
||||
function toDto(row: Row): CustomAvatarItem {
|
||||
return {
|
||||
CustomAvatarItemId: row.custom_avatar_item_id,
|
||||
CreatorAccountId: row.creator_account_id,
|
||||
Name: row.name,
|
||||
Description: row.description,
|
||||
Price: row.price,
|
||||
Accessibility: row.accessibility,
|
||||
ForceCannotPublish: row.force_cannot_publish === 1,
|
||||
IsFeatured: row.is_featured === 1,
|
||||
IsRecRoomApproved: row.is_rec_room_approved === 1,
|
||||
BaseAvatarItemId: row.base_avatar_item_id,
|
||||
BaseAvatarItemColor: row.base_avatar_item_color,
|
||||
DesignFilename: row.design_filename,
|
||||
ThumbnailImageFilename: row.thumbnail_image_filename,
|
||||
CreatedAt: row.created_at,
|
||||
ModifiedAt: row.modified_at,
|
||||
PreviewOrientation: row.preview_orientation,
|
||||
RankingContext: null,
|
||||
OutfitType: row.outfit_type,
|
||||
CurrentSaves: [],
|
||||
PurchaseInfo: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Inserts a new custom avatar item and returns it as the client's DTO. */
|
||||
export async function createCustomAvatarItem(
|
||||
db: D1Database,
|
||||
input: CreateCustomAvatarItemInput,
|
||||
now: Date = new Date()
|
||||
): Promise<CustomAvatarItem> {
|
||||
const ts = now.toISOString()
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO custom_avatar_item (
|
||||
custom_avatar_item_id, creator_account_id, name, description, price, accessibility,
|
||||
base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename,
|
||||
created_at, modified_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.customAvatarItemId,
|
||||
input.creatorAccountId,
|
||||
input.name,
|
||||
input.description,
|
||||
input.price,
|
||||
input.accessibility,
|
||||
input.baseAvatarItemId,
|
||||
input.baseAvatarItemColor,
|
||||
input.designFilename,
|
||||
input.thumbnailImageFilename,
|
||||
ts
|
||||
)
|
||||
.first<Row>()
|
||||
if (!row) throw new Error('custom_avatar_item insert returned no row')
|
||||
return toDto(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
|
||||
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
|
||||
*/
|
||||
export const UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM = 3
|
||||
|
||||
/** A custom avatar item as the client's `UgcPurchasableItem` (the store-facing view). */
|
||||
export interface UgcPurchasableItem {
|
||||
ItemType: number
|
||||
ItemId: string
|
||||
Name: string
|
||||
Description: string
|
||||
ImageName: string
|
||||
RoomId: number
|
||||
Price: number
|
||||
PurchaseCurrencyId: string | null
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
|
||||
* request — the item table has no room; what the client wants it for is still unknown.
|
||||
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
|
||||
*/
|
||||
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
|
||||
return {
|
||||
ItemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
|
||||
ItemId: item.CustomAvatarItemId,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
ImageName: item.ThumbnailImageFilename,
|
||||
RoomId: roomId,
|
||||
Price: item.Price,
|
||||
PurchaseCurrencyId: null,
|
||||
CreatedAt: item.CreatedAt,
|
||||
ModifiedAt: item.ModifiedAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the items with these ids, in the order asked; unknown ids are skipped. */
|
||||
export async function getCustomAvatarItems(
|
||||
db: D1Database,
|
||||
ids: string[]
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<Row>()
|
||||
const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)]))
|
||||
return ids.flatMap((id) => byId.get(id) ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
|
||||
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
|
||||
* those are excluded even when flagged. Newest first. Nothing sets the flag yet, so the
|
||||
* feed is empty until an operator writes `is_featured = 1`.
|
||||
*/
|
||||
export async function listFeaturedCustomAvatarItems(
|
||||
db: D1Database,
|
||||
limit = 50
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
|
||||
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
||||
)
|
||||
.bind(limit)
|
||||
.all<Row>()
|
||||
return results.map(toDto)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the creator looking at their own shelf: it adds the `Accessibility` 0 items everyone
|
||||
* else is not shown. Paging is not applied yet (the client sends none), so `TotalResults`
|
||||
* always equals the list length.
|
||||
*/
|
||||
export async function listCustomAvatarItemsByCreator(
|
||||
db: D1Database,
|
||||
creatorAccountId: number,
|
||||
includeUnpublished = false
|
||||
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM custom_avatar_item
|
||||
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
|
||||
ORDER BY created_at DESC, custom_avatar_item_id`
|
||||
)
|
||||
.bind(creatorAccountId, includeUnpublished ? 1 : 0)
|
||||
.all<Row>()
|
||||
const items = results.map(toDto)
|
||||
return { Results: items, TotalResults: items.length }
|
||||
}
|
||||
|
||||
/** The editable fields of `PUT /api/customAvatarItems/v1/:id`; null/undefined = leave alone. */
|
||||
export interface UpdateCustomAvatarItemInput {
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
price?: number | null
|
||||
accessibility?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a partial edit to one item, bumping `modified_at`. Fields the caller leaves
|
||||
* null keep their value (the client sends every field, nulling the untouched ones).
|
||||
* Returns the updated item, or null when no row has that id.
|
||||
*/
|
||||
export async function updateCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string,
|
||||
patch: UpdateCustomAvatarItemInput,
|
||||
now: Date = new Date()
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`UPDATE custom_avatar_item SET
|
||||
name = COALESCE(?2, name),
|
||||
description = COALESCE(?3, description),
|
||||
price = COALESCE(?4, price),
|
||||
accessibility = COALESCE(?5, accessibility),
|
||||
modified_at = ?6
|
||||
WHERE custom_avatar_item_id = ?1
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
patch.name ?? null,
|
||||
patch.description ?? null,
|
||||
patch.price ?? null,
|
||||
patch.accessibility ?? null,
|
||||
now.toISOString()
|
||||
)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
|
||||
/** Deletes one item's row. Returns the deleted item, or null when no row had that id. */
|
||||
export async function deleteCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *')
|
||||
.bind(id)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
|
||||
/** Fetches one item by id, or null. */
|
||||
export async function getCustomAvatarItem(
|
||||
db: D1Database,
|
||||
id: string
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||
.bind(id)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
}
|
||||
+74
-2
@@ -50,6 +50,13 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t
|
||||
/** Bearer-JWT security requirement, for the auth-gated routes. */
|
||||
export const AUTHED = [{ bearerAuth: [] }]
|
||||
|
||||
/**
|
||||
* A bearer token is honoured but not required: anonymous is a valid alternative. For routes
|
||||
* that serve public data but show more to a known caller (a creator's own unpublished
|
||||
* custom avatar items) instead of 401ing.
|
||||
*/
|
||||
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
|
||||
|
||||
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
|
||||
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
|
||||
@@ -91,6 +98,9 @@ export const JsonArray = z.array(z.unknown())
|
||||
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
|
||||
export const BareBoolean = z.boolean()
|
||||
|
||||
/** A bare JSON integer (e.g. `/api/customAvatarItems/v1/minPriceForPublicItem`). */
|
||||
export const BareInteger = z.number().int()
|
||||
|
||||
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
|
||||
export const BareString = z.string()
|
||||
|
||||
@@ -312,7 +322,9 @@ export const CheerPlayerRequest = z.object({
|
||||
export const SetSelectedCheerRequest = z.object({
|
||||
CheerCategory: z
|
||||
.string()
|
||||
.describe('The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'),
|
||||
.describe(
|
||||
'The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -326,6 +338,66 @@ export const CheerPlayerResponse = z.object({
|
||||
Message: z.string().nullable().describe('Null when the cheer landed'),
|
||||
})
|
||||
|
||||
/** The `metadata` JSON field of a custom-avatar-item creation. */
|
||||
export const CreateCustomAvatarItemMetadata = z.object({
|
||||
Name: z.string(),
|
||||
Description: z.string().optional(),
|
||||
Price: z.number().int().optional(),
|
||||
BaseAvatarItemId: z.number().int(),
|
||||
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
|
||||
Accessibility: z.number().int().optional(),
|
||||
})
|
||||
|
||||
/** The multipart body `POST /api/customAvatarItems/v1` takes. */
|
||||
export const CreateCustomAvatarItemRequest = z.object({
|
||||
metadata: z.string().describe('JSON `CreateCustomAvatarItemMetadata`, posted as a text field'),
|
||||
thumbnailImage: z.string().describe('The thumbnail PNG (binary file part)'),
|
||||
design: z.string().describe('The design blob (binary file part)'),
|
||||
})
|
||||
|
||||
/** The client's `CustomAvatarItem` record. */
|
||||
export const CustomAvatarItemDto = z.object({
|
||||
CustomAvatarItemId: z.string(),
|
||||
CreatorAccountId: z.number().int(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
Price: z.number().int(),
|
||||
Accessibility: z.number().int(),
|
||||
ForceCannotPublish: z.boolean(),
|
||||
IsFeatured: z.boolean(),
|
||||
IsRecRoomApproved: z.boolean(),
|
||||
BaseAvatarItemId: z.number().int(),
|
||||
BaseAvatarItemColor: z.string(),
|
||||
DesignFilename: z.string(),
|
||||
ThumbnailImageFilename: z.string(),
|
||||
CreatedAt: z.string(),
|
||||
ModifiedAt: z.string(),
|
||||
PreviewOrientation: z.number().int(),
|
||||
RankingContext: z.null(),
|
||||
OutfitType: z.number().int(),
|
||||
CurrentSaves: z.array(z.unknown()),
|
||||
PurchaseInfo: z.null(),
|
||||
})
|
||||
|
||||
/** The JSON body `PUT /api/customAvatarItems/v1/:id` takes; null leaves a field unchanged. */
|
||||
export const UpdateCustomAvatarItemRequest = z.object({
|
||||
Name: z.string().nullable().optional(),
|
||||
Description: z.string().nullable().optional(),
|
||||
Price: z.number().int().nullable().optional(),
|
||||
Accessibility: z.number().int().nullable().optional(),
|
||||
})
|
||||
|
||||
/** A bare list of custom avatar items (the featured feed). */
|
||||
export const CustomAvatarItemList = z.array(CustomAvatarItemDto)
|
||||
|
||||
/** The PascalCase `{ Value, Success, Error, error_id }` envelope custom-avatar-item routes answer with. */
|
||||
export const CustomAvatarItemResponse = z.object({
|
||||
Value: CustomAvatarItemDto.nullable(),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable(),
|
||||
error_id: z.string().nullable(),
|
||||
})
|
||||
|
||||
/** The `Ids` form body the bulk POST endpoints take. */
|
||||
export const BulkIdsRequest = z.object({
|
||||
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
||||
@@ -507,7 +579,7 @@ export const BulkCustomAvatarItemsRequest = z.object({
|
||||
|
||||
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
|
||||
export const CustomAvatarItemsPage = z.object({
|
||||
Results: JsonArray,
|
||||
Results: CustomAvatarItemList,
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
|
||||
+238
-10
@@ -12,6 +12,14 @@ import {
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
deleteCustomAvatarItem,
|
||||
getCustomAvatarItem,
|
||||
listCustomAvatarItemsByCreator,
|
||||
listFeaturedCustomAvatarItems,
|
||||
updateCustomAvatarItem,
|
||||
} from '../custom-avatar-items-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createInvention,
|
||||
@@ -36,7 +44,11 @@ import {
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
BareInteger,
|
||||
BulkCustomAvatarItemsRequest,
|
||||
CreateCustomAvatarItemRequest,
|
||||
CustomAvatarItemList,
|
||||
CustomAvatarItemResponse,
|
||||
CustomAvatarItemsPage,
|
||||
ErrorResponse,
|
||||
form,
|
||||
@@ -53,6 +65,7 @@ import {
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
LegacyAvatarItemSaves,
|
||||
OPTIONAL_AUTHED,
|
||||
OutfitSaveResponse,
|
||||
OutfitsBulkRequest,
|
||||
OutfitsBulkResponse,
|
||||
@@ -62,10 +75,12 @@ import {
|
||||
SaveInventionRequest,
|
||||
SetTagsRequest,
|
||||
SetTagsResponse,
|
||||
stringParam,
|
||||
stringQuery,
|
||||
SuccessValueEnvelope,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdateCustomAvatarItemRequest,
|
||||
UpdatePriceRequest,
|
||||
} from '../openapi'
|
||||
|
||||
@@ -227,17 +242,208 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json(true)
|
||||
)
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/minPriceForPublicItem',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Minimum token price for a public custom item',
|
||||
description:
|
||||
'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`.',
|
||||
responses: { 200: json(BareInteger, 'A bare `100`') },
|
||||
}),
|
||||
(c) => c.json(100)
|
||||
)
|
||||
.post(
|
||||
'/api/customAvatarItems/v1',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Create a custom avatar item',
|
||||
description:
|
||||
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
|
||||
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
|
||||
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope.\n\n' +
|
||||
'The two files go to the shared image bucket (`recflare-img`) under ' +
|
||||
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
|
||||
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The created item'),
|
||||
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
// The featured custom-avatar-item feed. No curated items yet → an empty list.
|
||||
const fail = (message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, 400)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
if (typeof body.metadata !== 'string') return fail('metadata is required')
|
||||
let meta: Record<string, unknown>
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body.metadata)
|
||||
if (!parsed || typeof parsed !== 'object') return fail('metadata must be a JSON object')
|
||||
meta = parsed as Record<string, unknown>
|
||||
} catch {
|
||||
return fail('metadata is not valid JSON')
|
||||
}
|
||||
if (typeof meta.Name !== 'string' || meta.Name.trim() === '') return fail('Name is required')
|
||||
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
|
||||
if (typeof meta.BaseAvatarItemColor !== 'string')
|
||||
return fail('BaseAvatarItemColor is required')
|
||||
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
|
||||
if (!(body.design instanceof File)) return fail('design is required')
|
||||
|
||||
// Both files go to the shared image bucket, foldered by upload date and keyed by
|
||||
// the item's id (chosen here so the keys can carry it). The `img` worker serves
|
||||
// them back by key.
|
||||
const customAvatarItemId = crypto.randomUUID()
|
||||
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
|
||||
const thumbnailImageFilename = `${prefix}-thumb.png`
|
||||
const designFilename = `${prefix}-design.png`
|
||||
await Promise.all([
|
||||
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
|
||||
}),
|
||||
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.design.type || 'image/png' },
|
||||
}),
|
||||
])
|
||||
|
||||
const item = await createCustomAvatarItem(c.env.DB, {
|
||||
customAvatarItemId,
|
||||
creatorAccountId: id,
|
||||
name: meta.Name,
|
||||
description: typeof meta.Description === 'string' ? meta.Description : '',
|
||||
price: typeof meta.Price === 'number' ? meta.Price : 0,
|
||||
baseAvatarItemId: meta.BaseAvatarItemId,
|
||||
baseAvatarItemColor: meta.BaseAvatarItemColor,
|
||||
accessibility: typeof meta.Accessibility === 'number' ? meta.Accessibility : 0,
|
||||
designFilename,
|
||||
thumbnailImageFilename,
|
||||
})
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Edit a custom avatar item',
|
||||
description:
|
||||
'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' +
|
||||
'sends every field and nulls the ones it is not changing, so null means "leave ' +
|
||||
'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' +
|
||||
'item in the same `{ Value, Success, Error, error_id }` envelope as the create.',
|
||||
security: AUTHED,
|
||||
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
|
||||
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The updated item'),
|
||||
400: json(CustomAvatarItemResponse, 'Malformed body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(CustomAvatarItemResponse, 'Not the creator'),
|
||||
404: json(CustomAvatarItemResponse, 'No such item'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const fail = (status: 400 | 403 | 404, message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
|
||||
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await getCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!existing) return fail(404, 'No such item')
|
||||
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!body) return fail(400, 'A JSON body is required')
|
||||
const str = (v: unknown, field: string): string | null | undefined => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v !== 'string') throw new TypeError(`${field} must be a string`)
|
||||
return v
|
||||
}
|
||||
const int = (v: unknown, field: string): number | null => {
|
||||
if (v === null || v === undefined) return null
|
||||
if (typeof v !== 'number' || !Number.isInteger(v))
|
||||
throw new TypeError(`${field} must be an integer`)
|
||||
return v
|
||||
}
|
||||
let patch
|
||||
try {
|
||||
patch = {
|
||||
name: str(body.Name, 'Name'),
|
||||
description: str(body.Description, 'Description'),
|
||||
price: int(body.Price, 'Price'),
|
||||
accessibility: int(body.Accessibility, 'Accessibility'),
|
||||
}
|
||||
} catch (e) {
|
||||
return fail(400, (e as Error).message)
|
||||
}
|
||||
if (patch.name !== null && patch.name?.trim() === '')
|
||||
return fail(400, 'Name must not be blank')
|
||||
|
||||
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
|
||||
if (!item) return fail(404, 'No such item')
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Delete a custom avatar item',
|
||||
description:
|
||||
'Removes the item and its two bucket objects (thumbnail and design). Only the ' +
|
||||
'creator may delete. Answers the deleted item in the `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope.',
|
||||
security: AUTHED,
|
||||
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The deleted item'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(CustomAvatarItemResponse, 'Not the creator'),
|
||||
404: json(CustomAvatarItemResponse, 'No such item'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const fail = (status: 403 | 404, message: string) =>
|
||||
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
|
||||
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await getCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!existing) return fail(404, 'No such item')
|
||||
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
|
||||
|
||||
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!item) return fail(404, 'No such item')
|
||||
// The row is gone; the objects follow. A missing key is a no-op for R2.
|
||||
await c.env.IMAGES.delete([item.ThumbnailImageFilename, item.DesignFilename])
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
// The featured custom-avatar-item feed: flagged (`is_featured`) AND published
|
||||
// (`Accessibility` != 0) items from the `custom_avatar_item` table.
|
||||
.get(
|
||||
'/api/customAvatarItems/v1/featured',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Featured custom avatar items',
|
||||
description: 'The curated feed. Nothing is curated yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
description:
|
||||
'The curated feed: items with `IsFeatured` set that are also published ' +
|
||||
'(`Accessibility` 0 is unpublished and is excluded even when flagged), newest first, ' +
|
||||
'up to 50. Nothing sets the flag yet, so it stays empty until an operator does.',
|
||||
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
|
||||
)
|
||||
|
||||
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
|
||||
@@ -288,20 +494,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Custom avatar items created by a given account. No storage yet → an empty
|
||||
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
|
||||
// Custom avatar items created by a given account, from the `custom_avatar_item` table,
|
||||
// in the paginated shape (matches the econ `customAvatarItems/v1/owned` shape). Auth is
|
||||
// optional: the creator themselves also sees their unpublished (`Accessibility` 0) items.
|
||||
.get(
|
||||
'/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'A creator’s custom avatar items',
|
||||
description:
|
||||
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
|
||||
'empty page — in the same shape as the `econ` worker’s `customAvatarItems/v1/owned`.',
|
||||
'The items an account has authored, newest first, in the same page shape as the ' +
|
||||
'`econ` worker’s `customAvatarItems/v1/owned`. Published items only — unless the ' +
|
||||
'bearer token is the creator’s, in which case their unpublished (`Accessibility` 0) ' +
|
||||
'items are included too. Paging is not applied (the client sends none), so ' +
|
||||
'`TotalResults` is the length of `Results`.',
|
||||
security: OPTIONAL_AUTHED,
|
||||
parameters: [idParam('accountId', 'Creator account id')],
|
||||
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
|
||||
responses: { 200: json(CustomAvatarItemsPage, 'The creator’s items') },
|
||||
}),
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
const viewer = await authedId(c)
|
||||
return c.json(await listCustomAvatarItemsByCreator(c.env.DB, accountId, viewer === accountId))
|
||||
}
|
||||
)
|
||||
|
||||
// The client asks which legacy avatar items have been rebuilt as custom items, so it
|
||||
@@ -984,6 +1199,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The featured dorm-skin feed (inventions that reskin the dorm). Nothing curates these
|
||||
// yet → an empty list, so the client's shelf renders empty rather than 404ing.
|
||||
.get(
|
||||
'/api/inventions/v1/featureddormskins',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The featured dorm-skin feed',
|
||||
description: 'Curated dorm-skin inventions. Nothing is curated yet, so it is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
|
||||
// shelf, and the "from creators you follow" row, from.
|
||||
//
|
||||
|
||||
@@ -30,6 +30,10 @@ import '../../api.app'
|
||||
|
||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
||||
import { banEvasionMatch, resolveBan } from '../../bans-db'
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||
} from '../../custom-avatar-items-db'
|
||||
import {
|
||||
countGoing,
|
||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||
@@ -148,6 +152,9 @@ beforeAll(async () => {
|
||||
|
||||
// Reputation + cheer credit (owned by the api worker) — cheering writes both.
|
||||
for (const stmt of REPUTATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Custom avatar items (owned by the api worker).
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
@@ -516,7 +523,12 @@ describe('public endpoints', () => {
|
||||
expect(allAnonymous[0]).toMatchObject({
|
||||
playerId: 7109,
|
||||
notificationType: 2, // NotificationType.MessageReceived
|
||||
data: { FromPlayerId: 0, ToPlayerId: 7109, Type: MessageType.PlayerCheerAnonymous, Data: '10' },
|
||||
data: {
|
||||
FromPlayerId: 0,
|
||||
ToPlayerId: 7109,
|
||||
Type: MessageType.PlayerCheerAnonymous,
|
||||
Data: '10',
|
||||
},
|
||||
})
|
||||
const anonymous = allAnonymous.slice(1)
|
||||
expect(anonymous[0]).toMatchObject({
|
||||
@@ -571,7 +583,11 @@ describe('public endpoints', () => {
|
||||
// reputation — all durable and all addressed: nothing was broadcast.
|
||||
expect(frames.map((f) => f.playerId)).toEqual([7131, 7131, 7130])
|
||||
expect(frames.some((f) => f.ephemeral)).toBe(false)
|
||||
expect(frames[0]!.data).toMatchObject({ ToPlayerId: 7131, Type: MessageType.PlayerCheer, Data: '40' })
|
||||
expect(frames[0]!.data).toMatchObject({
|
||||
ToPlayerId: 7131,
|
||||
Type: MessageType.PlayerCheer,
|
||||
Data: '40',
|
||||
})
|
||||
expect(frames[1]!.data).toMatchObject({ AccountId: 7131, CheerCreative: 1 })
|
||||
})
|
||||
|
||||
@@ -607,7 +623,9 @@ describe('public endpoints', () => {
|
||||
// The pin survives a cheer landing on the row, and a cheer's frame carries it.
|
||||
expect((await cheer({ PlayerIdTo: '7140', CheerCategory: '0' }, '7141')).status).toBe(200)
|
||||
expect(await reputationOf(7140)).toMatchObject({ CheerGeneral: 1 })
|
||||
expect(await getReputation(env.DB, 7140)).toMatchObject({ SelectedCheer: CheerCategory.GreatHost })
|
||||
expect(await getReputation(env.DB, 7140)).toMatchObject({
|
||||
SelectedCheer: CheerCategory.GreatHost,
|
||||
})
|
||||
|
||||
// -1 (`None`) unpins, read back as 0; a made-up category is refused.
|
||||
expect(await (await pin('-1', '7140')).json()).toEqual({ Success: true, Message: null })
|
||||
@@ -616,7 +634,7 @@ describe('public endpoints', () => {
|
||||
Success: false,
|
||||
Message: 'CheerCategory is not a cheer category',
|
||||
})
|
||||
expect((await pin('0', '7140').then((r) => r.status))).toBe(200)
|
||||
expect(await pin('0', '7140').then((r) => r.status)).toBe(200)
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
|
||||
@@ -936,9 +954,56 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
|
||||
test('GET /api/customAvatarItems/v1/featured lists flagged, published items, newest first', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
const older = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
item('Older', 1),
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const newer = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
item('Newer', 1),
|
||||
new Date('2026-08-02T00:00:00Z')
|
||||
)
|
||||
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
|
||||
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
|
||||
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
|
||||
await env.DB.prepare(
|
||||
'UPDATE custom_avatar_item SET is_featured = 1 WHERE custom_avatar_item_id != ?1'
|
||||
)
|
||||
.bind(unflagged.CustomAvatarItemId)
|
||||
.run()
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
|
||||
expect(res.status).toBe(200)
|
||||
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
|
||||
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||
newer.CustomAvatarItemId,
|
||||
older.CustomAvatarItemId,
|
||||
])
|
||||
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
|
||||
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: true, CurrentSaves: [] })
|
||||
|
||||
function item(name: string, accessibility: number) {
|
||||
return {
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name,
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/featureddormskins returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -948,10 +1013,70 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
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()
|
||||
const base = {
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
}
|
||||
const pub = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Published',
|
||||
accessibility: 1,
|
||||
},
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const draft = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Draft',
|
||||
accessibility: 0,
|
||||
},
|
||||
new Date('2026-08-02T00:00:00Z')
|
||||
)
|
||||
await createCustomAvatarItem(env.DB, {
|
||||
...base,
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 9,
|
||||
name: 'Other',
|
||||
accessibility: 0,
|
||||
})
|
||||
|
||||
type Page = { Results: Array<{ CustomAvatarItemId: string }>; TotalResults: number }
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v2/fromCreator/205`
|
||||
|
||||
// Anonymous, or someone else: only the published (Accessibility != 0) item.
|
||||
for (const headers of [{}, await bearer('9')]) {
|
||||
const res = await exports.default.fetch(url, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const page = (await res.json()) as Page
|
||||
expect(page.TotalResults).toBe(1)
|
||||
expect(page.Results.map((i) => i.CustomAvatarItemId)).toEqual([pub.CustomAvatarItemId])
|
||||
}
|
||||
|
||||
// The creator: their unpublished item too, newest first.
|
||||
const own = (await (
|
||||
await exports.default.fetch(url, { headers: await bearer('205') })
|
||||
).json()) as Page
|
||||
expect(own.TotalResults).toBe(2)
|
||||
expect(own.Results.map((i) => i.CustomAvatarItemId)).toEqual([
|
||||
draft.CustomAvatarItemId,
|
||||
pub.CustomAvatarItemId,
|
||||
])
|
||||
|
||||
const none = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
|
||||
expect(await none.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
// Nothing locks avatar items here, so the array is empty and the posted ids are never
|
||||
@@ -2354,6 +2479,231 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom avatar items', () => {
|
||||
test('minPriceForPublicItem is a bare 100', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/minPriceForPublicItem`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(100)
|
||||
})
|
||||
|
||||
test('POST creates an item from the multipart form and returns it', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
Name: 'custom shirt 1',
|
||||
Description: 'custom shirt 2',
|
||||
Price: 0,
|
||||
BaseAvatarItemId: 2184,
|
||||
BaseAvatarItemColor: '#F55C1A',
|
||||
Accessibility: 0,
|
||||
})
|
||||
)
|
||||
form.set(
|
||||
'thumbnailImage',
|
||||
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
|
||||
)
|
||||
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Value: Record<string, unknown>
|
||||
Success: boolean
|
||||
Error: null
|
||||
error_id: null
|
||||
}
|
||||
expect(body.Success).toBe(true)
|
||||
expect(body.Error).toBeNull()
|
||||
expect(body.error_id).toBeNull()
|
||||
expect(body.Value).toMatchObject({
|
||||
CreatorAccountId: 205,
|
||||
Name: 'custom shirt 1',
|
||||
Description: 'custom shirt 2',
|
||||
Price: 0,
|
||||
Accessibility: 0,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: false,
|
||||
BaseAvatarItemId: 2184,
|
||||
BaseAvatarItemColor: '#F55C1A',
|
||||
PreviewOrientation: 0,
|
||||
RankingContext: null,
|
||||
OutfitType: 0,
|
||||
CurrentSaves: [],
|
||||
PurchaseInfo: null,
|
||||
})
|
||||
const itemId = body.Value.CustomAvatarItemId as string
|
||||
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
const date = (body.Value.CreatedAt as string).slice(0, 10)
|
||||
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
|
||||
expect(body.Value.DesignFilename).toBe(`avatar-item/${date}/${itemId}-design.png`)
|
||||
expect(body.Value.CreatedAt).toBe(body.Value.ModifiedAt)
|
||||
|
||||
// Both uploads landed in the image bucket under those keys.
|
||||
const thumb = await env.IMAGES.get(body.Value.ThumbnailImageFilename as string)
|
||||
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(
|
||||
new Uint8Array([1, 2, 3])
|
||||
)
|
||||
expect(thumb!.httpMetadata?.contentType).toBe('image/png')
|
||||
const design = await env.IMAGES.get(body.Value.DesignFilename as string)
|
||||
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(
|
||||
new Uint8Array([4, 5, 6])
|
||||
)
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1'
|
||||
)
|
||||
.bind(body.Value.CustomAvatarItemId)
|
||||
.first()
|
||||
expect(row).toEqual({ name: 'custom shirt 1', creator_account_id: 205 })
|
||||
})
|
||||
|
||||
test('PUT edits the creator’s item, leaving nulled fields alone', async () => {
|
||||
const item = await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Visor',
|
||||
description: 'shiny',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 0,
|
||||
designFilename: 'd',
|
||||
thumbnailImageFilename: 't',
|
||||
},
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}`
|
||||
const res = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Value: Record<string, unknown>
|
||||
Success: boolean
|
||||
Error: null
|
||||
}
|
||||
expect(body.Success).toBe(true)
|
||||
expect(body.Value).toMatchObject({
|
||||
CustomAvatarItemId: item.CustomAvatarItemId,
|
||||
Name: 'Visor',
|
||||
Description: 'shiny',
|
||||
Price: 100,
|
||||
Accessibility: 1,
|
||||
CreatedAt: '2026-08-01T00:00:00.000Z',
|
||||
})
|
||||
expect(body.Value.ModifiedAt).not.toBe(item.ModifiedAt)
|
||||
|
||||
// Someone else can't edit it; an unknown id 404s; a bad type 400s.
|
||||
const other = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('9')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 5 }),
|
||||
})
|
||||
expect(other.status).toBe(403)
|
||||
const missing = await exports.default.fetch(
|
||||
`${ORIGIN}/api/customAvatarItems/v1/${crypto.randomUUID()}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 5 }),
|
||||
}
|
||||
)
|
||||
expect(missing.status).toBe(404)
|
||||
const bad = await exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Price: 'lots' }),
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
expect(await bad.json()).toMatchObject({ Success: false, Value: null })
|
||||
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
|
||||
})
|
||||
|
||||
test('DELETE removes the creator’s item and its bucket objects', async () => {
|
||||
// Create through the endpoint so the objects really exist in the bucket.
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
|
||||
const created = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
).json()) as {
|
||||
Value: { CustomAvatarItemId: string; ThumbnailImageFilename: string; DesignFilename: string }
|
||||
}
|
||||
const { CustomAvatarItemId, ThumbnailImageFilename, DesignFilename } = created.Value
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v1/${CustomAvatarItemId}`
|
||||
|
||||
// Not the creator → 403 and nothing changes.
|
||||
const other = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('9') })
|
||||
expect(other.status).toBe(403)
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
|
||||
|
||||
const res = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('205') })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: { CustomAvatarItemId, Name: 'Gone' },
|
||||
})
|
||||
expect(await env.IMAGES.get(ThumbnailImageFilename)).toBeNull()
|
||||
expect(await env.IMAGES.get(DesignFilename)).toBeNull()
|
||||
expect(
|
||||
await env.DB.prepare('SELECT 1 FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||
.bind(CustomAvatarItemId)
|
||||
.first()
|
||||
).toBeNull()
|
||||
|
||||
// Gone now → 404; no token → 401.
|
||||
const again = await exports.default.fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: await bearer('205'),
|
||||
})
|
||||
expect(again.status).toBe(404)
|
||||
expect((await exports.default.fetch(url, { method: 'DELETE' })).status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST 400s without the files', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'x', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: form,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toMatchObject({ Success: false, Value: null })
|
||||
})
|
||||
|
||||
test('POST 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('player reports', () => {
|
||||
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
||||
@@ -5269,6 +5619,7 @@ describe('openapi', () => {
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /api/customAvatarItems/v1/{id}',
|
||||
'DELETE /api/images/v1/deletesaved',
|
||||
'DELETE /api/playerevents/v2/delete/{eventId}',
|
||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
@@ -5286,6 +5637,7 @@ describe('openapi', () => {
|
||||
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
|
||||
'GET /api/customAvatarItems/v1/isCreationEnabled',
|
||||
'GET /api/customAvatarItems/v1/isRenderingEnabled',
|
||||
'GET /api/customAvatarItems/v1/minPriceForPublicItem',
|
||||
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
|
||||
'GET /api/equipment/v2/getUnlocked',
|
||||
'GET /api/gameconfigs/v1/all',
|
||||
@@ -5301,6 +5653,7 @@ describe('openapi', () => {
|
||||
'GET /api/inventions/v1',
|
||||
'GET /api/inventions/v1/details',
|
||||
'GET /api/inventions/v1/featured',
|
||||
'GET /api/inventions/v1/featureddormskins',
|
||||
'GET /api/inventions/v1/fromcreators',
|
||||
'GET /api/inventions/v1/fulllineageowner',
|
||||
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
||||
@@ -5369,6 +5722,7 @@ describe('openapi', () => {
|
||||
'POST /api/avatar/v1/lockeditems/bulk',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/customAvatarItems/v1',
|
||||
'POST /api/customAvatarItems/v1/bulk',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
@@ -5409,6 +5763,7 @@ describe('openapi', () => {
|
||||
'POST /api/v1/progression/bulk',
|
||||
'POST /outfits/bulk',
|
||||
'POST /statsigUserProperties',
|
||||
'PUT /api/customAvatarItems/v1/{id}',
|
||||
'PUT /api/playerevents/v2/{eventId}/accessibility',
|
||||
'PUT /api/playerevents/v2/{eventId}/description',
|
||||
'PUT /api/playerevents/v2/{eventId}/name',
|
||||
|
||||
Reference in New Issue
Block a user