589 lines
18 KiB
TypeScript
589 lines
18 KiB
TypeScript
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<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>;
|
|
export type TelemetryEventInput = z.infer<typeof telemetryEventSchema>;
|
|
export type TelemetryBatchInput = z.infer<typeof telemetryBatchSchema>;
|