import sanitizeHtml from "sanitize-html"; import validator from "validator"; import { z } from "zod"; import { isValidDocument } from "./validators/document-validator"; /** * Schemas de validação usando Zod * Garante type-safety e validação robusta de dados de entrada */ // Schema para email - validação RFC 5322 export const emailSchema = z .string() .min(1, "Email é obrigatório") .email("Formato de email inválido") .refine((email) => validator.isEmail(email), { message: "Email inválido", }) .transform((email) => email.trim().toLowerCase()); // Schema para telefone (aceita brasileiro e internacional) export const phoneSchema = z .string() .min(1, "Telefone é obrigatório") .refine((phone) => { // Remove todos os caracteres não numéricos (incluindo +) // Aceita formato internacional com código de país (ex: +55119988887777) const cleaned = phone.replace(/[^\d]/g, ""); // Aceita telefones de 8 a 15 dígitos (padrão internacional E.164) // O código de país pode ter 1-3 dígitos, então o número total pode ser maior // Mas para validação, aceitamos de 10 a 16 dígitos (código + número) return cleaned.length >= 10 && cleaned.length <= 16; }, "Telefone deve ter entre 10 e 16 dígitos (incluindo código de país)") .transform((phone) => { // Remove todos os caracteres não numéricos, mantendo apenas dígitos // O formato final será: código de país + número (ex: 55119988887777) return phone.replace(/[^\d]/g, ""); }); // Schema para telefone com código de país (para registro) export const phoneWithCountrySchema = z.object({ dialCode: z.string().min(1, "Código de país é obrigatório"), phoneNumber: phoneSchema, }); // Schema para CPF/CNPJ export const documentSchema = z .string() .min(1, "CPF/CNPJ é obrigatório") .refine((doc) => isValidDocument(doc), { message: "CPF ou CNPJ inválido", }) .transform((doc) => doc.replace(/[^\d]/g, "")); // Normaliza removendo formatação // Schema para nome (alfabético, sem números) export const nameSchema = z .string() .min(3, "Nome deve ter no mínimo 3 caracteres") .max(100, "Nome deve ter no máximo 100 caracteres") .refine( (name) => /^[A-Za-zÀ-ÿ\s'-]+$/.test(name.trim()), "Nome deve conter apenas letras, espaços e acentos", ) .transform((name) => name.trim()); // Schema para mensagem (sanitiza HTML e previne XSS) export const messageSchema = z .string() .max(280, "Mensagem deve ter no máximo 280 caracteres") .transform((message) => { // Sanitiza HTML para prevenir XSS const sanitized = sanitizeHtml(message, { allowedTags: [], // Remove todas as tags HTML allowedAttributes: {}, }); return sanitized.trim(); }) .optional(); // Schema para tier de doação export const donationTierSchema = z.enum( ["coffee", "snack", "lunch", "custom"], { message: "Tier de doação inválido", }, ); // Schema para valor customizado export const customAmountSchema = z .number() .min(5, "Valor mínimo é R$ 5,00") .max(100000, "Valor máximo é R$ 100.000,00"); // Schema completo para criação de doação export const createDonationSchema = z .discriminatedUnion("anonymous", [ // Doação anônima z.object({ tier: donationTierSchema, anonymous: z.literal(true), customAmount: z.number().optional(), }), // Doação identificada z.object({ tier: donationTierSchema, anonymous: z.literal(false), donorName: nameSchema, donorEmail: emailSchema, donorPhone: phoneSchema, donorTaxId: documentSchema, donorMessage: messageSchema, customAmount: z.number().optional(), }), ]) .refine( (data) => { // Se tier for custom, customAmount é obrigatório if (data.tier === "custom") { return data.customAmount !== undefined; } return true; }, { message: "customAmount é obrigatório quando tier é 'custom'", path: ["customAmount"], }, ) .refine( (data) => { // Se tier for custom, customAmount deve estar no range válido if (data.tier === "custom" && data.customAmount) { return data.customAmount >= 5 && data.customAmount <= 100000; } return true; }, { message: "Valor deve estar entre R$ 5,00 e R$ 100.000,00", path: ["customAmount"], }, ); // Schema para verificação de status export const verifyStatusSchema = z.object({ pixId: z.string().min(1, "pixId é obrigatório"), }); // Schema para verificação de billing export const verifyBillingSchema = z.object({ billingId: z.string().min(1, "ID da cobrança é obrigatório"), }); // ============================================ // SCHEMAS DE AUTENTICAÇÃO E USUÁRIOS // ============================================ // Schema para username (alfanumérico + underscore) export const usernameSchema = z .string() .min(3, "Username deve ter no mínimo 3 caracteres") .max(50, "Username deve ter no máximo 50 caracteres") .regex( /^[a-zA-Z0-9_]+$/, "Username deve conter apenas letras, números e underscore", ) .transform((username) => username.toLowerCase().trim()); // Schema para senha (complexidade mínima) export const passwordSchema = z .string() .min(8, "Senha deve ter no mínimo 8 caracteres") .refine( (password) => { // Pelo menos 1 maiúscula if (!/[A-Z]/.test(password)) return false; // Pelo menos 1 minúscula if (!/[a-z]/.test(password)) return false; // Pelo menos 1 número if (!/[0-9]/.test(password)) return false; return true; }, { message: "Senha deve conter pelo menos 1 letra maiúscula, 1 letra minúscula e 1 número", }, ); // Schema para servidor/personagem no registro export const registerServerCharacterSchema = z.object({ serverName: z .string() .min(1, "Nome do servidor é obrigatório") .max(50, "Nome do servidor deve ter no máximo 50 caracteres") .transform((name) => name.trim()), characterName: z .string() .min(3, "Nome do personagem deve ter no mínimo 3 caracteres") .max(100, "Nome do personagem deve ter no máximo 100 caracteres") .transform((name) => name.trim()), isMain: z.boolean().optional().default(false), }); // ============================================ // SCHEMAS DE ASSINATURAS E PERMISSÕES // ============================================ const tierSlugRegex = /^[a-z0-9-]+$/; const featureKeyRegex = /^[a-z0-9_.-]+$/; const subscriptionTierFeatureSchema = z.object({ featureKey: z .string() .min(2, "featureKey deve ter no mínimo 2 caracteres") .max(100, "featureKey deve ter no máximo 100 caracteres") .regex( featureKeyRegex, "featureKey deve conter apenas letras, números, ponto, hífen ou underscore", ) .transform((key) => key.trim().toLowerCase()), label: z .string() .min(3, "Label deve ter no mínimo 3 caracteres") .max(255, "Label deve ter no máximo 255 caracteres") .transform((value) => value.trim()), description: z .string() .max(500, "Descrição deve ter no máximo 500 caracteres") .transform((value) => value.trim()) .optional() .nullable(), sortOrder: z.number().int().min(0).max(1000).optional(), }); export const createSubscriptionTierSchema = z .object({ name: z .string() .min(3, "Nome deve ter no mínimo 3 caracteres") .max(100, "Nome deve ter no máximo 100 caracteres") .transform((value) => value.trim()), slug: z .string() .min(2, "Slug deve ter no mínimo 2 caracteres") .max(50, "Slug deve ter no máximo 50 caracteres") .regex( tierSlugRegex, "Slug deve conter apenas letras minúsculas, números e hífen", ) .transform((value) => value.trim().toLowerCase()), description: z .string() .max(500, "Descrição deve ter no máximo 500 caracteres") .transform((value) => value.trim()) .optional() .nullable(), level: z.number().int().min(0).max(99).optional(), priceMonthly: z.number().min(0).max(100000).nullable().optional(), priceYearly: z.number().min(0).max(1000000).nullable().optional(), isActive: z.boolean().optional(), features: z.array(subscriptionTierFeatureSchema).max(50).optional(), }) .refine( (data) => { if ( data.priceMonthly !== undefined && data.priceYearly !== undefined && data.priceYearly !== null && data.priceMonthly !== null ) { return data.priceYearly >= data.priceMonthly; } return true; }, { message: "Preço anual deve ser maior ou igual ao mensal", path: ["priceYearly"], }, ); export const updateSubscriptionTierSchema = createSubscriptionTierSchema .partial() .extend({ slug: z .string() .min(2, "Slug deve ter no mínimo 2 caracteres") .max(50, "Slug deve ter no máximo 50 caracteres") .regex( tierSlugRegex, "Slug deve conter apenas letras minúsculas, números e hífen", ) .transform((value) => value.trim().toLowerCase()) .optional(), features: z .array( subscriptionTierFeatureSchema.extend({ featureId: z.number().int().positive().optional(), _delete: z.boolean().optional(), }), ) .max(50) .optional(), }); export const assignSubscriptionTierSchema = z .object({ userId: z.number().int().positive(), tierId: z.number().int().positive().nullable().optional(), tierSlug: z .string() .min(2, "Slug deve ter no mínimo 2 caracteres") .max(50, "Slug deve ter no máximo 50 caracteres") .regex( tierSlugRegex, "Slug deve conter apenas letras minúsculas, números e hífen", ) .transform((value) => value.trim().toLowerCase()) .optional(), }) .refine((data) => data.tierId !== undefined || data.tierSlug !== undefined, { message: "Informe tierId ou tierSlug", path: ["tierId"], }); // Schema para registro de usuário export const registerSchema = z.object({ username: usernameSchema, email: emailSchema, password: passwordSchema, firstName: nameSchema, lastName: nameSchema, phone: phoneSchema.optional(), servers: z .array(registerServerCharacterSchema) .min(1, "Selecione pelo menos um servidor"), }); // Schema para login (aceita email ou username) export const loginSchema = z.object({ emailOrUsername: z .string() .min(1, "Email ou username é obrigatório") .transform((val) => val.trim().toLowerCase()), password: z.string().min(1, "Senha é obrigatória"), }); // Schema para atualização de perfil export const updateUserSchema = z.object({ firstName: nameSchema.optional(), lastName: nameSchema.optional(), phone: phoneSchema.optional(), whatsappNotifications: z.boolean().optional(), }); // Schema para personagem export const characterSchema = z.object({ characterName: z .string() .min(3, "Nome do personagem deve ter no mínimo 3 caracteres") .max(100, "Nome do personagem deve ter no máximo 100 caracteres") .transform((name) => name.trim()), serverName: z .string() .min(1, "Nome do servidor é obrigatório") .max(50, "Nome do servidor deve ter no máximo 50 caracteres") .transform((name) => name.trim()), isMain: z.boolean().optional().default(false), }); // ============================================ // SCHEMAS DE MARKETPLACE (LISTINGS E BIDS) // ============================================ // Schema para criar anúncio (listing) export const createListingSchema = z .object({ item_id: z .number() .int() .positive("ID do item deve ser positivo") .optional(), // ID legado (SQLite) - opcional item_id_unified: z .number() .int() .positive("ID unificado do item deve ser positivo") .optional(), // ID unificado da tabela items - preferido item_name: z .string() .min(1, "Nome do item é obrigatório") .max(255, "Nome do item deve ter no máximo 255 caracteres") .transform((name) => name.trim()), quantity: z .number() .int() .positive("Quantidade deve ser maior que zero") .default(1), server_name: z .string() .min(1, "Nome do servidor é obrigatório") .max(50, "Nome do servidor deve ter no máximo 50 caracteres") .transform((name) => name.trim()), price_pix: z .number() .positive("Preço PIX deve ser positivo") .optional() .nullable(), price_tibia_coins: z .number() .int() .min(0, "Preço em Tibia Coins deve ser não negativo") .optional() .nullable(), price_gold_coins: z .number() .int() .min(0, "Preço em Gold Coins deve ser não negativo") .optional() .nullable(), is_auction: z.boolean().optional().default(false), auction_ends_at: z .string() .datetime("Data de término do leilão inválida") .optional() .nullable() .transform((val) => (val ? new Date(val) : null)), description: z .string() .max(5000, "Descrição deve ter no máximo 5000 caracteres") .optional() .nullable() .transform((val) => val?.trim() || null), // Campos para troca por item trade_item_id: z .number() .int() .positive("ID do item de troca deve ser positivo") .optional() .nullable(), trade_item_name: z .string() .min( 1, "Nome do item de troca é obrigatório se trade_item_id for fornecido", ) .max(255, "Nome do item de troca deve ter no máximo 255 caracteres") .optional() .nullable() .transform((name) => name?.trim() || null), trade_item_quantity: z .number() .int() .positive("Quantidade do item de troca deve ser maior que zero") .optional() .nullable() .default(1), }) .refine( (data) => { // Pelo menos um ID (item_id ou item_id_unified) deve ser fornecido if (!data.item_id && !data.item_id_unified) { return false; } return true; }, { message: "Pelo menos um ID do item (item_id ou item_id_unified) deve ser fornecido", path: ["item_id"], }, ) .refine( (data) => { // Se trade_item_id for fornecido, trade_item_name também deve ser if (data.trade_item_id && !data.trade_item_name) { return false; } return true; }, { message: "Nome do item de troca é obrigatório quando ID é fornecido", path: ["trade_item_name"], }, ) .refine( (data) => { // Pelo menos um preço ou troca por item deve ser fornecido return ( data.price_pix !== null || data.price_tibia_coins !== null || data.price_gold_coins !== null || data.trade_item_id !== null ); }, { message: "Pelo menos um método de pagamento (PIX, Tibia Coins ou Gold Coins) deve ser especificado", path: ["price_pix"], }, ) .refine( (data) => { // Se for leilão, deve ter data de término if (data.is_auction && !data.auction_ends_at) { return false; } return true; }, { message: "Data de término do leilão é obrigatória quando is_auction é true", path: ["auction_ends_at"], }, ); // Schema para atualizar anúncio export const updateListingSchema = z .object({ quantity: z.number().int().positive().optional(), price_pix: z.number().positive().optional().nullable(), price_tibia_coins: z.number().int().min(0).optional().nullable(), price_gold_coins: z.number().int().min(0).optional().nullable(), description: z.string().max(5000).optional().nullable(), listing_status: z .enum(["active", "sold", "cancelled", "in_escrow", "expired"]) .optional(), }) .refine((data) => { // Se atualizando preços, pelo menos um deve ser fornecido ou manter existentes const _hasPrice = data.price_pix !== undefined || data.price_tibia_coins !== undefined || data.price_gold_coins !== undefined; return true; // Permite atualizar sem especificar preços (mantém existentes) }); // Schema para criar lance (bid) export const bidSchema = z.object({ listing_id: z.number().int().positive("ID do anúncio deve ser positivo"), bid_amount: z.number().positive("Valor do lance deve ser positivo"), bid_type: z.enum(["pix", "tibia_coins", "gold_coins"]).optional().nullable(), }); // ============================================ // SCHEMAS DE TELEMETRIA // ============================================ // Schema para evento de telemetria export const telemetryEventSchema = z.object({ event_type: z.enum([ "ad_view", "ad_click", "ad_exit", "search", "filter", "favorite", "unfavorite", "purchase", "purchase_intention_created", "purchase_intention_viewed", "bid_placed", "bid_viewed", "seller_profile_viewed", "inquiry", "page_view", "price_change", ]), ad_id: z.number().int().positive().optional().nullable(), session_id: z.string().optional().nullable(), metadata: z.any().optional(), }); // Schema para batch de eventos (inclui session_id no nível do batch) export const telemetryBatchSchema = z.object({ events: z.array(telemetryEventSchema).min(1).max(100), // Limite de 100 eventos por batch session_id: z.string().optional().nullable(), // session_id pode estar no nível do batch }); // Type exports para uso no código export type CreateDonationInput = z.infer; export type VerifyStatusInput = z.infer; export type VerifyBillingInput = z.infer; export type RegisterInput = z.infer; export type LoginInput = z.infer; export type UpdateUserInput = z.infer; export type CharacterInput = z.infer; export type CreateListingInput = z.infer; export type UpdateListingInput = z.infer; export type BidInput = z.infer; export type TelemetryEventInput = z.infer; export type TelemetryBatchInput = z.infer;