forked from syntaxbullet/aurorabot
refactor: Implement Zod schema validation for inventory effect payloads and enhance item route DTO type safety.
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
import { levelingService } from "@shared/modules/leveling/leveling.service";
|
||||||
import { economyService } from "@shared/modules/economy/economy.service";
|
import { economyService } from "@shared/modules/economy/economy.service";
|
||||||
import { userTimers } from "@db/schema";
|
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 type { LootTableItem } from "@shared/lib/types";
|
||||||
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
import { inventoryService } from "@shared/modules/inventory/inventory.service";
|
||||||
import { inventory, items } from "@db/schema";
|
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
|
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);
|
await levelingService.addXp(userId, BigInt(effect.amount), txFn);
|
||||||
return `Gained ${effect.amount} XP`;
|
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);
|
await economyService.modifyUserBalance(userId, BigInt(effect.amount), TransactionType.ITEM_USE, `Used Item`, null, txFn);
|
||||||
return `Gained ${effect.amount} 🪙`;
|
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;
|
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 boostDuration = getDuration(effect);
|
||||||
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
const expiresAt = new Date(Date.now() + boostDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
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`;
|
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 roleDuration = getDuration(effect);
|
||||||
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
const roleExpiresAt = new Date(Date.now() + roleDuration * 1000);
|
||||||
await txFn.insert(userTimers).values({
|
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`;
|
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";
|
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[];
|
const pool = effect.pool as LootTableItem[];
|
||||||
if (!pool || pool.length === 0) return "The box is empty...";
|
if (!pool || pool.length === 0) return "The box is empty...";
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
handleColorRole,
|
handleColorRole,
|
||||||
handleLootbox
|
handleLootbox
|
||||||
} from "./effect.handlers";
|
} 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> = {
|
export const effectHandlers: Record<string, EffectHandler> = {
|
||||||
'ADD_XP': handleAddXp,
|
'ADD_XP': handleAddXp,
|
||||||
@@ -18,3 +21,21 @@ export const effectHandlers: Record<string, EffectHandler> = {
|
|||||||
'COLOR_ROLE': handleColorRole,
|
'COLOR_ROLE': handleColorRole,
|
||||||
'LOOTBOX': handleLootbox
|
'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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,71 @@
|
|||||||
import type { Transaction } from "@shared/lib/types";
|
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>;
|
export type EffectHandler = (userId: string, effect: any, txFn: Transaction) => Promise<any>;
|
||||||
|
|||||||
@@ -171,17 +171,11 @@ export const inventoryService = {
|
|||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
// 2. Apply Effects
|
// 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) {
|
for (const effect of usageData.effects) {
|
||||||
const handler = effectHandlers[effect.type];
|
const result = await validateAndExecuteEffect(effect, userId, txFn);
|
||||||
if (handler) {
|
results.push(result);
|
||||||
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)`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Consume
|
// 3. Consume
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { join, resolve, dirname } from "path";
|
import { join, resolve, dirname } from "path";
|
||||||
import type { RouteContext, RouteModule } from "./types";
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
|
import type { CreateItemDTO, UpdateItemDTO } from "@shared/modules/items/items.service";
|
||||||
import {
|
import {
|
||||||
jsonResponse,
|
jsonResponse,
|
||||||
errorResponse,
|
errorResponse,
|
||||||
@@ -121,7 +122,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
const contentType = req.headers.get("content-type") || "";
|
const contentType = req.headers.get("content-type") || "";
|
||||||
|
|
||||||
let itemData: any;
|
let itemData: CreateItemDTO | null = null;
|
||||||
let imageFile: File | null = null;
|
let imageFile: File | null = null;
|
||||||
|
|
||||||
if (contentType.includes("multipart/form-data")) {
|
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;
|
imageFile = formData.get("image") as File | null;
|
||||||
|
|
||||||
if (typeof jsonData === "string") {
|
if (typeof jsonData === "string") {
|
||||||
itemData = JSON.parse(jsonData);
|
itemData = JSON.parse(jsonData) as CreateItemDTO;
|
||||||
} else {
|
} else {
|
||||||
return errorResponse("Missing item data", 400);
|
return errorResponse("Missing item data", 400);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
itemData = await req.json();
|
itemData = await req.json() as CreateItemDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemData) {
|
||||||
|
return errorResponse("Missing item data", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -235,7 +240,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
|
|
||||||
return withErrorHandling(async () => {
|
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);
|
const existing = await itemsService.getItemById(id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
@@ -250,7 +255,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build update data
|
// Build update data
|
||||||
const updateData: any = {};
|
const updateData: Partial<UpdateItemDTO> = {};
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
if (data.rarity !== undefined) updateData.rarity = data.rarity;
|
||||||
|
|||||||
Reference in New Issue
Block a user