From 741604be246fac87ec0fcafe1563929e83edece1 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 15 Sep 2026 20:39:24 -0400 Subject: [PATCH] [api] migrate custom avatar items for incoming RRO items --- CLAUDE.md | 11 + .../0022_custom_avatar_item_json.sql | 69 ++++ .../api/scripts/import-custom-avatar-items.ts | 69 ++++ apps/api/src/custom-avatar-items-db.ts | 299 +++++++++++------- apps/api/src/openapi.ts | 44 ++- apps/api/src/routes/avatar.ts | 9 +- apps/api/src/test/integration/api.test.ts | 166 +++++++++- 7 files changed, 535 insertions(+), 132 deletions(-) create mode 100644 apps/api/migrations/0022_custom_avatar_item_json.sql create mode 100644 apps/api/scripts/import-custom-avatar-items.ts diff --git a/CLAUDE.md b/CLAUDE.md index f7407fa..05a12fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,17 @@ inconsistency here without checking the client first. ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also serves nine rows starting at the runner-up. The unranked sentinel stays a big number (99999) precisely because 0 is now a real rank, first place. +- A custom avatar item (`api`: `custom_avatar_item`, every `/api/customAvatarItems/*` read) is ONE + record for two kinds of thing. A player-made shirt has `BaseAvatarItemId`/`BaseAvatarItemColor` + and a design PNG (`DesignFilename`, `ThumbnailImageFilename`) and an empty `CurrentSaves`; a + FIRST-PARTY item (imported from the official export, Coach-authored, its own `OutfitType`) has + those four NULL and is rendered from `CurrentSaves` — one built Unity assetbundle per + `BodyType`, which the client picks by the wearer's body. The row is the record as JSON, so an + import is the export verbatim (`apps/api/scripts/import-custom-avatar-items.ts` writes the + migration; re-importing an id replaces it). Serve both through one shape: the shirt carries + empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves + name their assetbundles and thumbnails by bare filename; storing the record does nothing + about serving those files. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/apps/api/migrations/0022_custom_avatar_item_json.sql b/apps/api/migrations/0022_custom_avatar_item_json.sql new file mode 100644 index 0000000..93296cd --- /dev/null +++ b/apps/api/migrations/0022_custom_avatar_item_json.sql @@ -0,0 +1,69 @@ +-- Custom avatar items become a JSON blob per row, the layout every other entity table on this +-- database uses (`invention`, `event`, `room`, `account`, `club`): the client's `CustomAvatarItem` +-- record verbatim in `data`, with the fields the queries filter and sort on exposed as generated +-- (virtual) columns. Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- Why: the flat table (0015) had one NOT NULL column per field of the player-made shirt, and the +-- FIRST-PARTY items being imported are the same record with those four fields NULL +-- (`BaseAvatarItemId`, `BaseAvatarItemColor`, `DesignFilename`, `ThumbnailImageFilename` — a +-- studio-built item has no base item and no design PNG) plus fields the table had no home for: +-- `CurrentSaves` (one built Unity assetbundle per body type, which is how the client renders +-- them), `Tags`, `CustomBadgeMetadata`, `RankedEntityId`. Storing the record whole means the +-- import is the record as exported, and a field the source adds later is served without a +-- migration. `CurrentSaves` stays embedded rather than in a child table: the client reads a +-- save off its item and picks one by `BodyType`; nothing looks a save up by its bare id. +-- +-- The generated columns keep the names the flat table had, so the indexes and every query +-- read the same. `name_lower`/`description_lower` back the store's search, which matches the +-- needle as a case-folded substring of either. Booleans come out of `json_extract` as 1/0 +-- (see 0008), so `is_featured = 1` keeps working. +-- +-- The existing player-made rows are carried across by rebuilding the DTO the old `toDto` +-- built from their columns — the same record the routes were already serving — with the +-- fields the flat table never had filled in as it served them (empty saves and tags, null +-- badge/ranking, `RankedEntityId` = the item id, as the official record carries it). + +CREATE TABLE custom_avatar_item_new ( + data TEXT NOT NULL, + custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL, + creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL, + accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL, + outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL, + price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL, + is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL, + created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL, + name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL, + description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL + ); + +INSERT INTO custom_avatar_item_new (data) +SELECT json_object( + 'CustomAvatarItemId', custom_avatar_item_id, + 'CreatorAccountId', creator_account_id, + 'Name', name, + 'Description', description, + 'Price', price, + 'Accessibility', accessibility, + 'ForceCannotPublish', json(iif(force_cannot_publish, 'true', 'false')), + 'IsFeatured', json(iif(is_featured, 'true', 'false')), + 'IsRecRoomApproved', json(iif(is_rec_room_approved, 'true', 'false')), + 'BaseAvatarItemId', base_avatar_item_id, + 'BaseAvatarItemColor', base_avatar_item_color, + 'DesignFilename', design_filename, + 'ThumbnailImageFilename', thumbnail_image_filename, + 'CreatedAt', created_at, + 'ModifiedAt', modified_at, + 'PreviewOrientation', preview_orientation, + 'OutfitType', outfit_type, + 'CurrentSaves', json('[]'), + 'Tags', json('[]'), + 'CustomBadgeMetadata', NULL, + 'RankedEntityId', custom_avatar_item_id, + 'RankingContext', NULL +) FROM custom_avatar_item; + +DROP TABLE custom_avatar_item; +ALTER TABLE custom_avatar_item_new RENAME TO custom_avatar_item; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id); +CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id); diff --git a/apps/api/scripts/import-custom-avatar-items.ts b/apps/api/scripts/import-custom-avatar-items.ts new file mode 100644 index 0000000..c83e2f9 --- /dev/null +++ b/apps/api/scripts/import-custom-avatar-items.ts @@ -0,0 +1,69 @@ +/** + * Turns an export of first-party custom avatar items into a D1 migration that seeds the + * `custom_avatar_item` table with them, one `INSERT` per record, the way + * `apps/rooms/migrations/0002_import_rooms.sql` seeds the rooms. + * + * bun apps/api/scripts/import-custom-avatar-items.ts + * + * `export.json` is a JSON array of `CustomAvatarItem` records as the official service serves + * them (a `{ Results: [...] }` page or a single record are accepted too). `NNNN` is the + * migration number; the file is written to `apps/api/migrations/NNNN_import_custom_avatar_items.sql`. + * + * Each record is stored VERBATIM as the row's JSON (the table is a JSON blob per row, see + * src/custom-avatar-items-db.ts) minus `PurchaseInfo`, which is a store-side projection and + * never stored. A record whose id is already present is REPLACED, so re-running an import + * with corrected data lands; a `DELETE` is not issued, so player-made items and previously + * imported items the new export lacks are left alone. + * + * The saves name assetbundles and thumbnails by bare filename; the migration does nothing + * about those files, which have to be served from wherever the client fetches them. + */ + +import { readFileSync, writeFileSync } from 'node:fs' + +interface Record_ { + CustomAvatarItemId: string + PurchaseInfo?: unknown + [key: string]: unknown +} + +const [input, number] = process.argv.slice(2) +if (!input || !number || !/^\d{4}$/.test(number)) { + console.error('usage: bun apps/api/scripts/import-custom-avatar-items.ts ') + process.exit(2) +} + +const parsed: unknown = JSON.parse(readFileSync(input, 'utf8')) +const records: Record_[] = Array.isArray(parsed) + ? parsed + : parsed && typeof parsed === 'object' && Array.isArray((parsed as { Results?: unknown }).Results) + ? (parsed as { Results: Record_[] }).Results + : [parsed as Record_] + +const seen = new Set() +const lines: string[] = [ + `-- Seed first-party custom avatar items (${records.length} records) from ${input.split('/').pop()}.`, + '-- Generated by apps/api/scripts/import-custom-avatar-items.ts — do not edit by hand.', + '-- Each record is the official `CustomAvatarItem` verbatim; an existing id is replaced.', + '', +] +for (const record of records) { + if (typeof record.CustomAvatarItemId !== 'string' || record.CustomAvatarItemId === '') { + throw new Error(`record without a CustomAvatarItemId: ${JSON.stringify(record).slice(0, 80)}`) + } + if (seen.has(record.CustomAvatarItemId)) { + throw new Error(`duplicate CustomAvatarItemId in export: ${record.CustomAvatarItemId}`) + } + seen.add(record.CustomAvatarItemId) + const { PurchaseInfo: _purchaseInfo, ...stored } = record + const json = JSON.stringify(stored).replaceAll("'", "''") + lines.push( + `INSERT INTO custom_avatar_item (data) VALUES ('${json}') ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data;` + ) +} + +const out = decodeURIComponent( + new URL(`../migrations/${number}_import_custom_avatar_items.sql`, import.meta.url).pathname +) +writeFileSync(out, lines.join('\n') + '\n') +console.log(`wrote ${records.length} records to ${out}`) diff --git a/apps/api/src/custom-avatar-items-db.ts b/apps/api/src/custom-avatar-items-db.ts index dcf7b57..d3ca29b 100644 --- a/apps/api/src/custom-avatar-items-db.ts +++ b/apps/api/src/custom-avatar-items-db.ts @@ -1,42 +1,88 @@ /** - * 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. + * Custom avatar items on the shared `recflare` D1 database: the player-designed shirts built + * on a base catalog item, and the first-party items imported from the official service, which + * are the same record with a set of built Unity assetbundles (`CurrentSaves`) in place of a + * base item and a design PNG. * - * 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//-thumb.png` and `-design.png`; the two filename columns hold - * those bucket keys, which the `img` worker serves back by key. + * A row is the client's `CustomAvatarItem` record verbatim, as JSON in `data` — the layout the + * `invention`, `event`, `room`, `account` and `club` tables use — with the fields the queries + * filter and sort on exposed as generated (virtual) columns. So a row IS the response, an + * import is the exported record as-is, and a field the source adds later is served without a + * migration. The flat one-column-per-field table this replaced (0015) could not hold a + * first-party item at all: its base-item and filename columns were NOT NULL, and it had no home + * for the saves. * - * The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql, - * applied under its own `migrations_table`). + * `CurrentSaves` stays EMBEDDED rather than in a child table: the client reads a save off its + * item and picks one by `BodyType`, and nothing on this server looks a save up by its bare id + * (the legacy-item lookup keys them by `AvatarItemDesc`). Room saves got their own table + * because the client points at one by bare id; that does not apply here. + * + * The two uploads that accompany a player's creation (the design blob and the thumbnail PNG) + * live in the shared image bucket (`recflare-img`, the `IMAGES` binding) under + * `avatar-item//-thumb.png` and `-design.png`; `ThumbnailImageFilename` and + * `DesignFilename` hold those bucket keys, which the `img` worker serves back by key. A + * first-party item has both null and is rendered from its saves' assetbundles instead. + * + * The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql, rebuilt + * as JSON by 0022_custom_avatar_item_json.sql, applied under its own `migrations_table`). */ -/** Schema DDL (mirror of migrations/0015_custom_avatar_item.sql). */ +/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.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 + data TEXT NOT NULL, + custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL, + creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL, + accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL, + outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL, + price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL, + is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL, + created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL, + name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL, + description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`, `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). */ +/** + * One built version of a first-party item, as the client's PascalCase `CustomAvatarItemSave`: + * a Unity assetbundle (`UnityAsset`, with `UnityAsset2` a second variant) the client downloads + * to render the item, plus the thumbnail it shows in the store. An item carries one per + * `BodyType` it was built for; the client picks the save matching the wearer's body. + * `AdditionalConfiguration` is the client's own JSON-in-a-string document and is served as + * stored. Not the camelCase `CustomAvatarItemSave` the legacy-item lookup documents — same + * save, different casing and field set; keep the two apart. + */ +export interface CustomAvatarItemSave { + CustomAvatarItemSaveId: number + CustomAvatarItemId: string + UnityAssetId: string + BodyType: number + OutfitType: number + QAState: number + CreatedAt: string + ModifiedAt: string + Description: string | null + ThumbnailFileName: string + AdditionalConfiguration: string + UnityAsset: string + UnityAssetHash: string + UnityAsset2: string | null + UnityAsset2Hash: string | null +} + +/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */ +export interface CustomAvatarItemTag { + TagType: number + Value: string +} + +/** + * The client's `CustomAvatarItem` record (PascalCase, as served). The four nullable fields are + * null on a first-party item, which has no base item or design PNG: it is rendered from + * `CurrentSaves` instead, which is empty on a player-made shirt. + */ export interface CustomAvatarItem { CustomAvatarItemId: string CreatorAccountId: number @@ -47,16 +93,19 @@ export interface CustomAvatarItem { ForceCannotPublish: boolean IsFeatured: boolean IsRecRoomApproved: boolean - BaseAvatarItemId: number - BaseAvatarItemColor: string - DesignFilename: string - ThumbnailImageFilename: string + BaseAvatarItemId: number | null + BaseAvatarItemColor: string | null + DesignFilename: string | null + ThumbnailImageFilename: string | null CreatedAt: string ModifiedAt: string PreviewOrientation: number RankingContext: null OutfitType: number - CurrentSaves: never[] + CurrentSaves: CustomAvatarItemSave[] + Tags: CustomAvatarItemTag[] + CustomBadgeMetadata: unknown + RankedEntityId: string PurchaseInfo: null } @@ -71,7 +120,8 @@ export interface CustomAvatarItem { * This is what the store's user-generated-content tab searches for, ALONE * (`GET /api/customAvatarItems/v1/search?outfitTypes=105&includeCoachItems=False`), so it is * what every created item must be filed under: the table's default of 0 (Hat) matched nothing, - * and the tab sat empty over a full catalog. + * and the tab sat empty over a full catalog. The imported first-party items carry their own + * slot (a wing is 100, Shoulder) and are found by the storefront tab's dozen-slot query. * * Not to be confused with the search's `itemTypes`, a different 3-member enum (All -1, None 0, * Shirt 1) whose Shirt is 1, not 101 or 105. The client sends `itemTypes=-1` (All) alongside @@ -97,85 +147,88 @@ export interface CreateCustomAvatarItemInput { } 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 + data: string } +/** + * What a stored record may lack and the response must carry: an imported record predates + * some of these, and `PurchaseInfo` is a store-side projection (`econ` prices items), never + * stored. Everything the record does carry wins over these. + */ +const DTO_DEFAULTS = { + CurrentSaves: [] as CustomAvatarItemSave[], + Tags: [] as CustomAvatarItemTag[], + CustomBadgeMetadata: null, + RankingContext: null, + PurchaseInfo: null, +} as const + 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, - } + const stored = JSON.parse(row.data) as CustomAvatarItem + return { ...DTO_DEFAULTS, ...stored, RankingContext: null, PurchaseInfo: null } } -/** Inserts a new custom avatar item and returns it as the client's DTO. */ +/** Inserts a new player-made custom avatar item and returns it as the client's DTO. */ export async function createCustomAvatarItem( db: D1Database, input: CreateCustomAvatarItemInput, now: Date = new Date() ): Promise { const ts = now.toISOString() + const record: Omit = { + CustomAvatarItemId: input.customAvatarItemId, + CreatorAccountId: input.creatorAccountId, + Name: input.name, + Description: input.description, + Price: input.price, + Accessibility: input.accessibility, + ForceCannotPublish: false, + IsFeatured: false, + IsRecRoomApproved: false, + BaseAvatarItemId: input.baseAvatarItemId, + BaseAvatarItemColor: input.baseAvatarItemColor, + DesignFilename: input.designFilename, + ThumbnailImageFilename: input.thumbnailImageFilename, + CreatedAt: ts, + ModifiedAt: ts, + PreviewOrientation: 0, + OutfitType: input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT, + CurrentSaves: [], + Tags: [], + CustomBadgeMetadata: null, + RankedEntityId: input.customAvatarItemId, + RankingContext: null, + } 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, outfit_type - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, ?12) - RETURNING *` - ) - .bind( - input.customAvatarItemId, - input.creatorAccountId, - input.name, - input.description, - input.price, - input.accessibility, - input.baseAvatarItemId, - input.baseAvatarItemColor, - input.designFilename, - input.thumbnailImageFilename, - ts, - input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT - ) + .prepare('INSERT INTO custom_avatar_item (data) VALUES (?1) RETURNING data') + .bind(JSON.stringify(record)) .first() if (!row) throw new Error('custom_avatar_item insert returned no row') return toDto(row) } +/** + * Stores a record as exported from the official service (the first-party items), verbatim, + * replacing any row with the same `CustomAvatarItemId` so a re-import with corrections lands. + * This is what the generated import migration does in SQL; it is here for tests and tooling. + */ +export async function importCustomAvatarItem( + db: D1Database, + record: Omit & { PurchaseInfo?: unknown } +): Promise { + const { PurchaseInfo: _purchaseInfo, ...stored } = record + const row = await db + .prepare( + `INSERT INTO custom_avatar_item (data) VALUES (?1) + ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data + RETURNING data` + ) + .bind(JSON.stringify(stored)) + .first() + if (!row) throw new Error('custom_avatar_item import 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`). @@ -200,6 +253,8 @@ export interface UgcPurchasableItem { * 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. + * `ImageName` is the item's own thumbnail for a player-made shirt; a first-party item has + * none at the item level, so the first save's thumbnail stands in. */ export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem { return { @@ -207,7 +262,7 @@ export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPur ItemId: item.CustomAvatarItemId, Name: item.Name, Description: item.Description, - ImageName: item.ThumbnailImageFilename, + ImageName: item.ThumbnailImageFilename ?? item.CurrentSaves[0]?.ThumbnailFileName ?? '', RoomId: roomId, Price: item.Price, PurchaseCurrencyId: null, @@ -224,10 +279,10 @@ export async function getCustomAvatarItems( 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})`) + .prepare(`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`) .bind(...ids) .all() - const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)])) + const byId = new Map(results.map(toDto).map((item) => [item.CustomAvatarItemId, item])) return ids.flatMap((id) => byId.get(id) ?? []) } @@ -243,7 +298,7 @@ export async function listFeaturedCustomAvatarItems( ): Promise { const { results } = await db .prepare( - `SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0 + `SELECT data FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0 ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1` ) .bind(limit) @@ -263,7 +318,7 @@ export async function listHotCustomAvatarItems( ): Promise { const { results } = await db .prepare( - `SELECT * FROM custom_avatar_item WHERE accessibility != 0 + `SELECT data FROM custom_avatar_item WHERE accessibility != 0 ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1` ) .bind(limit) @@ -314,10 +369,10 @@ export const SEARCH_MAX_TAKE = 200 * 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. + * `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring, + * or its `CustomAvatarItemId` whole. 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 @@ -351,8 +406,13 @@ export async function searchCustomAvatarItems( // 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}%`) + // `name_lower`/`description_lower` are generated columns, lowered once at the row rather + // than per query. An id typed in whole finds its item too, since the client searches by + // `CustomAvatarItemId` as well as by text. + const exact = bind(needle.toLowerCase()) where.push( - `(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')` + `(name_lower LIKE ${pattern} ESCAPE '\\' OR description_lower LIKE ${pattern} ESCAPE '\\'` + + ` OR lower(custom_avatar_item_id) = ${exact})` ) } @@ -372,7 +432,7 @@ export async function searchCustomAvatarItems( const offset = bind(skip) const { results } = await db .prepare( - `SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')} + `SELECT data FROM custom_avatar_item WHERE ${where.join(' AND ')} ORDER BY created_at DESC, custom_avatar_item_id LIMIT ${limit} OFFSET ${offset}` ) @@ -395,7 +455,7 @@ export async function listCustomAvatarItemsByCreator( ): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> { const { results } = await db .prepare( - `SELECT * FROM custom_avatar_item + `SELECT data FROM custom_avatar_item WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2) ORDER BY created_at DESC, custom_avatar_item_id` ) @@ -414,8 +474,9 @@ export interface UpdateCustomAvatarItemInput { } /** - * 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). + * Applies a partial edit to one item in place with `json_set`, bumping `ModifiedAt`. 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( @@ -426,14 +487,14 @@ export async function updateCustomAvatarItem( ): Promise { 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 + `UPDATE custom_avatar_item SET data = json_set(data, + '$.Name', coalesce(?2, json_extract(data, '$.Name')), + '$.Description', coalesce(?3, json_extract(data, '$.Description')), + '$.Price', coalesce(?4, json_extract(data, '$.Price')), + '$.Accessibility', coalesce(?5, json_extract(data, '$.Accessibility')), + '$.ModifiedAt', ?6) WHERE custom_avatar_item_id = ?1 - RETURNING *` + RETURNING data` ) .bind( id, @@ -453,7 +514,7 @@ export async function deleteCustomAvatarItem( id: string ): Promise { const row = await db - .prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *') + .prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING data') .bind(id) .first() return row ? toDto(row) : null @@ -465,7 +526,7 @@ export async function getCustomAvatarItem( id: string ): Promise { const row = await db - .prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1') + .prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id = ?1') .bind(id) .first() return row ? toDto(row) : null diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index c05009b..3debbb5 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -374,7 +374,36 @@ export const CreateCustomAvatarItemRequest = z.object({ design: z.string().describe('The design blob (binary file part)'), }) -/** The client's `CustomAvatarItem` record. */ +/** + * One built version of a first-party item, PascalCase as it rides inside `CurrentSaves`: the + * Unity assetbundle the client downloads to render the item on one `BodyType`, its hash, and + * the thumbnail the store shows. `AdditionalConfiguration` is the client's own JSON-in-a-string. + * Not the camelCase `CustomAvatarItemSave` of the legacy-item lookup below — same save, + * different casing and field set. + */ +export const CustomAvatarItemCurrentSave = z.object({ + CustomAvatarItemSaveId: z.int(), + CustomAvatarItemId: z.string(), + UnityAssetId: z.string(), + BodyType: z.int(), + OutfitType: z.int(), + QAState: z.int(), + CreatedAt: z.string(), + ModifiedAt: z.string(), + Description: z.string().nullable(), + ThumbnailFileName: z.string(), + AdditionalConfiguration: z.string().describe('JSON-in-a-string'), + UnityAsset: z.string(), + UnityAssetHash: z.string(), + UnityAsset2: z.string().nullable(), + UnityAsset2Hash: z.string().nullable(), +}) + +/** + * The client's `CustomAvatarItem` record, served straight out of the row's JSON. A player-made + * shirt has a base item and a design PNG and no saves; a first-party item has the four + * base/filename fields null and is rendered from `CurrentSaves` instead. + */ export const CustomAvatarItemDto = z.object({ CustomAvatarItemId: z.string(), CreatorAccountId: z.number().int(), @@ -385,16 +414,19 @@ export const CustomAvatarItemDto = z.object({ ForceCannotPublish: z.boolean(), IsFeatured: z.boolean(), IsRecRoomApproved: z.boolean(), - BaseAvatarItemId: z.number().int(), - BaseAvatarItemColor: z.string(), - DesignFilename: z.string(), - ThumbnailImageFilename: z.string(), + BaseAvatarItemId: z.number().int().nullable(), + BaseAvatarItemColor: z.string().nullable(), + DesignFilename: z.string().nullable(), + ThumbnailImageFilename: z.string().nullable(), CreatedAt: z.string(), ModifiedAt: z.string(), PreviewOrientation: z.number().int(), RankingContext: z.null(), OutfitType: z.number().int(), - CurrentSaves: z.array(z.unknown()), + CurrentSaves: z.array(CustomAvatarItemCurrentSave), + Tags: z.array(z.object({ TagType: z.int(), Value: z.string() })), + CustomBadgeMetadata: z.unknown().nullable(), + RankedEntityId: z.string(), PurchaseInfo: z.null(), }) diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 91a094d..9832653 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -639,8 +639,13 @@ export const avatarRoutes = new Hono({ strict: false }) 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]) + // The row is gone; the objects follow. A missing key is a no-op for R2. A first-party + // item has neither (it is rendered from its saves' assetbundles), so there is nothing + // to delete for one. + const keys = [item.ThumbnailImageFilename, item.DesignFilename].filter( + (k): k is string => k !== null + ) + if (keys.length > 0) await c.env.IMAGES.delete(keys) return c.json({ Value: item, Success: true, Error: null, error_id: null }) } ) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index c88ce48..d66c2b3 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -35,6 +35,7 @@ import { banEvasionMatch, resolveBan } from '../../bans-db' import { createCustomAvatarItem, SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL, + importCustomAvatarItem, } from '../../custom-avatar-items-db' import { countGoing, @@ -999,7 +1000,8 @@ describe('public endpoints', () => { 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' + `UPDATE custom_avatar_item SET data = json_set(data, '$.IsFeatured', json('true')) + WHERE custom_avatar_item_id != ?1` ) .bind(unflagged.CustomAvatarItemId) .run() @@ -1153,7 +1155,8 @@ describe('public endpoints', () => { // slots to filter across, so set them straight in the table. expect(created.OutfitType).toBe(105) await env.DB.prepare( - 'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1' + `UPDATE custom_avatar_item SET data = json_set(data, '$.OutfitType', ?2) + WHERE custom_avatar_item_id = ?1` ) .bind(created.CustomAvatarItemId, outfitType) .run() @@ -1320,6 +1323,152 @@ describe('public endpoints', () => { ).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat']) }) + test('GET /api/customAvatarItems/v1/search serves an imported first-party item as exported', async () => { + await env.DB.prepare('DELETE FROM custom_avatar_item').run() + + // A first-party item, as the official service exports it: the same record as a player's + // shirt with the base-item and filename fields NULL and a built Unity assetbundle per + // body type in `CurrentSaves`. The flat table could not hold this at all; the JSON row + // stores it verbatim and the search serves it back as-is, saves included. + const wings = { + Accessibility: 1, + BaseAvatarItemColor: null, + BaseAvatarItemId: null, + CreatedAt: '2024-09-26T21:32:48.523Z', + CreatorAccountId: 1, + CurrentSaves: [ + { + AdditionalConfiguration: + '{"v":1,"d":{"n":"(BB_SkellyWings_Shoulder)","o":100,"b":0,"ir":[6,184,0],"p":"3a74ca85-fa71-44c7-9c28-aabd605908db","a":"09999705-15d4-4a84-9c0a-65cfe5bde8d7"},"ls":1}', + BodyType: 0, + CreatedAt: '2024-10-02T22:50:38.809Z', + CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568', + CustomAvatarItemSaveId: 1083, + Description: null, + ModifiedAt: '2024-11-27T02:34:07.523Z', + OutfitType: 100, + QAState: 2, + ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png', + UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle', + UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle', + UnityAsset2Hash: '/QYNs1vk1E+GF6qhVf3eR4YUCb0z4vFVkzPIA6mqjqY=', + UnityAssetHash: 'hQjd30phdbC0U7lBMOX/YtpwxEjPwVvVJaQ/4UzKA0Y=', + UnityAssetId: '7ba365a7-cf85-4029-80a3-7d0a08ab6ce6', + }, + { + AdditionalConfiguration: + '{"v":1,"d":{"n":"(FB_SkellyWings_Shoulder)","o":100,"b":1,"ir":[6,186,0],"p":"09999705-15d4-4a84-9c0a-65cfe5bde8d7","a":"3a74ca85-fa71-44c7-9c28-aabd605908db"},"ls":1}', + BodyType: 1, + CreatedAt: '2024-10-02T22:52:04.891Z', + CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568', + CustomAvatarItemSaveId: 1085, + Description: null, + ModifiedAt: '2024-11-27T02:34:07.523Z', + OutfitType: 100, + QAState: 2, + ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png', + UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle', + UnityAsset2: '5ppm948m3znl2chvbvy88j092.assetbundle', + UnityAsset2Hash: 'ZtPbhPSKXkBD9bsmajTmO7A99Ew8tCQBYjE1THE+VUk=', + UnityAssetHash: 'TCPaQ1+T74pPBWiVChCYDyC/MNHjHbWEYk7leIqVTOU=', + UnityAssetId: '03413da3-e63c-4971-8ca9-c726bc90c0e2', + }, + ], + CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568', + CustomBadgeMetadata: null, + Description: + 'This item will have a fix on its connection point to the back on some body types in a future patch.', + DesignFilename: null, + ForceCannotPublish: false, + IsFeatured: false, + IsRecRoomApproved: true, + ModifiedAt: '2025-02-19T05:19:28.328Z', + Name: 'Skeletal Wings', + OutfitType: 100, + PreviewOrientation: 0, + Price: 6000, + RankedEntityId: '83fe651f-15b3-46a7-8afc-adec32c35568', + RankingContext: null, + Tags: [{ TagType: 0, Value: 'export' }], + ThumbnailImageFilename: null, + } + await importCustomAvatarItem(env.DB, wings) + // A player-made shirt beside it, to prove the two kinds share one table and one shape. + const shirt = await createCustomAvatarItem(env.DB, { + customAvatarItemId: crypto.randomUUID(), + creatorAccountId: 205, + name: 'Skeleton Tee', + description: '', + price: 0, + baseAvatarItemId: 2184, + baseAvatarItemColor: '#F55C1A', + accessibility: 1, + designFilename: 'design_x.bin', + thumbnailImageFilename: 'thumb_x.png', + }) + + 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> + } + + // The storefront tab's real query finds it (Coach-authored, OutfitType 100 among the + // dozen slots asked for) and serves the record exactly as exported, plus the store-side + // `PurchaseInfo` the client asked for with `includePurchaseInfos` (null until priced). + const store = 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(store).toEqual([{ ...wings, PurchaseInfo: null }]) + + // Text search matches it by name and by description, and by its id typed whole. + expect((await search('?searchQuery=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings']) + expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([ + 'Skeletal Wings', + ]) + expect( + (await search('?searchQuery=83FE651F-15B3-46A7-8AFC-ADEC32C35568')).map((i) => i.Name) + ).toEqual(['Skeletal Wings']) + // "skeleton" is not a substring of "skeletal": only the shirt. + expect((await search('?searchQuery=skeleton')).map((i) => i.Name)).toEqual(['Skeleton Tee']) + + // The user-generated-content tab's query does not see it: wrong side of the catalog and + // wrong slot both. + expect( + (await search('?outfitTypes=105&includeCoachItems=False')).map((i) => i.CustomAvatarItemId) + ).toEqual([shirt.CustomAvatarItemId]) + + // A player-made shirt still carries the full shape — empty saves, empty tags — so the + // client decodes both kinds through one parser. + const [tee] = await search('?searchQuery=skeleton') + expect(tee).toMatchObject({ + BaseAvatarItemId: 2184, + CurrentSaves: [], + Tags: [], + CustomBadgeMetadata: null, + RankedEntityId: shirt.CustomAvatarItemId, + }) + + // Re-importing the same id REPLACES the record (a corrected export lands) rather than + // duplicating or being ignored. + await importCustomAvatarItem(env.DB, { ...wings, Price: 5000 }) + expect((await search('?searchQuery=skeletal')).map((i) => i.Price)).toEqual([5000]) + + // The bulk lookup resolves it too, saves and all. + const bulk = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('42')) }, + body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId]]), + }) + expect(bulk.status).toBe(200) + const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }> + expect(got.CurrentSaves).toHaveLength(2) + expect(got.Price).toBe(5000) + }) + 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 = { @@ -4104,11 +4253,16 @@ describe('custom avatar items', () => { BaseAvatarItemColor: '#F55C1A', PreviewOrientation: 0, RankingContext: null, - OutfitType: 0, + // Filed as a custom shirt, the slot the store's user-generated-content tab searches. + OutfitType: 105, + // The full shape a first-party item carries, empty: no built saves, no tags. CurrentSaves: [], + Tags: [], + CustomBadgeMetadata: null, PurchaseInfo: null, }) const itemId = body.Value.CustomAvatarItemId as string + expect(body.Value.RankedEntityId).toBe(itemId) 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`) @@ -4126,8 +4280,10 @@ describe('custom avatar items', () => { new Uint8Array([4, 5, 6]) ) + // The row is the record as JSON, with the queried fields generated off it. const row = await env.DB.prepare( - 'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1' + `SELECT json_extract(data, '$.Name') AS name, creator_account_id + FROM custom_avatar_item WHERE custom_avatar_item_id = ?1` ) .bind(body.Value.CustomAvatarItemId) .first() @@ -4302,7 +4458,7 @@ describe('custom avatar items', () => { // Neither rejected request may create metadata or leave one of its two objects behind. const row = await env.DB.prepare( - "SELECT COUNT(*) AS n FROM custom_avatar_item WHERE name = 'bounded'" + "SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'bounded'" ).first<{ n: number }>() expect(row?.n).toBe(0) expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(