import { db } from '../../db/client.js'; import { questions, userInventoryItems, users } from '../../db/schema.js'; import { and, eq, sql } from 'drizzle-orm'; import { NotFoundError, ValidationError } from '../../utils/errors.js'; import { freezeStreak } from '../progress/streak-service.js'; import { HEART_RULES, ITEM_RULES, type InventoryItemId } from './rules.js'; import { consumeInventoryItem } from './inventory-service.js'; import type { UseInventoryItemResultDto, UsableInventoryItemId } from '../../types/app-api.js'; export interface UseInventoryItemInput { userId: string; itemId: UsableInventoryItemId; clientRequestId: string; questionId?: string; } export async function useInventoryItem(input: UseInventoryItemInput): Promise { switch (input.itemId) { case ITEM_RULES.heartSupply.id: return useHeartSupply(input); case ITEM_RULES.doubleXpPotion.id: return useDoubleXpPotion(input); case ITEM_RULES.hintFeather.id: return useHintFeather(input); case ITEM_RULES.streakShield.id: return useStreakShield(input); } } async function useHeartSupply(input: UseInventoryItemInput): Promise { const consumption = await consumeUseItem(input); const maxHearts = await getMaxHearts(input.userId); await db .update(users) .set({ heartsRemaining: maxHearts, heartsLastRestore: sql`NOW()`, }) .where(eq(users.id, input.userId)); return { itemId: input.itemId, quantityRemaining: consumption.item.quantity, effect: { type: 'restore_hearts', hearts: maxHearts, }, }; } async function useDoubleXpPotion(input: UseInventoryItemInput): Promise { const consumption = await consumeUseItem(input); if (!consumption.applied) { return { itemId: input.itemId, quantityRemaining: consumption.item.quantity, effect: { type: 'double_xp', activeUntil: consumption.item.activeUntil?.toISOString() ?? null, }, }; } const activeUntil = new Date(Date.now() + ITEM_RULES.doubleXpPotion.durationMs); await db .update(userInventoryItems) .set({ activeUntil, metadata: { activeEffect: 'double_xp', multiplier: ITEM_RULES.doubleXpPotion.multiplier, }, }) .where(and( eq(userInventoryItems.userId, input.userId), eq(userInventoryItems.itemId, input.itemId), )); return { itemId: input.itemId, quantityRemaining: consumption.item.quantity, effect: { type: 'double_xp', activeUntil: activeUntil.toISOString(), }, }; } async function useHintFeather(input: UseInventoryItemInput): Promise { if (!input.questionId) { throw new ValidationError('questionId is required for hint feather'); } const excludedOptions = await getHintExcludedOptions(input.questionId); const consumption = await consumeUseItem(input, { questionId: input.questionId, excludedOptions, }); return { itemId: input.itemId, quantityRemaining: consumption.item.quantity, effect: { type: 'hint', excludedOptions, }, }; } async function useStreakShield(input: UseInventoryItemInput): Promise { const consumption = await consumeUseItem(input); if (!consumption.applied) { const protectedUntil = await getStreakProtectedUntil(input.userId); return { itemId: input.itemId, quantityRemaining: consumption.item.quantity, effect: { type: 'streak_protection', streakProtectedUntil: protectedUntil, }, }; } const protectedUntil = new Date(); protectedUntil.setUTCDate(protectedUntil.getUTCDate() + ITEM_RULES.streakShield.protectDays); await db .update(users) .set({ streakProtectedUntil: protectedUntil }) .where(eq(users.id, input.userId)); await freezeStreak(input.userId); return { itemId: input.itemId, quantityRemaining: consumption.item.quantity, effect: { type: 'streak_protection', streakProtectedUntil: protectedUntil.toISOString(), }, }; } async function getStreakProtectedUntil(userId: string): Promise { const [user] = await db .select({ streakProtectedUntil: users.streakProtectedUntil }) .from(users) .where(eq(users.id, userId)) .limit(1); const value = user?.streakProtectedUntil ?? null; if (!value) return null; return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); } async function consumeUseItem(input: UseInventoryItemInput, snapshot: Record = {}) { return consumeInventoryItem({ userId: input.userId, itemId: input.itemId as InventoryItemId, quantity: 1, sourceType: 'system_adjust', sourceId: `use-item:${input.clientRequestId}`, idempotencyKey: `use-item:${input.clientRequestId}:${input.itemId}`, snapshot, }); } async function getMaxHearts(userId: string): Promise { const [user] = await db .select({ tier: users.tier }) .from(users) .where(eq(users.id, userId)) .limit(1); return user?.tier === 'pro' || user?.tier === 'proplus' ? HEART_RULES.subscribedMax : HEART_RULES.freeMax; } async function getHintExcludedOptions(questionId: string): Promise { const [question] = await db .select({ distractors: questions.distractors }) .from(questions) .where(eq(questions.id, questionId)) .limit(1); if (!question) throw new NotFoundError('Question'); const distractors = Array.isArray(question.distractors) ? question.distractors.filter((item): item is string => typeof item === 'string') : []; return distractors.slice(0, ITEM_RULES.hintFeather.excludedDistractors); }