mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[econ] purchase offer delete
This commit is contained in:
@@ -106,6 +106,7 @@ import {
|
|||||||
CreateRoomCurrencyRequest,
|
CreateRoomCurrencyRequest,
|
||||||
CreateRoomKeyRequest,
|
CreateRoomKeyRequest,
|
||||||
CustomAvatarItemsResponse,
|
CustomAvatarItemsResponse,
|
||||||
|
DeletePurchaseOfferRequest,
|
||||||
EquipmentUpdateRequest,
|
EquipmentUpdateRequest,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
form,
|
||||||
@@ -170,6 +171,7 @@ import {
|
|||||||
awardRoomCurrency,
|
awardRoomCurrency,
|
||||||
createPurchaseOffer,
|
createPurchaseOffer,
|
||||||
createRoomCurrency,
|
createRoomCurrency,
|
||||||
|
deletePurchaseOffer,
|
||||||
findPurchaseOffer,
|
findPurchaseOffer,
|
||||||
getPurchaseOffers,
|
getPurchaseOffers,
|
||||||
getRoomBalance,
|
getRoomBalance,
|
||||||
@@ -348,6 +350,7 @@ const HUB_INSTANCE = 'global'
|
|||||||
const CREATE_CURRENCY_FAILED = 'Failed to create currency'
|
const CREATE_CURRENCY_FAILED = 'Failed to create currency'
|
||||||
const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
|
const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
|
||||||
const CREATE_OFFER_FAILED = 'Failed to create purchase offer'
|
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'
|
const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable'
|
||||||
/**
|
/**
|
||||||
* `Status` on the room-key create's `{ Status, RoomKey }` reply. 0 is the observed success
|
* `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,
|
* The create- and delete-offer envelope — the same `{ Value, Success, Error, error_id }`
|
||||||
* carrying one purchase offer.
|
* shape again, carrying one purchase offer.
|
||||||
*/
|
*/
|
||||||
function purchaseOfferEnvelope(
|
function purchaseOfferEnvelope(
|
||||||
c: Context<App>,
|
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
|
// 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.
|
// else is reported PER ENTRY, including whether the caller may award that currency at all.
|
||||||
.post(
|
.post(
|
||||||
|
|||||||
@@ -1071,9 +1071,17 @@ export const CreatePurchaseOfferRequest = z.object({
|
|||||||
Order: z.string().optional().describe('Where it sits in the shop; defaults to 0'),
|
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
|
* The envelope the create- and delete-offer endpoints answer in — the same
|
||||||
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer in `Value`.
|
* `{ 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({
|
export const RoomCurrencyPurchaseOfferEnvelope = z.object({
|
||||||
Value: RoomCurrencyPurchaseOfferDto.nullable(),
|
Value: RoomCurrencyPurchaseOfferDto.nullable(),
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
* - `GET /api/roomcurrencies/v1/currencies?roomId=` lists a room's
|
* - `GET /api/roomcurrencies/v1/currencies?roomId=` lists a room's
|
||||||
* - `POST /api/roomcurrencies/v1/awardCurrency/bulk` pays players
|
* - `POST /api/roomcurrencies/v1/awardCurrency/bulk` pays players
|
||||||
* - the purchase offers below are the shop that sells the currency for other money
|
* - 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/roomCurrencies/v2/purchase` buys from that shop, with tokens
|
||||||
* - `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` spends it, on a room key
|
* - `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
|
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. */
|
/** One currency's shop — the group the batch read answers in, one per currency asked for. */
|
||||||
export interface RoomCurrencyPurchaseOffers {
|
export interface RoomCurrencyPurchaseOffers {
|
||||||
CurrencyId: string
|
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 () => {
|
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.
|
// Two players standing in 2511, one somewhere else, and one whose presence lapsed.
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
@@ -7048,6 +7165,7 @@ describe('econ endpoints', () => {
|
|||||||
'POST /api/roomcurrencies/v1/awardCurrency/bulk',
|
'POST /api/roomcurrencies/v1/awardCurrency/bulk',
|
||||||
'POST /api/roomcurrencies/v1/createCurrency',
|
'POST /api/roomcurrencies/v1/createCurrency',
|
||||||
'POST /api/roomcurrencies/v1/createPurchaseOffer',
|
'POST /api/roomcurrencies/v1/createPurchaseOffer',
|
||||||
|
'POST /api/roomcurrencies/v1/deletePurchaseOffer',
|
||||||
'POST /api/roomcurrencies/v1/updateCurrency',
|
'POST /api/roomcurrencies/v1/updateCurrency',
|
||||||
'POST /api/roomkeys/v1/awardbulk',
|
'POST /api/roomkeys/v1/awardbulk',
|
||||||
'POST /api/roomkeys/v1/create',
|
'POST /api/roomkeys/v1/create',
|
||||||
|
|||||||
Reference in New Issue
Block a user