/** * 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//-thumb.png` and `-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 { 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() 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 { 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() 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 { 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() return results.map(toDto) } /** * The "hot" (trending) feed (`GET /api/customAvatarItems/v1/hot`): every PUBLISHED item — * `Accessibility` 0 is the unpublished state and is the only thing held back. Newest * first, standing in for a trend ranking there is nothing to compute one from yet (no * purchase or wear counts are recorded). */ export async function listHotCustomAvatarItems( db: D1Database, limit = 50 ): Promise { const { results } = await db .prepare( `SELECT * FROM custom_avatar_item WHERE accessibility != 0 ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1` ) .bind(limit) .all() return results.map(toDto) } /** * The "Coach" system account — this server's stock content is authored by it, the same id the * `econ` worker attributes a self-buy or an anonymous gift to. */ export const COACH_ACCOUNT_ID = 1 /** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */ export interface CustomAvatarItemSearch { /** Free text, matched against an item's NAME or its DESCRIPTION. Blank means no filter. */ searchQuery?: string /** * `OutfitType`s to include. EMPTY means no filter rather than no results: the client sends * the full set of types it can render, so an absent parameter is "everything", not "nothing". */ outfitTypes?: number[] /** Whether items authored by the Coach — this server's stock content — are included. */ includeCoachItems?: boolean /** Lowest price to include, inclusive. */ minPrice?: number /** Highest price to include, inclusive. */ maxPrice?: number /** Rows to skip, for paging. */ skip?: number /** Rows to return. Capped at {@link SEARCH_MAX_TAKE}. */ take?: number } /** The most rows one search returns, whatever `take` asks for. The client asks for 100. */ export const SEARCH_MAX_TAKE = 200 /** * The store's item search (`GET /api/customAvatarItems/v1/search`), newest first. * * PUBLISHED items only — `Accessibility` 0 is the unpublished state, and this is the browse * surface everyone shares, so an unpublished item must not appear here even to its creator (who * has `fromCreator` for that). * * `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring. * Both sides are lowered rather than relying on `LIKE`, which folds case for ASCII only and * would miss half of what players type. `%` and `_` in the needle are escaped, so searching for * a literal one finds it instead of matching everything. * * `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of * how an empty IN () clause reads in SQL: the client sends every type it can render, so treating * an absent parameter as "match nothing" would empty the store. * * Ordered by recency because there is nothing else to order by — no purchase counts, no wear * counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The * `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a * `created_at` can swap places between pages and one is served twice while the other is missed. */ export async function searchCustomAvatarItems( db: D1Database, search: CustomAvatarItemSearch = {} ): Promise { const take = Math.min(Math.max(search.take ?? 50, 0), SEARCH_MAX_TAKE) const skip = Math.max(search.skip ?? 0, 0) if (take === 0) return [] const where = ['accessibility != 0'] const binds: Array = [] /** Bind a value and get its placeholder, so the numbering can't drift as clauses are added. */ const bind = (value: number | string): string => `?${binds.push(value)}` const needle = search.searchQuery?.trim() ?? '' if (needle !== '') { // Escaped so a needle of LIKE metacharacters matches them literally rather than everything. const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`) const pattern = bind(`%${escaped}%`) where.push( `(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')` ) } const outfitTypes = search.outfitTypes ?? [] if (outfitTypes.length > 0) { where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`) } if (search.includeCoachItems === false) { where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`) } if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`) if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`) const limit = bind(take) const offset = bind(skip) const { results } = await db .prepare( `SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')} ORDER BY created_at DESC, custom_avatar_item_id LIMIT ${limit} OFFSET ${offset}` ) .bind(...binds) .all() 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() 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 { 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() 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 { const row = await db .prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *') .bind(id) .first() return row ? toDto(row) : null } /** Fetches one item by id, or null. */ export async function getCustomAvatarItem( db: D1Database, id: string ): Promise { const row = await db .prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1') .bind(id) .first() return row ? toDto(row) : null }