Files
pagodera/lib/validation-schemas.ts
T
Evandro Fonseca Junior 55eebde0ba feat: add user authentication UI to header component
Add login and register dialogs, user dropdown menu with profile link and logout option, and integrate with session management hook. Prevent hydration mismatches by conditionally rendering auth elements after mount. Update package-lock.json with new @esbuild dependencies for build tooling.
2025-11-04 02:33:29 -03:00

368 lines
11 KiB
TypeScript

import { z } from "zod";
import validator from "validator";
import { isValidDocument } from "./validators/document-validator";
import sanitizeHtml from "sanitize-html";
/**
* 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),
});
// 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"),
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),
}).refine(
(data) => {
// Pelo menos um preço deve ser fornecido
return (
data.price_pix !== null ||
data.price_tibia_coins !== null ||
data.price_gold_coins !== 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(),
});
// Type exports para uso no código
export type CreateDonationInput = z.infer<typeof createDonationSchema>;
export type VerifyStatusInput = z.infer<typeof verifyStatusSchema>;
export type VerifyBillingInput = z.infer<typeof verifyBillingSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type LoginInput = z.infer<typeof loginSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type CharacterInput = z.infer<typeof characterSchema>;
export type CreateListingInput = z.infer<typeof createListingSchema>;
export type UpdateListingInput = z.infer<typeof updateListingSchema>;
export type BidInput = z.infer<typeof bidSchema>;