/** * Received gifts — the "gift boxes" a player is handed on the shared `recflare` D1. * A box is created when a player buys a storefront item (for themselves or as a * gift) and lingers until the client opens it. Opening is purely cosmetic: the item * itself is granted into the player's inventory at purchase time (see the `econ` * worker's inventory-db.ts), so consuming a box just deletes the row — there is * nothing left to grant. * * The `econ` worker owns the schema/migration (apps/econ/migrations/ * 0003_received_gift.sql) and is the only writer: `POST /api/storefronts/v2/buyItem` * inserts a box and `GET /api/avatar/v2/gifts` lists a player's pending boxes. The * `api` worker only deletes, from `POST /api/avatar/v2/gifts/consume`. Both import * these helpers so the table name and row shape live in one place. * * One row per gift box. `data` is the box's rendered content as an opaque JSON blob * (the currency/avatar-item fields the client draws); `id` and `created_at` are * columns so a box can be listed and deleted by id without parsing the blob. */ /** Schema DDL (mirror of apps/econ/migrations/0003_received_gift.sql). */ export const RECEIVED_GIFT_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS received_gift ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, data TEXT NOT NULL, created_at TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_received_gift_account ON received_gift (account_id)`, ] /** * The rendered content of a gift box, as the client draws it. Written verbatim by * `buyItem` from the storefront item's `GiftDrop`; never queried on. `Id` and * `CreatedAt` are NOT part of this — they come from the row (see {@link StoredGift}). */ export interface GiftContent extends Record { ConsumableItemDesc: string ConsumableCount: number // When the gift carries a consumable, the id of the `consumable` row granted at // purchase and the player's total of that consumable *before* this grant. They let // gift-consume fire an accurate ConsumableMappingAdded notification without having // to re-correlate the box to its row. Absent on non-consumable gifts. ConsumableMappingId?: number ConsumablePreExistingCount?: number AvatarItemDesc: string AvatarItemType: number | null CurrencyType: number Currency: number Xp: number PackageType: number Message: string EquipmentPrefabName: string EquipmentModificationGuid: string GiftRarity: number Platform: number PlatformsToSpawnOn: number BalanceType: number | null } /** A stored gift box: its content plus the row's identity (`Id`, `CreatedAt`). */ export interface StoredGift extends GiftContent { Id: number CreatedAt: string } interface GiftRow { id: number data: string created_at: string } /** * Create a gift box for `accountId`, returning its assigned id and creation time so * the caller can echo the box back in the purchase response. */ export async function createGift( db: D1Database, accountId: number, content: GiftContent ): Promise<{ id: number; createdAt: string }> { const createdAt = new Date().toISOString() const row = await db .prepare( 'INSERT INTO received_gift (account_id, data, created_at) VALUES (?1, ?2, ?3) RETURNING id' ) .bind(accountId, JSON.stringify(content), createdAt) .first<{ id: number }>() // RETURNING always yields a row on a successful insert; the guard is for the types. return { id: row?.id ?? 0, createdAt } } /** * Read a gift box by id regardless of owner, returning the box plus its owner account * id (or null when no such box exists). Lets a consumer tell "already gone" (a * harmless no-op) apart from "belongs to another player" (which must be forbidden). */ export async function getGift( db: D1Database, giftId: number ): Promise<{ accountId: number; gift: StoredGift } | null> { const row = await db .prepare('SELECT id, account_id, data, created_at FROM received_gift WHERE id = ?1') .bind(giftId) .first<{ id: number; account_id: number; data: string; created_at: string }>() if (row === null) return null return { accountId: row.account_id, gift: { ...(JSON.parse(row.data) as GiftContent), Id: row.id, CreatedAt: row.created_at }, } } /** A player's pending gift boxes, oldest first, with `Id`/`CreatedAt` merged in. */ export async function getPendingGifts(db: D1Database, accountId: number): Promise { const { results } = await db .prepare('SELECT id, data, created_at FROM received_gift WHERE account_id = ?1 ORDER BY id') .bind(accountId) .all() return results.map((r) => ({ ...(JSON.parse(r.data) as GiftContent), Id: r.id, CreatedAt: r.created_at, })) } /** * Delete (consume) a player's gift box by id, returning the box's content (so the * caller can act on what it held, e.g. notify the client of a new consumable), or * null — changing nothing — when the box doesn't exist or isn't theirs. The item was * already granted at purchase, so this only dismisses the box. */ export async function consumeGift( db: D1Database, accountId: number, giftId: number ): Promise { const row = await db .prepare( 'DELETE FROM received_gift WHERE id = ?1 AND account_id = ?2 RETURNING id, data, created_at' ) .bind(giftId, accountId) .first() if (row === null) return null return { ...(JSON.parse(row.data) as GiftContent), Id: row.id, CreatedAt: row.created_at } }