mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] purchase offer delete
This commit is contained in:
@@ -106,6 +106,7 @@ import {
|
||||
CreateRoomCurrencyRequest,
|
||||
CreateRoomKeyRequest,
|
||||
CustomAvatarItemsResponse,
|
||||
DeletePurchaseOfferRequest,
|
||||
EquipmentUpdateRequest,
|
||||
ErrorResponse,
|
||||
form,
|
||||
@@ -170,6 +171,7 @@ import {
|
||||
awardRoomCurrency,
|
||||
createPurchaseOffer,
|
||||
createRoomCurrency,
|
||||
deletePurchaseOffer,
|
||||
findPurchaseOffer,
|
||||
getPurchaseOffers,
|
||||
getRoomBalance,
|
||||
@@ -348,6 +350,7 @@ const HUB_INSTANCE = 'global'
|
||||
const CREATE_CURRENCY_FAILED = 'Failed to create currency'
|
||||
const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
|
||||
const CREATE_OFFER_FAILED = 'Failed to create purchase offer'
|
||||
const DELETE_OFFER_FAILED = 'Failed to delete purchase offer'
|
||||
const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable'
|
||||
/**
|
||||
* `Status` on the room-key create's `{ Status, RoomKey }` reply. 0 is the observed success
|
||||
@@ -415,8 +418,8 @@ function roomConsumableEnvelope(c: Context<App>, value: RoomConsumable | null, e
|
||||
}
|
||||
|
||||
/**
|
||||
* The create-offer envelope — the same `{ Value, Success, Error, error_id }` shape again,
|
||||
* carrying one purchase offer.
|
||||
* The create- and delete-offer envelope — the same `{ Value, Success, Error, error_id }`
|
||||
* shape again, carrying one purchase offer.
|
||||
*/
|
||||
function purchaseOfferEnvelope(
|
||||
c: Context<App>,
|
||||
@@ -4211,6 +4214,64 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Take a purchase offer down. Auth-gated (401) and gated to the creator or a co-owner of
|
||||
// the room that minted the currency the offer sells (403). The body names ONLY the offer,
|
||||
// so the currency — and through it the room — is found by walking the shops for the id;
|
||||
// nothing about the caller's whereabouts is consulted.
|
||||
.post(
|
||||
'/api/roomcurrencies/v1/deletePurchaseOffer',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Remove a purchase offer from a room currency',
|
||||
description: [
|
||||
'Removes the offer named by `PurchaseOfferId` (its `CurrencyPurchaseOfferId`) from',
|
||||
'whichever currency’s shop lists it. Form-encoded (`PurchaseOfferId=…`). The body',
|
||||
'names nothing else: the currency, and the room that gates the delete, are found',
|
||||
'from the offer.',
|
||||
'',
|
||||
'Gated to the creator or a co-owner of the room that minted the currency. A valid',
|
||||
'token from anyone else is a 403. Answers the same',
|
||||
'`{ Value, Success, Error, error_id }` envelope the create answers in, with the',
|
||||
'offer as it stood when removed in `Value`, or a 200 carrying `Success: false` when',
|
||||
'no shop lists that id. What the client does with `Value` here is an assumption —',
|
||||
'only the create’s reply has been observed.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(DeletePurchaseOfferRequest, 'The offer to remove'),
|
||||
responses: {
|
||||
200: json(
|
||||
RoomCurrencyPurchaseOfferEnvelope,
|
||||
'The offer as removed, or a rejection with `Success: false`'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the creator or a co-owner of the minting room (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const purchaseOfferId = (
|
||||
typeof body.PurchaseOfferId === 'string' ? body.PurchaseOfferId : ''
|
||||
).trim()
|
||||
if (purchaseOfferId === '') return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||||
|
||||
// Found BEFORE it is removed, because the find is what says which room gates the
|
||||
// delete: an unknown id is a refusal, and a known one is somebody's to refuse.
|
||||
const found = await findPurchaseOffer(c.env.DB, purchaseOfferId)
|
||||
if (!found) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||||
|
||||
const canManage = await canManageRoomById(c.env.DB, found.RoomId, accountId)
|
||||
if (canManage === null) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||||
if (!canManage) return c.body(null, 403)
|
||||
|
||||
const removed = await deletePurchaseOffer(c.env.DB, purchaseOfferId)
|
||||
if (!removed) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||||
return purchaseOfferEnvelope(c, removed.Offer)
|
||||
}
|
||||
)
|
||||
|
||||
// Award room currency to players, several awards per call. Auth-gated (401); everything
|
||||
// else is reported PER ENTRY, including whether the caller may award that currency at all.
|
||||
.post(
|
||||
|
||||
@@ -1071,9 +1071,17 @@ export const CreatePurchaseOfferRequest = z.object({
|
||||
Order: z.string().optional().describe('Where it sits in the shop; defaults to 0'),
|
||||
})
|
||||
|
||||
/** `POST /api/roomcurrencies/v1/deletePurchaseOffer` — form-encoded. */
|
||||
export const DeletePurchaseOfferRequest = z.object({
|
||||
PurchaseOfferId: z
|
||||
.string()
|
||||
.describe('The offer to take down, by its `CurrencyPurchaseOfferId` (any letter case)'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope the create-offer endpoint answers in — the same
|
||||
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer in `Value`.
|
||||
* The envelope the create- and delete-offer endpoints answer in — the same
|
||||
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer (as created,
|
||||
* or as it stood when removed) in `Value`.
|
||||
*/
|
||||
export const RoomCurrencyPurchaseOfferEnvelope = z.object({
|
||||
Value: RoomCurrencyPurchaseOfferDto.nullable(),
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* - `GET /api/roomcurrencies/v1/currencies?roomId=` lists a room's
|
||||
* - `POST /api/roomcurrencies/v1/awardCurrency/bulk` pays players
|
||||
* - the purchase offers below are the shop that sells the currency for other money
|
||||
* (`POST /api/roomcurrencies/v1/createPurchaseOffer` lists one, `…/deletePurchaseOffer`
|
||||
* takes it down)
|
||||
* - `POST /api/roomCurrencies/v2/purchase` buys from that shop, with tokens
|
||||
* - `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` spends it, on a room key
|
||||
*
|
||||
@@ -419,6 +421,48 @@ export async function findPurchaseOffer(
|
||||
return offer ? { Offer: offer, RoomId: row.room_id } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a purchase offer down by its `CurrencyPurchaseOfferId`, returning the offer that was
|
||||
* removed — beside its room, like {@link findPurchaseOffer} — or null when no shop lists it.
|
||||
*
|
||||
* The id is all the delete names, and the offer lives inside its currency's `purchase_offers`
|
||||
* JSON, so this is a find (which says which row, and which room gates the delete) and then a
|
||||
* rewrite of that ONE row's array without the offer. The rewrite is done in SQL —
|
||||
* `json_group_array` over `json_each`, keeping every entry whose id is not this one — rather
|
||||
* than read-modify-written here, so an offer being added to the same shop at the same moment
|
||||
* is not lost under a stale copy of the list; `createPurchaseOffer` appends in SQL for the same
|
||||
* reason. Filtered by id, not by position, so a concurrent append cannot shift the wrong entry
|
||||
* under the delete. The match is case-insensitive like the find: the client has been seen
|
||||
* sending ids in either case.
|
||||
*
|
||||
* A shop emptied by its last delete is left as `[]`, not reset to null: both read as "no
|
||||
* offers", and nothing distinguishes a shop never built from one cleared out.
|
||||
*/
|
||||
export async function deletePurchaseOffer(
|
||||
db: D1Database,
|
||||
purchaseOfferId: string
|
||||
): Promise<FoundPurchaseOffer | null> {
|
||||
const found = await findPurchaseOffer(db, purchaseOfferId)
|
||||
if (!found) return null
|
||||
|
||||
const { meta } = await db
|
||||
.prepare(
|
||||
`UPDATE room_currency
|
||||
SET purchase_offers = (
|
||||
SELECT json_group_array(json(o.value))
|
||||
FROM json_each(room_currency.purchase_offers) AS o
|
||||
WHERE lower(json_extract(o.value, '$.CurrencyPurchaseOfferId')) <> lower(?2)
|
||||
)
|
||||
WHERE currency_id = ?1 AND json_valid(purchase_offers)`
|
||||
)
|
||||
.bind(found.Offer.CurrencyId, purchaseOfferId)
|
||||
.run()
|
||||
|
||||
// The find said the row was there; a rewrite that touched nothing means it went away
|
||||
// between the two (the currency deleted, say), and there is no offer to report removed.
|
||||
return meta.changes > 0 ? found : null
|
||||
}
|
||||
|
||||
/** One currency's shop — the group the batch read answers in, one per currency asked for. */
|
||||
export interface RoomCurrencyPurchaseOffers {
|
||||
CurrencyId: string
|
||||
|
||||
@@ -2079,6 +2079,123 @@ describe('econ endpoints', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('POST deletePurchaseOffer takes one offer out of its shop, gated to the room', async () => {
|
||||
type Offer = {
|
||||
CurrencyPurchaseOfferId: string
|
||||
CurrencyId: string
|
||||
Order: number
|
||||
Name: string
|
||||
CurrencyAmount: number
|
||||
Price: number
|
||||
ModifiedAt: string
|
||||
}
|
||||
type Envelope = {
|
||||
Value: Offer | null
|
||||
Success: boolean
|
||||
Error: string | null
|
||||
error_id: null
|
||||
}
|
||||
const post = async (path: string, fields: Record<string, string>, sub: string | null) =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(sub === null ? {} : await bearer(sub)),
|
||||
},
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
const addOffer = async (currencyId: string, name: string, order: number) =>
|
||||
(
|
||||
(await (
|
||||
await post(
|
||||
'/api/roomcurrencies/v1/createPurchaseOffer',
|
||||
{
|
||||
CurrencyId: currencyId,
|
||||
Name: name,
|
||||
Amount: '5',
|
||||
Price: '50',
|
||||
Order: String(order),
|
||||
},
|
||||
'1'
|
||||
)
|
||||
).json()) as Envelope
|
||||
).Value!
|
||||
const remove = (offerId: string, sub: string | null) =>
|
||||
post('/api/roomcurrencies/v1/deletePurchaseOffer', { PurchaseOfferId: offerId }, sub)
|
||||
const shopOf = async (currencyId: string) =>
|
||||
(
|
||||
(await (
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/roomcurrencies/v1/getPurchaseOffersBatch?ids=${currencyId}`
|
||||
)
|
||||
).json()) as Array<{ PurchaseOffers: Offer[] }>
|
||||
)[0].PurchaseOffers.map((o) => o.Name)
|
||||
const refused = {
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Failed to delete purchase offer',
|
||||
error_id: null,
|
||||
}
|
||||
|
||||
// Two shops in room 2511, so the delete has to find the right one from the id alone.
|
||||
const shopped = (await envOf(await create({ ...body, Name: 'Shopped' }, await bearer('1'))))
|
||||
.Value!
|
||||
const other = (await envOf(await create({ ...body, Name: 'Other' }, await bearer('1'))))
|
||||
.Value!
|
||||
const first = await addOffer(shopped.CurrencyId, 'First', 0)
|
||||
const second = await addOffer(shopped.CurrencyId, 'Second', 1)
|
||||
const third = await addOffer(shopped.CurrencyId, 'Third', 2)
|
||||
const elsewhere = await addOffer(other.CurrencyId, 'Elsewhere', 0)
|
||||
|
||||
// No token → 401; a valid token with no standing in the room → 403, and the offer
|
||||
// stays. An id no shop lists, or none at all, is a refusal in the envelope.
|
||||
expect((await remove(second.CurrencyPurchaseOfferId, null)).status).toBe(401)
|
||||
expect((await remove(second.CurrencyPurchaseOfferId, '999')).status).toBe(403)
|
||||
expect(await shopOf(shopped.CurrencyId)).toEqual(['First', 'Second', 'Third'])
|
||||
for (const id of [crypto.randomUUID(), '']) {
|
||||
const res = await remove(id, '1')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual(refused)
|
||||
}
|
||||
|
||||
// The co-owner removes the middle one: the answer is the offer as it stood, the
|
||||
// shop keeps its neighbours in order, and the other currency's shop is untouched.
|
||||
const res = await remove(second.CurrencyPurchaseOfferId, '2')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
Value: second,
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
})
|
||||
expect(await shopOf(shopped.CurrencyId)).toEqual(['First', 'Third'])
|
||||
expect(await shopOf(other.CurrencyId)).toEqual(['Elsewhere'])
|
||||
// What is left is still what the create wrote, not a re-encoding of it.
|
||||
expect(
|
||||
(await (
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/roomcurrencies/v1/getPurchaseOffersBatch?ids=${shopped.CurrencyId}`
|
||||
)
|
||||
).json()) as Array<{ PurchaseOffers: Offer[] }>
|
||||
).toEqual([{ CurrencyId: shopped.CurrencyId, PurchaseOffers: [first, third] }])
|
||||
|
||||
// Gone means gone: a second delete is the unknown-id refusal. The id matches in any
|
||||
// letter case, like the purchase's lookup.
|
||||
expect(await (await remove(second.CurrencyPurchaseOfferId, '1')).json()).toEqual(refused)
|
||||
expect(
|
||||
(
|
||||
(await (
|
||||
await remove(third.CurrencyPurchaseOfferId.toUpperCase(), '1')
|
||||
).json()) as Envelope
|
||||
).Value?.Name
|
||||
).toBe('Third')
|
||||
// Emptying a shop leaves it an empty shop, still listed.
|
||||
await remove(first.CurrencyPurchaseOfferId, '1')
|
||||
expect(await shopOf(shopped.CurrencyId)).toEqual([])
|
||||
expect(await shopOf(other.CurrencyId)).toEqual(['Elsewhere'])
|
||||
expect(elsewhere.Name).toBe('Elsewhere')
|
||||
})
|
||||
|
||||
test('POST createCurrency pushes RoomCurrencyCreated to the room and the creator', async () => {
|
||||
// Two players standing in 2511, one somewhere else, and one whose presence lapsed.
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
@@ -7048,6 +7165,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/roomcurrencies/v1/awardCurrency/bulk',
|
||||
'POST /api/roomcurrencies/v1/createCurrency',
|
||||
'POST /api/roomcurrencies/v1/createPurchaseOffer',
|
||||
'POST /api/roomcurrencies/v1/deletePurchaseOffer',
|
||||
'POST /api/roomcurrencies/v1/updateCurrency',
|
||||
'POST /api/roomkeys/v1/awardbulk',
|
||||
'POST /api/roomkeys/v1/create',
|
||||
|
||||
Reference in New Issue
Block a user