Domine mutations complexas no tRPC: transações atômicas, validações avançadas, upload de arquivos e operações em lote para SaaS profissionais.
Integridade dos Dados: Transações garantem que operações complexas sejam atômicas e consistentes.
Experiência do Usuário: Operações em lote e validações robustas melhoram a UX significativamente.
// 📁 src/server/trpc/routers/order.ts
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { router, protectedProcedure } from '../trpc';
export const orderRouter = router({
// 🛒 Criar pedido com transação complexa
createOrder: protectedProcedure
.input(z.object({
items: z.array(z.object({
productId: z.string(),
quantity: z.number().min(1),
price: z.number().min(0),
})),
shippingAddress: z.object({
street: z.string(),
city: z.string(),
state: z.string(),
zipCode: z.string(),
country: z.string(),
}),
paymentMethod: z.enum(['CREDIT_CARD', 'PIX', 'BOLETO']),
couponCode: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { items, shippingAddress, paymentMethod, couponCode } = input;
const userId = ctx.session.user.id;
// 🔄 Executar tudo em uma transação atômica
const result = await ctx.prisma.$transaction(async (tx) => {
// 1️⃣ Validar estoque de todos os produtos
const productIds = items.map(item => item.productId);
const products = await tx.product.findMany({
where: { id: { in: productIds } },
select: { id: true, stockQuantity: true, price: true, name: true },
});
// 🔍 Verificar se todos os produtos existem
if (products.length !== productIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Um ou mais produtos não foram encontrados',
});
}
// 📊 Validar estoque e preços
const stockIssues: string[] = [];
const priceIssues: string[] = [];
for (const item of items) {
const product = products.find(p => p.id === item.productId);
if (!product) continue;
// 📦 Verificar estoque
if (product.stockQuantity < item.quantity) {
stockIssues.push(`${product.name}: estoque insuficiente`);
}
// 💰 Verificar preço (proteção contra race conditions)
if (Math.abs(product.price - item.price) > 0.01) {
priceIssues.push(`${product.name}: preço alterado`);
}
}
if (stockIssues.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Estoque insuficiente: ' + stockIssues.join(', '),
});
}
if (priceIssues.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Preços desatualizados: ' + priceIssues.join(', '),
});
}
// 2️⃣ Aplicar cupom de desconto se fornecido
let discount = 0;
let appliedCoupon = null;
if (couponCode) {
const coupon = await tx.coupon.findUnique({
where: { code: couponCode },
include: { usedBy: true },
});
if (!coupon) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cupom inválido',
});
}
// 🗓️ Verificar validade
if (coupon.expiresAt && coupon.expiresAt < new Date()) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cupom expirado',
});
}
// 📊 Verificar limite de uso
if (coupon.maxUses && coupon.usedBy.length >= coupon.maxUses) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cupom esgotado',
});
}
// 👤 Verificar se usuário já usou (se for cupom único)
if (coupon.onePerUser && coupon.usedBy.some(u => u.userId === userId)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cupom já utilizado',
});
}
discount = coupon.discountPercent || 0;
appliedCoupon = coupon;
}
// 3️⃣ Calcular totais
const subtotal = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const discountAmount = subtotal * (discount / 100);
const shippingCost = 15.99; // Poderia ser calculado dinamicamente
const total = subtotal - discountAmount + shippingCost;
// 4️⃣ Criar pedido
const order = await tx.order.create({
data: {
userId,
status: 'PENDING',
subtotal,
discountAmount,
shippingCost,
total,
paymentMethod,
shippingAddress: {
create: shippingAddress,
},
items: {
create: items.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.price,
total: item.price * item.quantity,
})),
},
...(appliedCoupon && {
coupon: {
connect: { id: appliedCoupon.id },
},
}),
},
include: {
items: {
include: {
product: {
select: { name: true, image: true },
},
},
},
shippingAddress: true,
coupon: true,
},
});
// 5️⃣ Atualizar estoque dos produtos
await Promise.all(
items.map(item =>
tx.product.update({
where: { id: item.productId },
data: {
stockQuantity: {
decrement: item.quantity,
},
},
})
)
);
// 6️⃣ Registrar uso do cupom
if (appliedCoupon) {
await tx.couponUsage.create({
data: {
couponId: appliedCoupon.id,
userId,
orderId: order.id,
},
});
}
// 7️⃣ Criar registro de auditoria
await tx.auditLog.create({
data: {
userId,
action: 'ORDER_CREATED',
resource: 'ORDER',
resourceId: order.id,
metadata: {
orderTotal: total,
itemCount: items.length,
couponUsed: couponCode || null,
},
},
});
return order;
});
// 🔔 Dispara eventos pós-transação
await Promise.all([
// 📧 Enviar email de confirmação
ctx.emailService.sendOrderConfirmation(result.id),
// 📊 Atualizar analytics
ctx.analytics.trackEvent('order_created', {
userId,
orderId: result.id,
total: result.total,
}),
// 📱 Notificar vendedor
ctx.notificationService.notifyNewOrder(result.id),
]);
return result;
}),
// 🔄 Cancelar pedido com rollback
cancelOrder: protectedProcedure
.input(z.object({
orderId: z.string(),
reason: z.string().min(10),
}))
.mutation(async ({ input, ctx }) => {
const { orderId, reason } = input;
const userId = ctx.session.user.id;
const result = await ctx.prisma.$transaction(async (tx) => {
// 1️⃣ Buscar pedido
const order = await tx.order.findUnique({
where: { id: orderId },
include: { items: true, coupon: true },
});
if (!order) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Pedido não encontrado',
});
}
// 🔒 Verificar permissão
if (order.userId !== userId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Você não tem permissão para cancelar este pedido',
});
}
// 📊 Verificar se pode cancelar
if (order.status === 'CANCELLED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Pedido já cancelado',
});
}
if (order.status === 'DELIVERED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Não é possível cancelar pedido já entregue',
});
}
// 2️⃣ Restaurar estoque
await Promise.all(
order.items.map(item =>
tx.product.update({
where: { id: item.productId },
data: {
stockQuantity: {
increment: item.quantity,
},
},
})
)
);
// 3️⃣ Reverter uso do cupom
if (order.coupon) {
await tx.couponUsage.deleteMany({
where: {
couponId: order.coupon.id,
userId,
orderId,
},
});
}
// 4️⃣ Atualizar pedido
const updatedOrder = await tx.order.update({
where: { id: orderId },
data: {
status: 'CANCELLED',
cancelledAt: new Date(),
cancellationReason: reason,
},
include: {
items: {
include: {
product: {
select: { name: true },
},
},
},
},
});
// 5️⃣ Auditoria
await tx.auditLog.create({
data: {
userId,
action: 'ORDER_CANCELLED',
resource: 'ORDER',
resourceId: orderId,
metadata: {
reason,
refundAmount: order.total,
},
},
});
return updatedOrder;
});
return result;
}),
});
// 📁 src/server/trpc/validations/advanced.ts
import { z } from 'zod';
// 🎯 Validação de CPF/CNPJ
const cpfRegex = /^\d{3}\.\d{3}\.\d{3}-\d{2}$/;
const cnpjRegex = /^\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}$/;
export const cpfSchema = z.string()
.regex(cpfRegex, 'CPF deve ter formato: 000.000.000-00')
.refine(validateCPF, 'CPF inválido');
export const cnpjSchema = z.string()
.regex(cnpjRegex, 'CNPJ deve ter formato: 00.000.000/0000-00')
.refine(validateCNPJ, 'CNPJ inválido');
// 📧 Validação de email com domínios permitidos
export const businessEmailSchema = z.string()
.email('Email inválido')
.refine(
(email) => {
const blockedDomains = ['tempmail.com', '10minutemail.com', 'guerrillamail.com'];
const domain = email.split('@')[1];
return !blockedDomains.includes(domain);
},
'Email temporário não é permitido'
);
// 🔐 Validação de senha forte
export const strongPasswordSchema = z.string()
.min(8, 'Senha deve ter pelo menos 8 caracteres')
.regex(/[a-z]/, 'Senha deve conter pelo menos uma letra minúscula')
.regex(/[A-Z]/, 'Senha deve conter pelo menos uma letra maiúscula')
.regex(/[0-9]/, 'Senha deve conter pelo menos um número')
.regex(/[^A-Za-z0-9]/, 'Senha deve conter pelo menos um caractere especial');
// 📱 Validação de telefone brasileiro
export const phoneSchema = z.string()
.regex(/^\(\d{2}\) \d{4,5}-\d{4}$/, 'Formato: (11) 99999-9999')
.refine(validateBrazilianPhone, 'Telefone inválido');
// 💳 Validação de cartão de crédito
export const creditCardSchema = z.object({
number: z.string()
.regex(/^\d{4} \d{4} \d{4} \d{4}$/, 'Formato: 0000 0000 0000 0000')
.refine(validateCreditCard, 'Número do cartão inválido'),
expiryDate: z.string()
.regex(/^\d{2}\/\d{2}$/, 'Formato: MM/AA')
.refine(validateExpiryDate, 'Data de expiração inválida'),
cvv: z.string()
.regex(/^\d{3,4}$/, 'CVV deve ter 3 ou 4 dígitos'),
holderName: z.string()
.min(2, 'Nome deve ter pelo menos 2 caracteres')
.max(50, 'Nome deve ter no máximo 50 caracteres')
.regex(/^[a-zA-ZÀ-ÿ\s]+$/, 'Nome deve conter apenas letras e espaços'),
});
// 🏢 Validação de endereço brasileiro
export const addressSchema = z.object({
zipCode: z.string()
.regex(/^\d{5}-\d{3}$/, 'CEP deve ter formato: 00000-000')
.refine(async (cep) => {
// 🌐 Validar CEP via API dos Correios
try {
const response = await fetch(`https://viacep.com.br/ws/${cep.replace('-', '')}/json/`);
const data = await response.json();
return !data.erro;
} catch {
return false;
}
}, 'CEP não encontrado'),
street: z.string().min(5, 'Rua deve ter pelo menos 5 caracteres'),
number: z.string().min(1, 'Número é obrigatório'),
complement: z.string().optional(),
neighborhood: z.string().min(2, 'Bairro é obrigatório'),
city: z.string().min(2, 'Cidade é obrigatória'),
state: z.string().length(2, 'Estado deve ter 2 caracteres'),
});
// 📊 Validação de dados de produto
export const productSchema = z.object({
name: z.string()
.min(3, 'Nome deve ter pelo menos 3 caracteres')
.max(100, 'Nome deve ter no máximo 100 caracteres'),
description: z.string()
.min(10, 'Descrição deve ter pelo menos 10 caracteres')
.max(5000, 'Descrição deve ter no máximo 5000 caracteres'),
price: z.number()
.positive('Preço deve ser positivo')
.max(999999.99, 'Preço máximo é R$ 999.999,99')
.multipleOf(0.01, 'Preço deve ter no máximo 2 casas decimais'),
weight: z.number()
.positive('Peso deve ser positivo')
.max(50, 'Peso máximo é 50kg'),
dimensions: z.object({
length: z.number().positive().max(200, 'Comprimento máximo é 200cm'),
width: z.number().positive().max(200, 'Largura máxima é 200cm'),
height: z.number().positive().max(200, 'Altura máxima é 200cm'),
}),
category: z.string().min(1, 'Categoria é obrigatória'),
images: z.array(z.string().url('URL de imagem inválida'))
.min(1, 'Pelo menos uma imagem é obrigatória')
.max(10, 'Máximo 10 imagens por produto'),
attributes: z.record(z.string(), z.any()).optional(),
seoTitle: z.string()
.min(10, 'Título SEO deve ter pelo menos 10 caracteres')
.max(60, 'Título SEO deve ter no máximo 60 caracteres'),
seoDescription: z.string()
.min(50, 'Descrição SEO deve ter pelo menos 50 caracteres')
.max(160, 'Descrição SEO deve ter no máximo 160 caracteres'),
});
// 🔄 Validação de operação em lote
export const batchOperationSchema = z.object({
operation: z.enum(['CREATE', 'UPDATE', 'DELETE']),
items: z.array(z.object({
id: z.string().optional(),
data: z.record(z.string(), z.any()),
}))
.min(1, 'Pelo menos um item é obrigatório')
.max(100, 'Máximo 100 itens por operação'),
options: z.object({
skipValidation: z.boolean().default(false),
continueOnError: z.boolean().default(false),
dryRun: z.boolean().default(false),
}).optional(),
});
// 🎨 Validação de upload de arquivo
export const fileUploadSchema = z.object({
filename: z.string()
.min(1, 'Nome do arquivo é obrigatório')
.regex(/^[a-zA-Z0-9._-]+$/, 'Nome de arquivo inválido'),
mimeType: z.string()
.refine(
(type) => {
const allowedTypes = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
'text/csv',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
return allowedTypes.includes(type);
},
'Tipo de arquivo não permitido'
),
size: z.number()
.positive('Tamanho do arquivo deve ser positivo')
.max(10 * 1024 * 1024, 'Arquivo deve ter no máximo 10MB'), // 10MB
base64: z.string()
.refine(
(data) => {
try {
return Buffer.from(data, 'base64').toString('base64') === data;
} catch {
return false;
}
},
'Dados base64 inválidos'
),
});
// 🧮 Funções de validação personalizadas
function validateCPF(cpf: string): boolean {
const numbers = cpf.replace(/[^\d]/g, '');
if (numbers.length !== 11) return false;
if (/^(\d)\1{10}$/.test(numbers)) return false;
// Algoritmo de validação do CPF
let sum = 0;
for (let i = 0; i < 9; i++) {
sum += parseInt(numbers[i]) * (10 - i);
}
let remainder = (sum * 10) % 11;
if (remainder === 10 || remainder === 11) remainder = 0;
if (remainder !== parseInt(numbers[9])) return false;
sum = 0;
for (let i = 0; i < 10; i++) {
sum += parseInt(numbers[i]) * (11 - i);
}
remainder = (sum * 10) % 11;
if (remainder === 10 || remainder === 11) remainder = 0;
if (remainder !== parseInt(numbers[10])) return false;
return true;
}
function validateCNPJ(cnpj: string): boolean {
const numbers = cnpj.replace(/[^\d]/g, '');
if (numbers.length !== 14) return false;
if (/^(\d)\1{13}$/.test(numbers)) return false;
// Algoritmo de validação do CNPJ
const weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
const weights2 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
let sum = 0;
for (let i = 0; i < 12; i++) {
sum += parseInt(numbers[i]) * weights1[i];
}
let remainder = sum % 11;
const digit1 = remainder < 2 ? 0 : 11 - remainder;
if (digit1 !== parseInt(numbers[12])) return false;
sum = 0;
for (let i = 0; i < 13; i++) {
sum += parseInt(numbers[i]) * weights2[i];
}
remainder = sum % 11;
const digit2 = remainder < 2 ? 0 : 11 - remainder;
return digit2 === parseInt(numbers[13]);
}
function validateBrazilianPhone(phone: string): boolean {
const numbers = phone.replace(/[^\d]/g, '');
// Celular: 11 dígitos (DDD + 9 + 8 dígitos)
// Fixo: 10 dígitos (DDD + 8 dígitos)
if (numbers.length !== 10 && numbers.length !== 11) return false;
const ddd = parseInt(numbers.substring(0, 2));
const validDDDs = [11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 24, 27, 28, 31, 32, 33, 34, 35, 37, 38, 41, 42, 43, 44, 45, 46, 47, 48, 49, 51, 53, 54, 55, 61, 62, 63, 64, 65, 66, 67, 68, 69, 71, 73, 74, 75, 77, 79, 81, 82, 83, 84, 85, 86, 87, 88, 89, 91, 92, 93, 94, 95, 96, 97, 98, 99];
return validDDDs.includes(ddd);
}
function validateCreditCard(number: string): boolean {
const numbers = number.replace(/[^\d]/g, '');
// Algoritmo de Luhn
let sum = 0;
let isEven = false;
for (let i = numbers.length - 1; i >= 0; i--) {
let digit = parseInt(numbers[i]);
if (isEven) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0;
}
function validateExpiryDate(date: string): boolean {
const [month, year] = date.split('/');
const currentDate = new Date();
const currentYear = currentDate.getFullYear() % 100;
const currentMonth = currentDate.getMonth() + 1;
const monthNum = parseInt(month);
const yearNum = parseInt(year);
if (monthNum < 1 || monthNum > 12) return false;
if (yearNum < currentYear) return false;
if (yearNum === currentYear && monthNum < currentMonth) return false;
return true;
}
// 📁 src/server/trpc/routers/upload.ts
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { router, protectedProcedure } from '../trpc';
import { fileUploadSchema } from '../validations/advanced';
import { uploadToS3, deleteFromS3 } from '@/lib/aws-s3';
import { scanForVirus } from '@/lib/virus-scanner';
export const uploadRouter = router({
// 📤 Upload de arquivo único
uploadFile: protectedProcedure
.input(fileUploadSchema.extend({
folder: z.enum(['products', 'avatars', 'documents', 'imports']),
isPublic: z.boolean().default(false),
}))
.mutation(async ({ input, ctx }) => {
const { filename, mimeType, size, base64, folder, isPublic } = input;
const userId = ctx.session.user.id;
try {
// 🔍 Verificar quota do usuário
const userQuota = await ctx.prisma.userQuota.findUnique({
where: { userId },
});
if (userQuota && userQuota.storageUsed + size > userQuota.storageLimit) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Quota de armazenamento excedida',
});
}
// 🦠 Verificar vírus no arquivo
const buffer = Buffer.from(base64, 'base64');
const isClean = await scanForVirus(buffer);
if (!isClean) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Arquivo contém vírus ou malware',
});
}
// 📊 Gerar nome único para o arquivo
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const extension = filename.split('.').pop();
const uniqueFilename = `${timestamp}-${randomString}.${extension}`;
// 🗂️ Definir caminho no S3
const s3Key = `${folder}/${userId}/${uniqueFilename}`;
// ☁️ Upload para S3
const uploadResult = await uploadToS3({
key: s3Key,
buffer,
contentType: mimeType,
isPublic,
});
// 💾 Salvar no banco de dados
const fileRecord = await ctx.prisma.file.create({
data: {
userId,
originalName: filename,
filename: uniqueFilename,
mimeType,
size,
s3Key,
s3Url: uploadResult.url,
folder,
isPublic,
metadata: {
uploadedAt: new Date(),
userAgent: ctx.req.headers['user-agent'],
ip: ctx.ip,
},
},
});
// 📊 Atualizar quota do usuário
if (userQuota) {
await ctx.prisma.userQuota.update({
where: { userId },
data: {
storageUsed: {
increment: size,
},
},
});
}
// 📝 Log de auditoria
await ctx.prisma.auditLog.create({
data: {
userId,
action: 'FILE_UPLOADED',
resource: 'FILE',
resourceId: fileRecord.id,
metadata: {
filename: filename,
size: size,
mimeType: mimeType,
folder: folder,
},
},
});
return {
id: fileRecord.id,
url: uploadResult.url,
filename: uniqueFilename,
originalName: filename,
size,
mimeType,
};
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Erro ao fazer upload do arquivo',
});
}
}),
// 📤 Upload múltiplo de arquivos
uploadMultiple: protectedProcedure
.input(z.object({
files: z.array(fileUploadSchema).min(1).max(10),
folder: z.enum(['products', 'avatars', 'documents', 'imports']),
isPublic: z.boolean().default(false),
}))
.mutation(async ({ input, ctx }) => {
const { files, folder, isPublic } = input;
const userId = ctx.session.user.id;
// 📊 Verificar quota total
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const userQuota = await ctx.prisma.userQuota.findUnique({
where: { userId },
});
if (userQuota && userQuota.storageUsed + totalSize > userQuota.storageLimit) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Quota de armazenamento excedida',
});
}
// 🔄 Processar uploads em paralelo (máximo 3 simultâneos)
const results = [];
const chunkSize = 3;
for (let i = 0; i < files.length; i += chunkSize) {
const chunk = files.slice(i, i + chunkSize);
const chunkResults = await Promise.all(
chunk.map(async (file) => {
// 🦠 Verificar vírus
const buffer = Buffer.from(file.base64, 'base64');
const isClean = await scanForVirus(buffer);
if (!isClean) {
return {
filename: file.filename,
error: 'Arquivo contém vírus ou malware',
};
}
try {
// 📊 Gerar nome único
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const extension = file.filename.split('.').pop();
const uniqueFilename = `${timestamp}-${randomString}.${extension}`;
const s3Key = `${folder}/${userId}/${uniqueFilename}`;
// ☁️ Upload para S3
const uploadResult = await uploadToS3({
key: s3Key,
buffer,
contentType: file.mimeType,
isPublic,
});
// 💾 Salvar no banco
const fileRecord = await ctx.prisma.file.create({
data: {
userId,
originalName: file.filename,
filename: uniqueFilename,
mimeType: file.mimeType,
size: file.size,
s3Key,
s3Url: uploadResult.url,
folder,
isPublic,
metadata: {
uploadedAt: new Date(),
batchUpload: true,
},
},
});
return {
id: fileRecord.id,
url: uploadResult.url,
filename: uniqueFilename,
originalName: file.filename,
size: file.size,
mimeType: file.mimeType,
};
} catch (error) {
return {
filename: file.filename,
error: 'Erro ao fazer upload',
};
}
})
);
results.push(...chunkResults);
}
// 📊 Atualizar quota apenas para uploads bem-sucedidos
const successfulUploads = results.filter(r => !r.error);
const successfulSize = successfulUploads.reduce((sum, r) => sum + (r.size || 0), 0);
if (successfulSize > 0 && userQuota) {
await ctx.prisma.userQuota.update({
where: { userId },
data: {
storageUsed: {
increment: successfulSize,
},
},
});
}
return {
successful: successfulUploads.length,
failed: results.filter(r => r.error).length,
results,
};
}),
// 🗑️ Deletar arquivo
deleteFile: protectedProcedure
.input(z.object({
fileId: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const { fileId } = input;
const userId = ctx.session.user.id;
const file = await ctx.prisma.file.findUnique({
where: { id: fileId },
});
if (!file) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Arquivo não encontrado',
});
}
// 🔒 Verificar permissão
if (file.userId !== userId) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Você não tem permissão para deletar este arquivo',
});
}
try {
// ☁️ Deletar do S3
await deleteFromS3(file.s3Key);
// 💾 Deletar do banco
await ctx.prisma.file.delete({
where: { id: fileId },
});
// 📊 Atualizar quota do usuário
await ctx.prisma.userQuota.update({
where: { userId },
data: {
storageUsed: {
decrement: file.size,
},
},
});
// 📝 Log de auditoria
await ctx.prisma.auditLog.create({
data: {
userId,
action: 'FILE_DELETED',
resource: 'FILE',
resourceId: fileId,
metadata: {
filename: file.originalName,
size: file.size,
},
},
});
return { success: true };
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Erro ao deletar arquivo',
});
}
}),
// 📋 Listar arquivos do usuário
getFiles: protectedProcedure
.input(z.object({
folder: z.enum(['products', 'avatars', 'documents', 'imports']).optional(),
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(),
}))
.query(async ({ input, ctx }) => {
const { folder, limit, cursor } = input;
const userId = ctx.session.user.id;
const files = await ctx.prisma.file.findMany({
where: {
userId,
...(folder && { folder }),
},
take: limit + 1,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: 'desc' },
select: {
id: true,
originalName: true,
filename: true,
mimeType: true,
size: true,
s3Url: true,
folder: true,
isPublic: true,
createdAt: true,
},
});
let nextCursor: string | undefined;
if (files.length > limit) {
const nextItem = files.pop();
nextCursor = nextItem!.id;
}
return {
files,
nextCursor,
hasMore: !!nextCursor,
};
}),
});
// 📁 src/server/trpc/routers/batch.ts
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { router, protectedProcedure } from '../trpc';
import { batchOperationSchema } from '../validations/advanced';
export const batchRouter = router({
// 📦 Operação em lote para produtos
batchProducts: protectedProcedure
.input(batchOperationSchema.extend({
resourceType: z.literal('product'),
}))
.mutation(async ({ input, ctx }) => {
const { operation, items, options = {} } = input;
const userId = ctx.session.user.id;
// 🔒 Verificar permissões
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!user || user.role !== 'ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Apenas administradores podem executar operações em lote',
});
}
const results = {
successful: 0,
failed: 0,
errors: [] as Array<{ index: number; error: string }>,
items: [] as Array<{ index: number; id?: string; data?: any }>,
};
// 🎯 Validar dados antes de processar
if (!options.skipValidation) {
const validationErrors = await validateBatchItems(items, operation);
if (validationErrors.length > 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Erros de validação encontrados',
cause: validationErrors,
});
}
}
// 🔄 Modo dry run (apenas simular)
if (options.dryRun) {
return {
...results,
message: 'Simulação executada com sucesso',
wouldProcess: items.length,
};
}
// 📊 Processar em chunks para evitar timeout
const chunkSize = 10;
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
await Promise.all(
chunk.map(async (item, chunkIndex) => {
const globalIndex = i + chunkIndex;
try {
let result;
switch (operation) {
case 'CREATE':
result = await ctx.prisma.product.create({
data: {
...item.data,
userId,
createdAt: new Date(),
},
});
break;
case 'UPDATE':
if (!item.id) {
throw new Error('ID é obrigatório para operação UPDATE');
}
result = await ctx.prisma.product.update({
where: { id: item.id },
data: {
...item.data,
updatedAt: new Date(),
},
});
break;
case 'DELETE':
if (!item.id) {
throw new Error('ID é obrigatório para operação DELETE');
}
await ctx.prisma.product.delete({
where: { id: item.id },
});
result = { id: item.id, deleted: true };
break;
default:
throw new Error(`Operação ${operation} não suportada`);
}
results.successful++;
results.items.push({
index: globalIndex,
id: result.id,
data: result,
});
} catch (error) {
results.failed++;
results.errors.push({
index: globalIndex,
error: error instanceof Error ? error.message : 'Erro desconhecido',
});
// 🚫 Parar se não deve continuar em erro
if (!options.continueOnError) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Erro no item ${globalIndex}: ${error instanceof Error ? error.message : 'Erro desconhecido'}`,
});
}
}
})
);
}
// 📝 Log de auditoria
await ctx.prisma.auditLog.create({
data: {
userId,
action: `BATCH_${operation}`,
resource: 'PRODUCT',
resourceId: 'batch-operation',
metadata: {
itemsProcessed: items.length,
successful: results.successful,
failed: results.failed,
operation,
},
},
});
return results;
}),
// 📊 Importar dados via CSV
importFromCSV: protectedProcedure
.input(z.object({
fileId: z.string(),
resourceType: z.enum(['product', 'customer', 'order']),
mapping: z.record(z.string(), z.string()), // Campo CSV -> Campo DB
options: z.object({
skipFirstRow: z.boolean().default(true),
validateOnly: z.boolean().default(false),
chunkSize: z.number().min(1).max(100).default(50),
}).optional(),
}))
.mutation(async ({ input, ctx }) => {
const { fileId, resourceType, mapping, options = {} } = input;
const userId = ctx.session.user.id;
// 📁 Buscar arquivo
const file = await ctx.prisma.file.findUnique({
where: { id: fileId },
});
if (!file || file.userId !== userId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Arquivo não encontrado',
});
}
if (file.mimeType !== 'text/csv') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Arquivo deve ser CSV',
});
}
try {
// 📥 Baixar e processar CSV
const csvData = await downloadAndParseCSV(file.s3Url);
// 🔄 Aplicar mapeamento
const mappedData = csvData.map((row, index) => {
const mappedRow: any = {};
Object.entries(mapping).forEach(([csvColumn, dbField]) => {
if (row[csvColumn] !== undefined) {
mappedRow[dbField] = row[csvColumn];
}
});
return {
index: index + (options.skipFirstRow ? 2 : 1), // +1 para linha de cabeçalho
data: mappedRow,
};
});
// ✅ Apenas validar se solicitado
if (options.validateOnly) {
const validationErrors = await validateImportData(mappedData, resourceType);
return {
valid: validationErrors.length === 0,
totalRows: mappedData.length,
errors: validationErrors,
preview: mappedData.slice(0, 5), // Mostrar 5 primeiros
};
}
// 📊 Processar importação
const results = {
successful: 0,
failed: 0,
errors: [] as Array<{ row: number; error: string }>,
};
const chunkSize = options.chunkSize || 50;
for (let i = 0; i < mappedData.length; i += chunkSize) {
const chunk = mappedData.slice(i, i + chunkSize);
await Promise.all(
chunk.map(async (item) => {
try {
switch (resourceType) {
case 'product':
await ctx.prisma.product.create({
data: {
...item.data,
userId,
},
});
break;
case 'customer':
await ctx.prisma.customer.create({
data: {
...item.data,
userId,
},
});
break;
case 'order':
await ctx.prisma.order.create({
data: {
...item.data,
userId,
},
});
break;
}
results.successful++;
} catch (error) {
results.failed++;
results.errors.push({
row: item.index,
error: error instanceof Error ? error.message : 'Erro desconhecido',
});
}
})
);
}
// 📝 Log de auditoria
await ctx.prisma.auditLog.create({
data: {
userId,
action: 'CSV_IMPORT',
resource: resourceType.toUpperCase(),
resourceId: fileId,
metadata: {
totalRows: mappedData.length,
successful: results.successful,
failed: results.failed,
filename: file.originalName,
},
},
});
return results;
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Erro ao processar importação',
});
}
}),
// 📤 Exportar dados para CSV
exportToCSV: protectedProcedure
.input(z.object({
resourceType: z.enum(['product', 'customer', 'order']),
filters: z.record(z.string(), z.any()).optional(),
columns: z.array(z.string()).optional(),
limit: z.number().min(1).max(10000).default(1000),
}))
.mutation(async ({ input, ctx }) => {
const { resourceType, filters = {}, columns, limit } = input;
const userId = ctx.session.user.id;
try {
let data;
switch (resourceType) {
case 'product':
data = await ctx.prisma.product.findMany({
where: {
userId,
...filters,
},
select: columns ?
Object.fromEntries(columns.map(col => [col, true])) :
undefined,
take: limit,
orderBy: { createdAt: 'desc' },
});
break;
case 'customer':
data = await ctx.prisma.customer.findMany({
where: {
userId,
...filters,
},
select: columns ?
Object.fromEntries(columns.map(col => [col, true])) :
undefined,
take: limit,
orderBy: { createdAt: 'desc' },
});
break;
case 'order':
data = await ctx.prisma.order.findMany({
where: {
userId,
...filters,
},
select: columns ?
Object.fromEntries(columns.map(col => [col, true])) :
undefined,
take: limit,
orderBy: { createdAt: 'desc' },
});
break;
}
// 📊 Converter para CSV
const csvContent = convertToCSV(data);
// 📁 Salvar arquivo CSV
const filename = `export-${resourceType}-${Date.now()}.csv`;
const s3Key = `exports/${userId}/${filename}`;
const uploadResult = await uploadToS3({
key: s3Key,
buffer: Buffer.from(csvContent, 'utf8'),
contentType: 'text/csv',
isPublic: false,
});
// 💾 Salvar referência do arquivo
const fileRecord = await ctx.prisma.file.create({
data: {
userId,
originalName: filename,
filename,
mimeType: 'text/csv',
size: Buffer.from(csvContent, 'utf8').length,
s3Key,
s3Url: uploadResult.url,
folder: 'exports',
isPublic: false,
metadata: {
exportType: resourceType,
recordCount: data.length,
filters,
},
},
});
return {
fileId: fileRecord.id,
filename,
url: uploadResult.url,
recordCount: data.length,
};
} catch (error) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Erro ao exportar dados',
});
}
}),
});
// 🔧 Funções auxiliares
async function validateBatchItems(items: any[], operation: string): Promise<string[]> {
const errors: string[] = [];
items.forEach((item, index) => {
if (operation !== 'CREATE' && !item.id) {
errors.push(`Item ${index}: ID é obrigatório para operação ${operation}`);
}
if (!item.data || Object.keys(item.data).length === 0) {
errors.push(`Item ${index}: dados são obrigatórios`);
}
});
return errors;
}
async function downloadAndParseCSV(url: string): Promise<any[]> {
// Implementar download e parsing do CSV
// Usar biblioteca como papaparse ou csv-parser
return [];
}
async function validateImportData(data: any[], resourceType: string): Promise<any[]> {
// Implementar validação específica por tipo de recurso
return [];
}
function convertToCSV(data: any[]): string {
if (data.length === 0) return '';
const headers = Object.keys(data[0]);
const csvRows = [headers.join(',')];
data.forEach(row => {
const values = headers.map(header => {
const value = row[header];
return typeof value === 'string' ? `"${value.replace(/"/g, '""')}"` : value;
});
csvRows.push(values.join(','));
});
return csvRows.join('\n');
}
Transações Atômicas:Use $transaction para operações que afetam múltiplas tabelas.
Validação Robusta:Sempre valide dados no servidor, nunca confie apenas no cliente.
Tratamento de Erros:Forneça mensagens claras e específicas para cada tipo de erro.
Auditoria:Registre todas as operações importantes para compliance.