From 0c67a8754ffa283b4e72e3e28c4926d5cbe6f4eb Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 13 Feb 2026 14:12:46 +0100 Subject: [PATCH] refactor: Implement Zod schema validation for inventory effect payloads and enhance item route DTO type safety. --- bot/modules/inventory/effect.handlers.ts | 17 ++--- bot/modules/inventory/effect.registry.ts | 23 ++++++- bot/modules/inventory/effect.types.ts | 68 +++++++++++++++++++ shared/modules/inventory/inventory.service.ts | 12 +--- web/src/routes/items.routes.ts | 15 ++-- 5 files changed, 112 insertions(+), 23 deletions(-) diff --git a/bot/modules/inventory/effect.handlers.ts b/bot/modules/inventory/effect.handlers.ts index df3a99a..0c5e66f 100644 --- a/bot/modules/inventory/effect.handlers.ts +++ b/bot/modules/inventory/effect.handlers.ts @@ -1,7 +1,8 @@ import { levelingService } from "@shared/modules/leveling/leveling.service"; import { economyService } from "@shared/modules/economy/economy.service"; import { userTimers } from "@db/schema"; -import type { EffectHandler } from "./effect.types"; +import type { EffectHandler, ValidatedEffectPayload } from "./effect.types"; +import { EffectType } from "@shared/lib/constants"; import type { LootTableItem } from "@shared/lib/types"; import { inventoryService } from "@shared/modules/inventory/inventory.service"; import { inventory, items } from "@db/schema"; @@ -15,21 +16,21 @@ const getDuration = (effect: any): number => { return effect.durationSeconds || 60; // Default to 60s if nothing provided }; -export const handleAddXp: EffectHandler = async (userId, effect, txFn) => { +export const handleAddXp: EffectHandler = async (userId, effect: Extract, txFn) => { await levelingService.addXp(userId, BigInt(effect.amount), txFn); return `Gained ${effect.amount} XP`; }; -export const handleAddBalance: EffectHandler = async (userId, effect, txFn) => { +export const handleAddBalance: EffectHandler = async (userId, effect: Extract, txFn) => { await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn); return `Gained ${effect.amount} 🪙`; }; -export const handleReplyMessage: EffectHandler = async (_userId, effect, _txFn) => { +export const handleReplyMessage: EffectHandler = async (_userId, effect: Extract, _txFn) => { return effect.message; }; -export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => { +export const handleXpBoost: EffectHandler = async (userId, effect: Extract, txFn) => { const boostDuration = getDuration(effect); const expiresAt = new Date(Date.now() + boostDuration * 1000); await txFn.insert(userTimers).values({ @@ -45,7 +46,7 @@ export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => { return `XP Boost (${effect.multiplier}x) active for ${Math.floor(boostDuration / 60)}m`; }; -export const handleTempRole: EffectHandler = async (userId, effect, txFn) => { +export const handleTempRole: EffectHandler = async (userId, effect: Extract, txFn) => { const roleDuration = getDuration(effect); const roleExpiresAt = new Date(Date.now() + roleDuration * 1000); await txFn.insert(userTimers).values({ @@ -62,11 +63,11 @@ export const handleTempRole: EffectHandler = async (userId, effect, txFn) => { return `Temporary Role granted for ${Math.floor(roleDuration / 60)}m`; }; -export const handleColorRole: EffectHandler = async (_userId, _effect, _txFn) => { +export const handleColorRole: EffectHandler = async (_userId, _effect: Extract, _txFn) => { return "Color Role Equipped"; }; -export const handleLootbox: EffectHandler = async (userId, effect, txFn) => { +export const handleLootbox: EffectHandler = async (userId, effect: Extract, txFn) => { const pool = effect.pool as LootTableItem[]; if (!pool || pool.length === 0) return "The box is empty..."; diff --git a/bot/modules/inventory/effect.registry.ts b/bot/modules/inventory/effect.registry.ts index 99d8416..d3cd171 100644 --- a/bot/modules/inventory/effect.registry.ts +++ b/bot/modules/inventory/effect.registry.ts @@ -7,7 +7,10 @@ import { handleColorRole, handleLootbox } from "./effect.handlers"; -import type { EffectHandler } from "./effect.types"; +import type { EffectHandler, ValidatedEffectPayload } from "./effect.types"; +import { EffectPayloadSchema } from "./effect.types"; +import { UserError } from "@shared/lib/errors"; +import type { Transaction } from "@shared/lib/types"; export const effectHandlers: Record = { 'ADD_XP': handleAddXp, @@ -18,3 +21,21 @@ export const effectHandlers: Record = { 'COLOR_ROLE': handleColorRole, 'LOOTBOX': handleLootbox }; + +export async function validateAndExecuteEffect( + effect: unknown, + userId: string, + tx: Transaction +) { + const result = EffectPayloadSchema.safeParse(effect); + if (!result.success) { + throw new UserError(`Invalid effect configuration: ${result.error.message}`); + } + + const handler = effectHandlers[result.data.type]; + if (!handler) { + throw new UserError(`Unknown effect type: ${result.data.type}`); + } + + return handler(userId, result.data, tx); +} diff --git a/bot/modules/inventory/effect.types.ts b/bot/modules/inventory/effect.types.ts index b2e1e44..064dc4a 100644 --- a/bot/modules/inventory/effect.types.ts +++ b/bot/modules/inventory/effect.types.ts @@ -1,3 +1,71 @@ import type { Transaction } from "@shared/lib/types"; +import { z } from "zod"; +import { EffectType, LootType } from "@shared/lib/constants"; + +// Helper Schemas +const LootTableItemSchema = z.object({ + type: z.nativeEnum(LootType), + weight: z.number(), + amount: z.number().optional(), + itemId: z.number().optional(), + minAmount: z.number().optional(), + maxAmount: z.number().optional(), + message: z.string().optional(), +}); + +const DurationSchema = z.object({ + durationSeconds: z.number().optional(), + durationMinutes: z.number().optional(), + durationHours: z.number().optional(), +}); + +// Effect Schemas +const AddXpSchema = z.object({ + type: z.literal(EffectType.ADD_XP), + amount: z.number().positive(), +}); + +const AddBalanceSchema = z.object({ + type: z.literal(EffectType.ADD_BALANCE), + amount: z.number(), +}); + +const ReplyMessageSchema = z.object({ + type: z.literal(EffectType.REPLY_MESSAGE), + message: z.string(), +}); + +const XpBoostSchema = DurationSchema.extend({ + type: z.literal(EffectType.XP_BOOST), + multiplier: z.number(), +}); + +const TempRoleSchema = DurationSchema.extend({ + type: z.literal(EffectType.TEMP_ROLE), + roleId: z.string(), +}); + +const ColorRoleSchema = z.object({ + type: z.literal(EffectType.COLOR_ROLE), + roleId: z.string(), +}); + +const LootboxSchema = z.object({ + type: z.literal(EffectType.LOOTBOX), + pool: z.array(LootTableItemSchema), +}); + +// Union Schema +export const EffectPayloadSchema = z.discriminatedUnion('type', [ + AddXpSchema, + AddBalanceSchema, + ReplyMessageSchema, + XpBoostSchema, + TempRoleSchema, + ColorRoleSchema, + LootboxSchema, +]); + +export type ValidatedEffectPayload = z.infer; export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise; diff --git a/shared/modules/inventory/inventory.service.ts b/shared/modules/inventory/inventory.service.ts index 2c46d2b..cc2d92f 100644 --- a/shared/modules/inventory/inventory.service.ts +++ b/shared/modules/inventory/inventory.service.ts @@ -171,17 +171,11 @@ export const inventoryService = { const results: any[] = []; // 2. Apply Effects - const { effectHandlers } = await import("@/modules/inventory/effect.registry"); + const { validateAndExecuteEffect } = await import("@/modules/inventory/effect.registry"); for (const effect of usageData.effects) { - const handler = effectHandlers[effect.type]; - if (handler) { - const result = await handler(userId, effect, txFn); - results.push(result); - } else { - console.warn(`No handler found for effect type: ${effect.type}`); - results.push(`Effect ${effect.type} applied (no description)`); - } + const result = await validateAndExecuteEffect(effect, userId, txFn); + results.push(result); } // 3. Consume diff --git a/web/src/routes/items.routes.ts b/web/src/routes/items.routes.ts index 0e05c62..7ee42ad 100644 --- a/web/src/routes/items.routes.ts +++ b/web/src/routes/items.routes.ts @@ -5,6 +5,7 @@ import { join, resolve, dirname } from "path"; import type { RouteContext, RouteModule } from "./types"; +import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service"; import { jsonResponse, errorResponse, @@ -121,7 +122,7 @@ async function handler(ctx: RouteContext): Promise { return withErrorHandling(async () => { const contentType = req.headers.get("content-type") || ""; - let itemData: any; + let itemData: CreateItemDTO | null = null; let imageFile: File | null = null; if (contentType.includes("multipart/form-data")) { @@ -130,12 +131,16 @@ async function handler(ctx: RouteContext): Promise { imageFile = formData.get("image") as File | null; if (typeof jsonData === "string") { - itemData = JSON.parse(jsonData); + itemData = JSON.parse(jsonData) as CreateItemDTO; } else { return errorResponse("Missing item data", 400); } } else { - itemData = await req.json(); + itemData = await req.json() as CreateItemDTO; + } + + if (!itemData) { + return errorResponse("Missing item data", 400); } // Validate required fields @@ -235,7 +240,7 @@ async function handler(ctx: RouteContext): Promise { if (!id) return null; return withErrorHandling(async () => { - const data = await req.json() as Record; + const data = await req.json() as Partial; const existing = await itemsService.getItemById(id); if (!existing) { @@ -250,7 +255,7 @@ async function handler(ctx: RouteContext): Promise { } // Build update data - const updateData: any = {}; + const updateData: Partial = {}; if (data.name !== undefined) updateData.name = data.name; if (data.description !== undefined) updateData.description = data.description; if (data.rarity !== undefined) updateData.rarity = data.rarity;