mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
438 lines
15 KiB
TypeScript
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
|
|
}
|