mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] wishlist
This commit is contained in:
+7
-3
@@ -25,7 +25,11 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
||||
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
|
||||
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
|
||||
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | The caller's item wishlist |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/:accountId` | ✓ | Another player's item wishlist |
|
||||
| POST | `/api/itemWishlists/v1/wishlist/add` | ✓ | Wish for an item (form, enveloped) |
|
||||
| POST | `/api/itemWishlists/v1/wishlist/remove` | ✓ | Un-wish an item (a POST, not DELETE) |
|
||||
| POST | `/api/itemWishlists/v1/isonwishlist/bulk` | ✓ | Per-player "has it wished for" booleans |
|
||||
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
|
||||
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
|
||||
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
|
||||
@@ -638,8 +642,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
|
||||
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
|
||||
storefronts, which the ASSETS binding can't enumerate.
|
||||
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
||||
empty-list stubs pending their own stores.
|
||||
- Several routes (equipment, room consumables/currencies) are empty-list stubs pending
|
||||
their own stores.
|
||||
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the
|
||||
reference caps activity XP per day in `daily_xp_ledgers`).
|
||||
- The level-reward table and the served config's `GiftRarity` disagree in places (see the
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Item wishlists, owned by the `econ` worker. One row per item a player has wished for —
|
||||
-- what the store's heart toggle writes (`POST /api/itemWishlists/v1/wishlist/add|remove`,
|
||||
-- form-encoded `purchasableItemId=1534&customAvatarItemId=`) and the wishlist page reads
|
||||
-- (`GET /api/itemWishlists/v1/wishlist/me|{accountId}`, a bare array of entries).
|
||||
--
|
||||
-- `wishlist_item_id` is a GUID: the client's `WishlistItemId` is a `Guid`, not an int, so the
|
||||
-- row's key has to be one. `player_id` is the account the list belongs to.
|
||||
--
|
||||
-- A row names ONE item in one of two ways — a catalog item by `purchasable_item_id` (the
|
||||
-- storefront's `PurchasableItemId`) or a player-made/first-party custom avatar item by
|
||||
-- `custom_avatar_item_id` (its GUID, stored lowercase) — so exactly one of the two is set.
|
||||
-- On the wire the client's `PurchasableItemId` is a plain int, so a custom-item row is served
|
||||
-- as `PurchasableItemId: 0` with the GUID beside it; the column is still NULL here so the two
|
||||
-- kinds of row can't be confused.
|
||||
--
|
||||
-- The two partial unique indexes make a wish boolean: a second `add` of the same item finds
|
||||
-- the existing row rather than making a twin. `idx_wishlist_player` is the read's access
|
||||
-- pattern (one player's whole list).
|
||||
--
|
||||
-- Kept in sync with WISHLIST_SCHEMA_DDL in src/wishlist-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wishlist (
|
||||
wishlist_item_id TEXT PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL,
|
||||
purchasable_item_id INTEGER,
|
||||
custom_avatar_item_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wishlist_player ON wishlist (player_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_purchasable
|
||||
ON wishlist (player_id, purchasable_item_id) WHERE purchasable_item_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_custom
|
||||
ON wishlist (player_id, custom_avatar_item_id) WHERE custom_avatar_item_id IS NOT NULL;
|
||||
+202
-12
@@ -112,6 +112,8 @@ import {
|
||||
GameRewardRequest,
|
||||
InfluencerIdsResponse,
|
||||
InfluencerTierResponse,
|
||||
IsOnWishlistBulkRequest,
|
||||
IsOnWishlistBulkResponse,
|
||||
ItemPurchaseInfoList,
|
||||
ItemPurchaseInfosRequest,
|
||||
json,
|
||||
@@ -152,6 +154,9 @@ import {
|
||||
UpdateRoomCurrencyRequest,
|
||||
UpdateRoomKeyRequest,
|
||||
UpsertRoomConsumableRequest,
|
||||
WishlistEnvelope,
|
||||
WishlistItemList,
|
||||
WishlistItemRequest,
|
||||
} from './openapi'
|
||||
import { claimReward, isNewActivity } from './reward-db'
|
||||
import {
|
||||
@@ -184,6 +189,13 @@ import {
|
||||
updateRoomKey,
|
||||
} from './room-key-db'
|
||||
import { getRoomPurchasables } from './room-purchasables-db'
|
||||
import {
|
||||
addWishlistItem,
|
||||
getWishlist,
|
||||
isOnWishlists,
|
||||
parseWishlistTarget,
|
||||
removeWishlistItem,
|
||||
} from './wishlist-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||
@@ -209,6 +221,7 @@ import type { AvatarItem } from './inventory-db'
|
||||
import type { RoomConsumable } from './room-consumable-db'
|
||||
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
|
||||
import type { RoomKey, RoomKeyHolding } from './room-key-db'
|
||||
import type { WishlistItem } from './wishlist-db'
|
||||
|
||||
// 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
|
||||
@@ -222,7 +235,7 @@ import type { RoomKey, RoomKeyHolding } from './room-key-db'
|
||||
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
|
||||
* avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
|
||||
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
||||
* binding. Some routes are still empty-list stubs (equipment, room consumables, …).
|
||||
*
|
||||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
@@ -256,6 +269,39 @@ function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Error` a wishlist write answers when its body names no item. Shown to the player
|
||||
* verbatim behind the client's “Wishlist error: ” prefix, so it is a sentence, not a code.
|
||||
*/
|
||||
const WISHLIST_NO_ITEM = 'No item was specified.'
|
||||
|
||||
/**
|
||||
* The `{ Value, Success, Error, error_id }` envelope the wishlist writes answer in — the
|
||||
* same shape the room-currency writes use. A null `item` with no `error` is still a success
|
||||
* (removing something that wasn't listed); an `error` makes it a refusal.
|
||||
*/
|
||||
function wishlistEnvelope(c: Context<App>, item: WishlistItem | null, error?: string) {
|
||||
return c.json({
|
||||
Value: item,
|
||||
Success: error === undefined,
|
||||
Error: error ?? null,
|
||||
error_id: null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The item a wishlist write's form body names — `purchasableItemId` or `customAvatarItemId`,
|
||||
* as the client spells them, with the PascalCase spellings accepted for a hand-written
|
||||
* request. Null when the body names nothing usable (see `parseWishlistTarget`).
|
||||
*/
|
||||
async function wishlistTargetFromBody(c: Context<App>) {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
return parseWishlistTarget(
|
||||
body.purchasableItemId ?? body.PurchasableItemId,
|
||||
body.customAvatarItemId ?? body.CustomAvatarItemId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
|
||||
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
|
||||
@@ -3115,20 +3161,41 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's item wishlist. [Authorize]; empty — nothing stores wishlists yet.
|
||||
// The caller's item wishlist. [Authorize]. A bare array of entries, newest wish first;
|
||||
// `[]` for a player who has wished for nothing. The client appends a KEYLESS query string
|
||||
// (`/me?True`, `/me?False` — a raw `bool.ToString()` after the `?`, no parameter name) that
|
||||
// no server can bind by name, so it is ignored: both spellings serve the same list. The
|
||||
// client caches this for five seconds and drops the cache when an add succeeds.
|
||||
.get(
|
||||
'/api/itemWishlists/v1/wishlist/me',
|
||||
listRoute('The player’s item wishlist', 'Empty for now', true),
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'The player’s item wishlist',
|
||||
description: [
|
||||
'The caller’s wishlist as a bare array of entries, newest first — `[]` when empty.',
|
||||
'Each entry names ONE item: a storefront item by `PurchasableItemId`, or a custom',
|
||||
'avatar item by `CustomAvatarItemId` (with `PurchasableItemId` 0, the client’s field',
|
||||
'being a plain int).',
|
||||
'',
|
||||
'The client appends `?True` or `?False` with no parameter name; it cannot be read',
|
||||
'and is ignored — both serve the same list.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(WishlistItemList, 'The caller’s wishlist, newest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
return c.json(await getWishlist(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Another player's item wishlist, by account id — what the client reads to show what
|
||||
// somebody else is hoping for (and to mark items in the store as already wished for).
|
||||
// Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone.
|
||||
// somebody else is hoping for. Public to any signed-in player: a wishlist exists to be
|
||||
// read by others. An unknown account has simply wished for nothing.
|
||||
//
|
||||
// Registered AFTER `/me` so that path stays its own route rather than being read as an
|
||||
// account id — the pattern here is digits-only, so it could not swallow `me`, but the
|
||||
@@ -3139,10 +3206,11 @@ const app = new Hono<App>({ strict: false })
|
||||
tags: ['Econ'],
|
||||
summary: 'Another player’s item wishlist',
|
||||
description: [
|
||||
'The wishlist of the account named in the path, as a bare array. Empty for now —',
|
||||
'nothing on this server stores wishlists, so every player’s is empty, and an empty',
|
||||
'list is what the client renders as “nothing wished for” where a 404 would read as a',
|
||||
'failed load.',
|
||||
'The wishlist of the account named in the path, as a bare array of entries, newest',
|
||||
'first. Readable by any signed-in player — a wishlist is for others to see. An',
|
||||
'unknown account answers `[]`, which the client renders as “nothing wished for”',
|
||||
'where a 404 would read as a failed load. The keyless `?True`/`?False` the client',
|
||||
'appends is ignored, as on `/me`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -3155,14 +3223,136 @@ const app = new Hono<App>({ strict: false })
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(JsonArray, 'That player’s wishlist — empty for now'),
|
||||
200: json(WishlistItemList, 'That player’s wishlist, newest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
const accountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
if (!Number.isInteger(accountId)) return c.json([])
|
||||
return c.json(await getWishlist(c.env.DB, accountId))
|
||||
}
|
||||
)
|
||||
|
||||
// Wish for an item. [Authorize]. Form-encoded: the client posts BOTH `purchasableItemId`
|
||||
// and `customAvatarItemId` every time with the unused one empty (its two nullables are
|
||||
// boxed without a HasValue guard), so either may be empty or absent; exactly one has to
|
||||
// name an item. Answers the `{ Value, Success, Error, error_id }` envelope with the entry.
|
||||
// An item already on the list answers its EXISTING entry: the toggle is driven by the
|
||||
// client's idea of the current state, and re-adding changes nothing.
|
||||
//
|
||||
// The row has to persist: on success the client drops its cached list and re-reads it,
|
||||
// so a 200 that stored nothing shows the heart un-filling again at once.
|
||||
.post(
|
||||
'/api/itemWishlists/v1/wishlist/add',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Wish for an item',
|
||||
description: [
|
||||
'Adds an item to the caller’s wishlist. Form-encoded; the client posts both',
|
||||
'`purchasableItemId` and `customAvatarItemId` with the unused one EMPTY',
|
||||
'(`purchasableItemId=1534&customAvatarItemId=`), and either may also be absent —',
|
||||
'exactly one has to name an item.',
|
||||
'',
|
||||
'Answers `{ Value, Success, Error, error_id }` with the entry as `Value`. An item',
|
||||
'already on the list answers its existing entry rather than a twin or a refusal.',
|
||||
'A body naming no item is `Success: false` with a player-readable `Error` — the',
|
||||
'client shows it verbatim behind “Wishlist error: ”. A bare entry or a 204 fails the',
|
||||
'client the same way `Success: false` does.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(WishlistItemRequest, 'The item to wish for'),
|
||||
responses: {
|
||||
200: json(WishlistEnvelope, 'The entry, or a refusal in the same shape'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await wishlistTargetFromBody(c)
|
||||
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
|
||||
return wishlistEnvelope(c, await addWishlistItem(c.env.DB, id, target))
|
||||
}
|
||||
)
|
||||
|
||||
// Take an item off the caller's wishlist. [Authorize]. A POST, not a DELETE, with the same
|
||||
// form body as `add`. The client parses the same envelope and discards `Value`, so the
|
||||
// removed entry is served anyway; an item that wasn't on the list is still a success —
|
||||
// the list ends up as asked — with a null `Value`.
|
||||
.post(
|
||||
'/api/itemWishlists/v1/wishlist/remove',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Un-wish an item',
|
||||
description: [
|
||||
'Removes an item from the caller’s wishlist. A POST (not a DELETE) with the same',
|
||||
'form body as `add`. Answers the same envelope with the REMOVED entry as `Value`;',
|
||||
'the client discards it but parses the shape, so a bare body or 204 fails it. An',
|
||||
'item that wasn’t on the list is still `Success: true`, with a null `Value`. A body',
|
||||
'naming no item is `Success: false`.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(WishlistItemRequest, 'The item to un-wish'),
|
||||
responses: {
|
||||
200: json(WishlistEnvelope, 'The removed entry, or a refusal in the same shape'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await wishlistTargetFromBody(c)
|
||||
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
|
||||
return wishlistEnvelope(c, await removeWishlistItem(c.env.DB, id, target))
|
||||
}
|
||||
)
|
||||
|
||||
// Whether each of several players has an item wished for. [Authorize]. Form-encoded:
|
||||
// `accountIds` repeated once per player plus the item named as on add/remove. A BARE
|
||||
// positional array of booleans back, one per id in the order posted — a short array would
|
||||
// leave the tail reading as unset, so every id is answered, unknown players as false.
|
||||
.post(
|
||||
'/api/itemWishlists/v1/isonwishlist/bulk',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Whether players have an item wished for',
|
||||
description: [
|
||||
'For each `accountIds` entry, whether that player’s wishlist has the item named by',
|
||||
'`purchasableItemId` or `customAvatarItemId`. Form-encoded, `accountIds` repeated',
|
||||
'once per id (a comma-separated single value is accepted too). The answer is a BARE',
|
||||
'array of booleans, one per id in the order posted. A body naming no item answers',
|
||||
'false for everyone.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(IsOnWishlistBulkRequest, 'The players to ask about, and the item'),
|
||||
responses: {
|
||||
200: json(IsOnWishlistBulkResponse, 'One boolean per `accountIds` entry, in order'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req
|
||||
.parseBody({ all: true })
|
||||
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
|
||||
// `all: true` keeps the repeated field a list; a single value arrives as a string.
|
||||
const accountIds = [body.accountIds, body.AccountIds]
|
||||
.flat()
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.flatMap((v) => v.split(','))
|
||||
.map((v) => Number.parseInt(v.trim(), 10))
|
||||
.filter((n) => Number.isInteger(n))
|
||||
const first = (v: unknown): unknown => (Array.isArray(v) ? v[0] : v)
|
||||
const target = parseWishlistTarget(
|
||||
first(body.purchasableItemId ?? body.PurchasableItemId),
|
||||
first(body.customAvatarItemId ?? body.CustomAvatarItemId)
|
||||
)
|
||||
if (target === null) return c.json(accountIds.map(() => false))
|
||||
return c.json(await isOnWishlists(c.env.DB, accountIds, target))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -846,6 +846,79 @@ export const OwnsRoomKeysResponse = z.array(
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* One wishlist entry — the client's five-key DTO, in its order. An element of the GET's bare
|
||||
* array and the `Value` of the add/remove envelope.
|
||||
*
|
||||
* `PurchasableItemId` is a plain int on the client, so an entry for a custom avatar item
|
||||
* carries 0 there and the item's GUID in `CustomAvatarItemId`; a storefront-item entry has
|
||||
* the id and a null GUID. Exactly one of the two names the item.
|
||||
*/
|
||||
export const WishlistItemDto = z.object({
|
||||
WishlistItemId: z.string().describe('GUID — the client’s `Guid`, so never an int'),
|
||||
AccountId: z.int().describe('Whose list the entry is on'),
|
||||
PurchasableItemId: z
|
||||
.int()
|
||||
.describe('The storefront item wished for, or 0 when the entry names a custom avatar item'),
|
||||
CustomAvatarItemId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('The custom avatar item wished for, or null when the entry names a storefront item'),
|
||||
CreatedAt: z.string().describe('ISO-8601 UTC'),
|
||||
})
|
||||
|
||||
/** `GET /api/itemWishlists/v1/wishlist/me|{accountId}` — a bare array, newest wish first. */
|
||||
export const WishlistItemList = z.array(WishlistItemDto)
|
||||
|
||||
/**
|
||||
* The envelope `wishlist/add` and `wishlist/remove` answer in — the same `{ Value, Success,
|
||||
* Error, error_id }` the room-currency writes use. The client reads `add`’s `Value` and
|
||||
* discards `remove`’s, but parses both as this shape: a bare entry or a 204 fails the same
|
||||
* way `Success: false` does. `Error` is shown to the player verbatim behind “Wishlist error: ”.
|
||||
*/
|
||||
export const WishlistEnvelope = z.object({
|
||||
Value: WishlistItemDto.nullable().describe(
|
||||
'The entry added or removed; null on a refusal, or on removing an item that wasn’t listed'
|
||||
),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
|
||||
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `wishlist/add` and `wishlist/remove` — form-encoded. The client posts BOTH fields every
|
||||
* time with the unused one empty (`purchasableItemId=1534&customAvatarItemId=`); either may
|
||||
* also be omitted. Exactly one has to name an item.
|
||||
*/
|
||||
export const WishlistItemRequest = z.object({
|
||||
purchasableItemId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The storefront item, as a positive integer; empty or absent for a custom item'),
|
||||
customAvatarItemId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The custom avatar item’s GUID; empty or absent for a storefront item'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/itemWishlists/v1/isonwishlist/bulk` — form-encoded. `accountIds` is REPEATED,
|
||||
* once per account (`accountIds=205&accountIds=207`); a comma-separated single value is
|
||||
* accepted too. The item is named as on add/remove.
|
||||
*/
|
||||
export const IsOnWishlistBulkRequest = WishlistItemRequest.extend({
|
||||
accountIds: z
|
||||
.array(z.string())
|
||||
.describe('The players to ask about, repeated once per id — answered in this order'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `isonwishlist/bulk`’s answer: a bare POSITIONAL array of booleans, one per `accountIds`
|
||||
* entry in the order posted. A short array leaves the tail reading as “not wished for”, so
|
||||
* every id gets an element — an unknown player’s is false.
|
||||
*/
|
||||
export const IsOnWishlistBulkResponse = z.array(z.boolean())
|
||||
|
||||
/**
|
||||
* One purchase offer on a room currency — a way to BUY that currency, priced in another
|
||||
* ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and
|
||||
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
ROOM_CURRENCY_SCHEMA_DDL,
|
||||
} from '../../room-currency-db'
|
||||
import { createRoomKey, ROOM_KEY_SCHEMA_DDL } from '../../room-key-db'
|
||||
import { WISHLIST_SCHEMA_DDL } from '../../wishlist-db'
|
||||
|
||||
import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db'
|
||||
import type { Env } from '../../context'
|
||||
@@ -223,6 +224,7 @@ beforeAll(async () => {
|
||||
for (const stmt of ROOM_CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_KEY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of WISHLIST_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// The platform link table (owned by `auth`) — the Discord supporter gift reads the roles
|
||||
// `www` records on each Discord link.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
@@ -829,6 +831,206 @@ describe('econ endpoints', () => {
|
||||
expect(mine.status).toBe(200)
|
||||
})
|
||||
|
||||
describe('item wishlist', () => {
|
||||
const WISHLIST = `${ORIGIN}/api/itemWishlists/v1`
|
||||
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
// The form body exactly as the client posts it: both fields, the unused one empty.
|
||||
const post = async (path: string, sub: string, body: string) =>
|
||||
exports.default.fetch(`${WISHLIST}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const list = async (who: string, sub: string) =>
|
||||
(
|
||||
await exports.default.fetch(`${WISHLIST}/wishlist/${who}`, { headers: await bearer(sub) })
|
||||
).json()
|
||||
|
||||
test('the writes 401 without a token', async () => {
|
||||
for (const path of ['/wishlist/add', '/wishlist/remove', '/isonwishlist/bulk']) {
|
||||
const res = await exports.default.fetch(`${WISHLIST}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'purchasableItemId=1534&customAvatarItemId=',
|
||||
})
|
||||
expect(res.status, path).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
test('add stores an entry, serves it enveloped, and the list reads it back newest first', async () => {
|
||||
const first = await post('/wishlist/add', '601', 'purchasableItemId=1534&customAvatarItemId=')
|
||||
expect(first.status).toBe(200)
|
||||
const env1 = (await first.json()) as {
|
||||
Value: Record<string, unknown>
|
||||
Success: boolean
|
||||
Error: null
|
||||
error_id: null
|
||||
}
|
||||
// The envelope, `Value` first; the entry is the client's five keys in its order, with
|
||||
// a GUID id and a NULL custom id for a storefront item.
|
||||
expect(Object.keys(env1)).toEqual(['Value', 'Success', 'Error', 'error_id'])
|
||||
expect(env1.Success).toBe(true)
|
||||
expect(env1.Error).toBeNull()
|
||||
expect(env1.error_id).toBeNull()
|
||||
expect(Object.keys(env1.Value)).toEqual([
|
||||
'WishlistItemId',
|
||||
'AccountId',
|
||||
'PurchasableItemId',
|
||||
'CustomAvatarItemId',
|
||||
'CreatedAt',
|
||||
])
|
||||
expect(env1.Value.WishlistItemId).toMatch(GUID_RE)
|
||||
expect(env1.Value.AccountId).toBe(601)
|
||||
expect(env1.Value.PurchasableItemId).toBe(1534)
|
||||
expect(env1.Value.CustomAvatarItemId).toBeNull()
|
||||
expect(typeof env1.Value.CreatedAt).toBe('string')
|
||||
|
||||
// Wait so the second wish sorts after the first by its timestamp.
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
// A custom avatar item is the mirror image: `PurchasableItemId` 0 (the client's field
|
||||
// is a plain int), the GUID beside it — lowercased, however the client spelled it.
|
||||
const second = await post(
|
||||
'/wishlist/add',
|
||||
'601',
|
||||
'purchasableItemId=&customAvatarItemId=C3ED9841-1B4A-40A6-B266-5DBCFD7F2865'
|
||||
)
|
||||
const env2 = (await second.json()) as { Value: Record<string, unknown> }
|
||||
expect(env2.Value.PurchasableItemId).toBe(0)
|
||||
expect(env2.Value.CustomAvatarItemId).toBe('c3ed9841-1b4a-40a6-b266-5dbcfd7f2865')
|
||||
|
||||
// Newest first — the order the reference served. The keyless `?True`/`?False` the
|
||||
// client appends is ignored, so both spellings serve the same list.
|
||||
const mine = (await list('me?True', '601')) as Array<Record<string, unknown>>
|
||||
expect(mine.map((e) => e.WishlistItemId)).toEqual([
|
||||
env2.Value.WishlistItemId,
|
||||
env1.Value.WishlistItemId,
|
||||
])
|
||||
expect(await list('me?False', '601')).toEqual(mine)
|
||||
expect(await list('me', '601')).toEqual(mine)
|
||||
// Readable by another signed-in player under the account id.
|
||||
expect(await list('601?False', '602')).toEqual(mine)
|
||||
// And nobody else's list picked it up.
|
||||
expect(await list('me', '602')).toEqual([])
|
||||
})
|
||||
|
||||
test('re-adding an item answers the existing entry rather than a twin', async () => {
|
||||
const first = (await (
|
||||
await post('/wishlist/add', '603', 'purchasableItemId=2137&customAvatarItemId=')
|
||||
).json()) as { Value: { WishlistItemId: string } }
|
||||
const again = (await (
|
||||
await post('/wishlist/add', '603', 'purchasableItemId=2137&customAvatarItemId=')
|
||||
).json()) as { Success: boolean; Value: { WishlistItemId: string } }
|
||||
expect(again.Success).toBe(true)
|
||||
expect(again.Value.WishlistItemId).toBe(first.Value.WishlistItemId)
|
||||
expect(((await list('me', '603')) as unknown[]).length).toBe(1)
|
||||
})
|
||||
|
||||
test('remove answers the removed entry; removing an unlisted item is still a success', async () => {
|
||||
const added = (await (
|
||||
await post('/wishlist/add', '604', 'purchasableItemId=1274&customAvatarItemId=')
|
||||
).json()) as { Value: { WishlistItemId: string } }
|
||||
|
||||
const removed = await post(
|
||||
'/wishlist/remove',
|
||||
'604',
|
||||
'purchasableItemId=1274&customAvatarItemId='
|
||||
)
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toEqual({
|
||||
Value: expect.objectContaining({
|
||||
WishlistItemId: added.Value.WishlistItemId,
|
||||
AccountId: 604,
|
||||
PurchasableItemId: 1274,
|
||||
}),
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
})
|
||||
expect(await list('me', '604')).toEqual([])
|
||||
|
||||
// Already gone: the list is as asked, so this is not an error — a null `Value`.
|
||||
const again = await post(
|
||||
'/wishlist/remove',
|
||||
'604',
|
||||
'purchasableItemId=1274&customAvatarItemId='
|
||||
)
|
||||
expect(await again.json()).toEqual({
|
||||
Value: null,
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('remove only touches the caller’s own list', async () => {
|
||||
await post('/wishlist/add', '605', 'purchasableItemId=1274&customAvatarItemId=')
|
||||
const other = await post(
|
||||
'/wishlist/remove',
|
||||
'606',
|
||||
'purchasableItemId=1274&customAvatarItemId='
|
||||
)
|
||||
expect(((await other.json()) as { Value: unknown }).Value).toBeNull()
|
||||
expect(((await list('me', '605')) as unknown[]).length).toBe(1)
|
||||
})
|
||||
|
||||
test('a body naming no item is a refusal in the envelope, with a readable Error', async () => {
|
||||
for (const body of [
|
||||
'',
|
||||
'purchasableItemId=&customAvatarItemId=',
|
||||
'purchasableItemId=abc',
|
||||
'customAvatarItemId=not-a-guid',
|
||||
'purchasableItemId=0',
|
||||
]) {
|
||||
for (const path of ['/wishlist/add', '/wishlist/remove']) {
|
||||
const res = await post(path, '607', body)
|
||||
expect(res.status, `${path} ${body}`).toBe(200)
|
||||
expect(await res.json(), `${path} ${body}`).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'No item was specified.',
|
||||
error_id: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
expect(await list('me', '607')).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /isonwishlist/bulk answers one boolean per accountId, in order', async () => {
|
||||
await post('/wishlist/add', '608', 'purchasableItemId=3001&customAvatarItemId=')
|
||||
await post('/wishlist/add', '610', 'purchasableItemId=3001&customAvatarItemId=')
|
||||
await post(
|
||||
'/wishlist/add',
|
||||
'609',
|
||||
'purchasableItemId=&customAvatarItemId=8f0c1e4a-2b77-4b16-9a3e-5dbcfd7f2865'
|
||||
)
|
||||
|
||||
// `accountIds` repeated once per id, as the client's list parameter encodes; the
|
||||
// unknown account 611 reads false rather than shortening the array.
|
||||
const res = await post(
|
||||
'/isonwishlist/bulk',
|
||||
'608',
|
||||
'accountIds=608&accountIds=609&accountIds=610&accountIds=611&purchasableItemId=3001&customAvatarItemId='
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([true, false, true, false])
|
||||
|
||||
// A comma-separated single value, and a custom item, matched case-insensitively.
|
||||
const custom = await post(
|
||||
'/isonwishlist/bulk',
|
||||
'608',
|
||||
'accountIds=609,608&purchasableItemId=&customAvatarItemId=8F0C1E4A-2B77-4B16-9A3E-5DBCFD7F2865'
|
||||
)
|
||||
expect(await custom.json()).toEqual([true, false])
|
||||
|
||||
// No item named: false for everyone, still one per id.
|
||||
const none = await post('/isonwishlist/bulk', '608', 'accountIds=608&accountIds=610')
|
||||
expect(await none.json()).toEqual([false, false])
|
||||
// No ids: an empty array.
|
||||
const empty = await post('/isonwishlist/bulk', '608', 'purchasableItemId=3001')
|
||||
expect(await empty.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/avatar/v3/saved 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`)
|
||||
expect(anon.status).toBe(401)
|
||||
@@ -6791,6 +6993,9 @@ describe('econ endpoints', () => {
|
||||
'POST /api/consumables/v1/consume',
|
||||
'POST /api/equipment/v1/update',
|
||||
'POST /api/gamerewards/v1/request',
|
||||
'POST /api/itemWishlists/v1/isonwishlist/bulk',
|
||||
'POST /api/itemWishlists/v1/wishlist/add',
|
||||
'POST /api/itemWishlists/v1/wishlist/remove',
|
||||
'POST /api/items/bulkpurchase',
|
||||
'POST /api/items/purchaseInfos',
|
||||
'POST /api/objectives/v1/cleargroup',
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Item wishlists — the store's heart toggle.
|
||||
*
|
||||
* A player wishes for an item from its store page and reads the list back on the wishlist
|
||||
* page; other players can read it too (that is what a wishlist is for). One row per
|
||||
* (player, item) in `wishlist`. The endpoints, all on `/api/itemWishlists/v1`:
|
||||
* - `GET …/wishlist/me` and `…/wishlist/:accountId` — a bare array of entries
|
||||
* - `POST …/wishlist/add` and `…/wishlist/remove` — form-encoded, enveloped
|
||||
* - `POST …/isonwishlist/bulk` — a bare array of booleans, one per account asked about
|
||||
*
|
||||
* An entry names ONE item in one of two ways: a catalog item by `PurchasableItemId` (the
|
||||
* storefront's id, what `RRUI.Data.StoreItemModel` toggles) or a custom avatar item by
|
||||
* `CustomAvatarItemId` (its GUID, what `CustomAvatarItemModel` toggles). Both columns are
|
||||
* nullable and exactly one is set — but the client's `PurchasableItemId` is a plain `int`,
|
||||
* so a custom-item row is SERVED as `PurchasableItemId: 0`; see {@link toWishlistItem}.
|
||||
*
|
||||
* This worker (`econ`) owns the table and its migration — see apps/econ/migrations/
|
||||
* 0027_wishlist.sql.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0027_wishlist.sql) — also builds the table in tests. */
|
||||
export const WISHLIST_SCHEMA_DDL: string[] = [
|
||||
// `wishlist_item_id` is a GUID: the client's `WishlistItemId` is a `Guid`, so the row's
|
||||
// key has to be one. `custom_avatar_item_id` is stored LOWERCASE so the unique index below
|
||||
// holds whatever case the client sent the GUID in.
|
||||
`CREATE TABLE IF NOT EXISTS wishlist (
|
||||
wishlist_item_id TEXT PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL,
|
||||
purchasable_item_id INTEGER,
|
||||
custom_avatar_item_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_wishlist_player ON wishlist (player_id)`,
|
||||
// A wish is boolean: these make a second `add` of the same item find the existing row.
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_purchasable
|
||||
ON wishlist (player_id, purchasable_item_id) WHERE purchasable_item_id IS NOT NULL`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_custom
|
||||
ON wishlist (player_id, custom_avatar_item_id) WHERE custom_avatar_item_id IS NOT NULL`,
|
||||
]
|
||||
|
||||
/**
|
||||
* A wishlist entry as the client reads it (its `AMJGGOKLEGL`, five keys in this order). The
|
||||
* same object is an element of the GET's bare array and the `Value` of the add/remove
|
||||
* envelope.
|
||||
*
|
||||
* `PurchasableItemId` is a plain `int` on the client — there is no null to send — so a
|
||||
* custom-item entry carries 0 there and its GUID in `CustomAvatarItemId`, which IS nullable
|
||||
* and null on a catalog-item entry.
|
||||
*/
|
||||
export interface WishlistItem {
|
||||
/** GUID, minted here. */
|
||||
WishlistItemId: string
|
||||
AccountId: number
|
||||
/** The storefront item wished for, or 0 when the entry names a custom avatar item. */
|
||||
PurchasableItemId: number
|
||||
/** The custom avatar item wished for, or null when the entry names a storefront item. */
|
||||
CustomAvatarItemId: string | null
|
||||
/** ISO-8601 UTC. */
|
||||
CreatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Which item a request is about — exactly one of the two, already parsed. The add/remove/
|
||||
* isonwishlist bodies all name an item this way (`purchasableItemId=1534&customAvatarItemId=`,
|
||||
* the unused one empty), and `parseWishlistTarget` turns that into one of these or null.
|
||||
*/
|
||||
export type WishlistTarget =
|
||||
| { purchasableItemId: number; customAvatarItemId: null }
|
||||
| { purchasableItemId: null; customAvatarItemId: string }
|
||||
|
||||
/** The 36-char dashed GUID the client's formatter writes and reads; braces or `N` form fail it. */
|
||||
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Read the item a wishlist body names. Both fields are always posted (the client boxes two
|
||||
* nullables without a HasValue guard, so the unused one arrives empty), and a hand-written
|
||||
* request may leave one out — either is accepted. The storefront id wins when both are
|
||||
* somehow set. Null when neither names anything usable: no id, a non-integer id, or a string
|
||||
* that isn't a GUID.
|
||||
*/
|
||||
export function parseWishlistTarget(
|
||||
purchasableItemId: unknown,
|
||||
customAvatarItemId: unknown
|
||||
): WishlistTarget | null {
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
|
||||
const id = str(purchasableItemId)
|
||||
if (/^\d+$/.test(id)) {
|
||||
const n = Number(id)
|
||||
if (n > 0) return { purchasableItemId: n, customAvatarItemId: null }
|
||||
}
|
||||
const guid = str(customAvatarItemId)
|
||||
if (GUID_RE.test(guid)) {
|
||||
return { purchasableItemId: null, customAvatarItemId: guid.toLowerCase() }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
interface WishlistRow {
|
||||
wishlist_item_id: string
|
||||
player_id: number
|
||||
purchasable_item_id: number | null
|
||||
custom_avatar_item_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const SELECT_COLUMNS = `wishlist_item_id, player_id, purchasable_item_id, custom_avatar_item_id, created_at`
|
||||
|
||||
const toWishlistItem = (row: WishlistRow): WishlistItem => ({
|
||||
WishlistItemId: row.wishlist_item_id,
|
||||
AccountId: row.player_id,
|
||||
PurchasableItemId: row.purchasable_item_id ?? 0,
|
||||
CustomAvatarItemId: row.custom_avatar_item_id,
|
||||
CreatedAt: row.created_at,
|
||||
})
|
||||
|
||||
/** The WHERE clause that picks a target's row(s), with the target bound as `?<param>`. */
|
||||
function targetClause(
|
||||
target: WishlistTarget,
|
||||
param: number
|
||||
): { sql: string; value: number | string } {
|
||||
return target.purchasableItemId !== null
|
||||
? { sql: `purchasable_item_id = ?${param}`, value: target.purchasableItemId }
|
||||
: { sql: `custom_avatar_item_id = ?${param}`, value: target.customAvatarItemId }
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's wishlist, newest wish first — the order the reference served (its capture lists
|
||||
* 15:37:08 above 15:36:56). A player who has wished for nothing gets [].
|
||||
*/
|
||||
export async function getWishlist(db: D1Database, playerId: number): Promise<WishlistItem[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT ${SELECT_COLUMNS} FROM wishlist WHERE player_id = ?1
|
||||
ORDER BY created_at DESC, wishlist_item_id`
|
||||
)
|
||||
.bind(playerId)
|
||||
.all<WishlistRow>()
|
||||
return results.map(toWishlistItem)
|
||||
}
|
||||
|
||||
/** The player's entry for an item, or null when it isn't on their list. */
|
||||
export async function getWishlistItem(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
target: WishlistTarget
|
||||
): Promise<WishlistItem | null> {
|
||||
const where = targetClause(target, 2)
|
||||
const row = await db
|
||||
.prepare(`SELECT ${SELECT_COLUMNS} FROM wishlist WHERE player_id = ?1 AND ${where.sql}`)
|
||||
.bind(playerId, where.value)
|
||||
.first<WishlistRow>()
|
||||
return row === null ? null : toWishlistItem(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wish for an item. A wish is boolean, so an item already on the list is answered with its
|
||||
* EXISTING entry — original id and `CreatedAt` — rather than a twin or a refusal: the client
|
||||
* toggles by its own idea of the current state, and a stale page re-adding something changes
|
||||
* nothing. The unique indexes back this up under a race, in which case the loser re-reads.
|
||||
*/
|
||||
export async function addWishlistItem(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
target: WishlistTarget,
|
||||
now: Date = new Date()
|
||||
): Promise<WishlistItem> {
|
||||
const existing = await getWishlistItem(db, playerId, target)
|
||||
if (existing !== null) return existing
|
||||
const row: WishlistRow = {
|
||||
wishlist_item_id: crypto.randomUUID(),
|
||||
player_id: playerId,
|
||||
purchasable_item_id: target.purchasableItemId,
|
||||
custom_avatar_item_id: target.customAvatarItemId,
|
||||
created_at: now.toISOString(),
|
||||
}
|
||||
const { meta } = await db
|
||||
.prepare(`INSERT OR IGNORE INTO wishlist (${SELECT_COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5)`)
|
||||
.bind(
|
||||
row.wishlist_item_id,
|
||||
row.player_id,
|
||||
row.purchasable_item_id,
|
||||
row.custom_avatar_item_id,
|
||||
row.created_at
|
||||
)
|
||||
.run()
|
||||
if (meta.changes === 0) {
|
||||
// Lost a race with an identical add: the row that won is the entry.
|
||||
const winner = await getWishlistItem(db, playerId, target)
|
||||
if (winner !== null) return winner
|
||||
}
|
||||
return toWishlistItem(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an item off a player's list. Answers the entry that was removed, or null when the item
|
||||
* wasn't on the list — which the caller treats as success: the list ends up as asked either
|
||||
* way, and the client discards the value.
|
||||
*/
|
||||
export async function removeWishlistItem(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
target: WishlistTarget
|
||||
): Promise<WishlistItem | null> {
|
||||
const where = targetClause(target, 2)
|
||||
const row = await db
|
||||
.prepare(
|
||||
`DELETE FROM wishlist WHERE player_id = ?1 AND ${where.sql} RETURNING ${SELECT_COLUMNS}`
|
||||
)
|
||||
.bind(playerId, where.value)
|
||||
.first<WishlistRow>()
|
||||
return row === null ? null : toWishlistItem(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `playerIds` have `target` on their list — one boolean per id, in the order given,
|
||||
* so the caller can serve the client's positional `List<bool>`. Duplicated ids each get their
|
||||
* own answer; an unknown player simply hasn't wished for it.
|
||||
*/
|
||||
export async function isOnWishlists(
|
||||
db: D1Database,
|
||||
playerIds: number[],
|
||||
target: WishlistTarget
|
||||
): Promise<boolean[]> {
|
||||
if (playerIds.length === 0) return []
|
||||
const where = targetClause(target, 1)
|
||||
const placeholders = playerIds.map((_, i) => `?${i + 2}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT player_id FROM wishlist WHERE ${where.sql} AND player_id IN (${placeholders})`)
|
||||
.bind(where.value, ...playerIds)
|
||||
.all<{ player_id: number }>()
|
||||
const wished = new Set(results.map((r) => r.player_id))
|
||||
return playerIds.map((id) => wished.has(id))
|
||||
}
|
||||
Reference in New Issue
Block a user