Proyecto E-commerce - Parte 3
Finalizamos con el módulo de pedidos y checkout.
Schema de Pedidos
prisma
model Order { id String @id @default(uuid()) user User @relation(fields: [userId], references: [id]) userId String items OrderItem[] address Address @relation(fields: [addressId], references: [id]) addressId String subtotal Decimal @db.Decimal(10, 2) shipping Decimal @db.Decimal(10, 2) total Decimal @db.Decimal(10, 2) status OrderStatus @default(PENDING) paymentId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId]) @@index([status])} model OrderItem { id String @id @default(uuid()) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) orderId String product Product @relation(fields: [productId], references: [id]) productId String name String price Decimal @db.Decimal(10, 2) quantity Int @@unique([orderId, productId])} model Address { id String @id @default(uuid()) user User @relation(fields: [userId], references: [id]) userId String name String street String city String state String zipCode String country String phone String? orders Order[]} enum OrderStatus { PENDING PAID PROCESSING SHIPPED DELIVERED CANCELLED}Order Service
typescript
// src/services/order.service.tsimport { prisma } from '../config/database';import { AppError } from '../errors/AppError';import { mailService } from './mail.service'; interface CreateOrderDTO { items: { productId: string; quantity: number }[]; addressId: string;} export class OrderService { async create(userId: string, data: CreateOrderDTO) { return prisma.$transaction(async (tx) => { // Verificar stock y calcular precios const orderItems = []; let subtotal = 0; for (const item of data.items) { const product = await tx.product.findUnique({ where: { id: item.productId }, }); if (!product) { throw new AppError(`Producto no encontrado: ${item.productId}`, 404); } if (product.stock < item.quantity) { throw new AppError(`Stock insuficiente: ${product.name}`, 400); } const itemTotal = Number(product.price) * item.quantity; subtotal += itemTotal; orderItems.push({ productId: product.id, name: product.name, price: product.price, quantity: item.quantity, }); // Reservar stock await tx.product.update({ where: { id: product.id }, data: { stock: { decrement: item.quantity } }, }); } // Calcular envío const shipping = subtotal >= 100 ? 0 : 9.99; const total = subtotal + shipping; // Crear pedido const order = await tx.order.create({ data: { userId, addressId: data.addressId, subtotal, shipping, total, items: { create: orderItems }, }, include: { items: { include: { product: true } }, address: true, user: { select: { id: true, name: true, email: true } }, }, }); // Enviar email await mailService.sendOrderConfirmation(order); return order; }); } async findUserOrders(userId: string, page = 1, limit = 10) { const skip = (page - 1) * limit; const [orders, total] = await Promise.all([ prisma.order.findMany({ where: { userId }, skip, take: limit, orderBy: { createdAt: 'desc' }, include: { items: { include: { product: { select: { images: true } } } }, }, }), prisma.order.count({ where: { userId } }), ]); return { data: orders, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } }; } async findById(id: string, userId: string, isAdmin: boolean) { const order = await prisma.order.findUnique({ where: { id }, include: { items: { include: { product: true } }, address: true, user: { select: { id: true, name: true, email: true } }, }, }); if (!order) throw new AppError('Pedido no encontrado', 404); if (!isAdmin && order.userId !== userId) { throw new AppError('Acceso denegado', 403); } return order; } async updateStatus(id: string, status: OrderStatus) { const order = await prisma.order.update({ where: { id }, data: { status }, include: { user: true }, }); // Notificar al usuario if (status === 'SHIPPED') { await mailService.sendShippingNotification(order); } return order; } async cancel(id: string, userId: string) { const order = await this.findById(id, userId, false); if (order.status !== 'PENDING') { throw new AppError('Solo se pueden cancelar pedidos pendientes', 400); } return prisma.$transaction(async (tx) => { // Restaurar stock for (const item of order.items) { await tx.product.update({ where: { id: item.productId }, data: { stock: { increment: item.quantity } }, }); } // Cancelar pedido return tx.order.update({ where: { id }, data: { status: 'CANCELLED' }, }); }); }}Order Controller
typescript
// src/controllers/order.controller.tsexport class OrderController { async index(req: Request, res: Response) { const { page, limit } = req.query; const orders = await orderService.findUserOrders( req.user!.id, Number(page) || 1, Number(limit) || 10 ); return res.json(orders); } async show(req: Request, res: Response) { const order = await orderService.findById( req.params.id, req.user!.id, req.user!.role === 'ADMIN' ); return res.json(order); } async store(req: Request, res: Response) { const order = await orderService.create(req.user!.id, req.body); return res.status(201).json(order); } async cancel(req: Request, res: Response) { const order = await orderService.cancel(req.params.id, req.user!.id); return res.json(order); } // Admin async updateStatus(req: Request, res: Response) { const order = await orderService.updateStatus(req.params.id, req.body.status); return res.json(order); } async adminIndex(req: Request, res: Response) { const { page, limit, status } = req.query; const orders = await orderService.findAll({ page: Number(page) || 1, limit: Number(limit) || 20, status: status as OrderStatus, }); return res.json(orders); }}Rutas de Pedidos
typescript
// src/routes/order.routes.tsconst router = Router(); router.use(authenticate); router.get('/', asyncHandler(controller.index));router.post('/', validate(createOrderSchema), asyncHandler(controller.store));router.get('/:id', asyncHandler(controller.show));router.post('/:id/cancel', asyncHandler(controller.cancel)); // Adminrouter.get('/admin/all', authorize('ADMIN'), asyncHandler(controller.adminIndex));router.patch('/:id/status', authorize('ADMIN'), asyncHandler(controller.updateStatus)); export { router as orderRoutes };Dashboard Admin
typescript
// src/services/dashboard.service.tsexport class DashboardService { async getStats() { const [totalOrders, totalRevenue, totalUsers, recentOrders] = await Promise.all([ prisma.order.count({ where: { status: { not: 'CANCELLED' } } }), prisma.order.aggregate({ where: { status: { in: ['PAID', 'SHIPPED', 'DELIVERED'] } }, _sum: { total: true }, }), prisma.user.count(), prisma.order.findMany({ take: 10, orderBy: { createdAt: 'desc' }, include: { user: { select: { name: true } } }, }), ]); return { totalOrders, totalRevenue: totalRevenue._sum.total || 0, totalUsers, recentOrders, }; }}🎉 Proyecto Completo
Tu API de e-commerce ahora tiene:
- ✅ Autenticación - Registro, login, JWT
- ✅ Usuarios - Perfil, direcciones
- ✅ Categorías - CRUD completo
- ✅ Productos - CRUD con filtros, búsqueda, cache
- ✅ Reviews - Sistema de valoraciones
- ✅ Pedidos - Checkout, historial, cancelación
- ✅ Admin - Dashboard, gestión de pedidos
- ✅ Seguridad - RBAC, rate limiting, validación
- ✅ Deploy - Docker, CI/CD
Próximos Pasos
- Integrar pasarela de pagos (Stripe)
- Implementar websockets para notificaciones
- Agregar sistema de cupones
- Optimizar con GraphQL
¡Felicidades por completar el curso! 🚀