[api,econ,img] shirts fix #35

This commit is contained in:
Devin Zuczek
2026-08-26 00:38:02 -04:00
parent 199c34d1cb
commit 7ef949bfcf
12 changed files with 1247 additions and 30 deletions
+320
View File
@@ -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
}