mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[api,econ,img] shirts fix #35
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user