[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
+49
View File
@@ -21,6 +21,13 @@ import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
// Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
// their own, and buyInvention has to read the very rows `api` writes.
// Custom avatar items likewise live in an `api`-owned table; the UGC-purchasable bulk
// lookup is the store's view of those rows.
import {
getCustomAvatarItems,
toUgcPurchasable,
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
} from '../../api/src/custom-avatar-items-db'
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
// The notification-type ids the hub carries, and the payload shapes recovered from the
// client's own decoder (both owned by the `notify` worker). Imported rather than copied so
@@ -92,6 +99,8 @@ import {
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
UgcPurchasableBulkRequest,
UgcPurchasableItemList,
UNAUTHORIZED_RESPONSE,
UpdateObjectiveRequest,
UpdateObjectiveResponse,
@@ -2216,6 +2225,46 @@ const app = new Hono<App>({ strict: false })
(c) => c.json([])
)
// Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items
// (type 3) exist to resolve; they come off the api-owned `custom_avatar_item` table.
.post(
'/api/ugcPurchasables/v1/items/bulk',
describeRoute({
tags: ['Rooms'],
summary: 'Look up UGC purchasables by id',
description:
'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and ' +
'answers the store-facing `UgcPurchasableItem` view of each, in request order. ' +
'Only `itemType` 3 (custom avatar item) is served; other types and unknown ids are ' +
'dropped. `RoomId` is echoed onto every item — what the client wants it for is ' +
'not yet known. `PurchaseCurrencyId` is null until a currency exists.',
security: AUTHED,
requestBody: jsonBody(UgcPurchasableBulkRequest, 'The room and the ids to resolve'),
responses: {
200: json(UgcPurchasableItemList, 'The resolved items (unknown ids omitted)'),
400: json(ErrorResponse, 'Malformed body'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (!body || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400)
const roomId = typeof body.RoomId === 'number' ? body.RoomId : 0
const ids = (body.Ids as unknown[]).flatMap((ref) => {
if (!ref || typeof ref !== 'object') return []
const { itemType, itemId } = ref as Record<string, unknown>
return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string'
? [itemId]
: []
})
const items = await getCustomAvatarItems(c.env.DB, ids)
return c.json(items.map((item) => toUgcPurchasable(item, roomId)))
}
)
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
// unlocked-consumable DTO. A player who has bought none gets an empty list.
+28
View File
@@ -401,6 +401,34 @@ export const BuyInventionResponse = z.object({
})
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
export const UgcPurchasableBulkRequest = z.object({
RoomId: z.number().int().describe('Echoed back on each item; not otherwise used'),
Ids: z.array(
z.object({
itemType: z.number().int().describe('3 = custom avatar item (the only type served)'),
itemId: z.string().describe('The `CustomAvatarItemId`'),
})
),
})
/** The client's `UgcPurchasableItem` — a store-facing view of a custom avatar item. */
export const UgcPurchasableItemDto = z.object({
ItemType: z.number().int(),
ItemId: z.string(),
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
RoomId: z.number().int(),
Price: z.number().int(),
PurchaseCurrencyId: z.string().nullable(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
})
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto)
export const ErrorResponse = z.object({ error: z.string() })
// ---- Request schemas -------------------------------------------------------
@@ -15,6 +15,10 @@ import {
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
// is built here too (see the same cross-worker import in econ.app.ts).
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
} from '../../../../api/src/custom-avatar-items-db'
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
// The notification-type ids the hub carries, from the worker that owns them — asserting
// against the enum rather than a copied number is what keeps these frames honest.
@@ -69,6 +73,7 @@ beforeAll(async () => {
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
@@ -675,6 +680,63 @@ describe('econ endpoints', () => {
}
})
test('POST /api/ugcPurchasables/v1/items/bulk resolves custom avatar items, echoing RoomId', async () => {
const item = await createCustomAvatarItem(env.DB, {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Neon Visor',
description: '',
price: 250,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 0,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
})
const res = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({
RoomId: 92,
Ids: [
{ itemType: 3, itemId: item.CustomAvatarItemId },
{ itemType: 3, itemId: 'does-not-exist' },
{ itemType: 1, itemId: item.CustomAvatarItemId },
],
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
ItemType: 3,
ItemId: item.CustomAvatarItemId,
Name: 'Neon Visor',
Description: '',
ImageName: 'thumb_x.png',
RoomId: 92,
Price: 250,
PurchaseCurrencyId: null,
CreatedAt: item.CreatedAt,
ModifiedAt: item.ModifiedAt,
},
])
})
test('POST /api/ugcPurchasables/v1/items/bulk 400s without Ids and 401s without a token', async () => {
const bad = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({ RoomId: 92 }),
})
expect(bad.status).toBe(400)
const anon = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ RoomId: 92, Ids: [] }),
})
expect(anon.status).toBe(401)
})
test('GET /econ/roomEconConfig/:roomId echoes the room and disables sorting tabs', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`)
expect(anon.status).toBe(401)
@@ -2868,6 +2930,7 @@ describe('econ endpoints', () => {
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem',
'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update',
])