Files
recflare/packages/domain/src/gifts-db.ts
T

145 lines
5.3 KiB
TypeScript

/**
* 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<string, unknown> {
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<StoredGift[]> {
const { results } = await db
.prepare('SELECT id, data, created_at FROM received_gift WHERE account_id = ?1 ORDER BY id')
.bind(accountId)
.all<GiftRow>()
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<StoredGift | null> {
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<GiftRow>()
if (row === null) return null
return { ...(JSON.parse(row.data) as GiftContent), Id: row.id, CreatedAt: row.created_at }
}