refactor: Implement Zod schema validation for inventory effect payloads and enhance item route DTO type safety.

This commit is contained in:
syntaxbullet
2026-02-13 14:12:46 +01:00
parent bf20c61190
commit 0c67a8754f
5 changed files with 112 additions and 23 deletions

View File

@@ -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<ValidatedEffectPayload, { type: typeof EffectType.ADD_XP }>, 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<ValidatedEffectPayload, { type: typeof EffectType.ADD_BALANCE }>, 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<ValidatedEffectPayload, { type: typeof EffectType.REPLY_MESSAGE }>, _txFn) => {
return effect.message;
};
export const handleXpBoost: EffectHandler = async (userId, effect, txFn) => {
export const handleXpBoost: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.XP_BOOST }>, 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<ValidatedEffectPayload, { type: typeof EffectType.TEMP_ROLE }>, 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<ValidatedEffectPayload, { type: typeof EffectType.COLOR_ROLE }>, _txFn) => {
return "Color Role Equipped";
};
export const handleLootbox: EffectHandler = async (userId, effect, txFn) => {
export const handleLootbox: EffectHandler = async (userId, effect: Extract<ValidatedEffectPayload, { type: typeof EffectType.LOOTBOX }>, txFn) => {
const pool = effect.pool as LootTableItem[];
if (!pool || pool.length === 0) return "The box is empty...";

View File

@@ -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<string, EffectHandler> = {
'ADD_XP': handleAddXp,
@@ -18,3 +21,21 @@ export const effectHandlers: Record<string, EffectHandler> = {
'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);
}

View File

@@ -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<typeof EffectPayloadSchema>;
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;

View File

@@ -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

View File

@@ -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<Response | null> {
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<Response | null> {
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<Response | null> {
if (!id) return null;
return withErrorHandling(async () => {
const data = await req.json() as Record<string, any>;
const data = await req.json() as Partial<UpdateItemDTO>;
const existing = await itemsService.getItemById(id);
if (!existing) {
@@ -250,7 +255,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
}
// Build update data
const updateData: any = {};
const updateData: Partial<UpdateItemDTO> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.rarity !== undefined) updateData.rarity = data.rarity;