Files
recflare/apps/api/src/custom-avatar-items-db.ts
T
2026-08-27 22:42:35 -04:00

438 lines
15 KiB
TypeScript

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