├── tsconfig.json ├── .gitignore ├── src ├── routes │ ├── restaurants.ts │ ├── events.ts │ └── orders.ts ├── events │ ├── EventInitializer.ts │ ├── EventBus.ts │ └── types.ts ├── models │ └── index.ts ├── server.ts ├── handlers │ ├── DeliveryEventHandlers.ts │ └── OrderEventHandlers.ts ├── app.ts ├── services │ ├── PaymentService.ts │ ├── OrderService.ts │ └── DeliveryService.ts └── repositories │ └── InMemoryRepository.ts └── README.md /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "declaration": true, 14 | "declarationMap": true, 15 | "sourceMap": true, 16 | "experimentalDecorators": true, 17 | "emitDecoratorMetadata": true 18 | }, 19 | "include": ["src/**/*"], 20 | "exclude": ["node_modules", "dist"] 21 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | coverage 5 | .babelrc 6 | package-lock.json 7 | package.json 8 | yarn.lock 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | .env.staging.local 15 | .env.ci.local 16 | .env.ci 17 | .env.ci.local 18 | .env.ci 19 | .env.ci.local 20 | .env.ci 21 | .env.ci.local 22 | .env.ci 23 | .env.ci.local 24 | .env.ci 25 | .env.ci.local 26 | .env.ci 27 | .env.ci.local 28 | .env.ci 29 | .env.ci.local 30 | .env.ci 31 | .env.ci.local 32 | .env.ci 33 | .env.ci.local 34 | .env.ci 35 | .env.ci.local 36 | .env.ci 37 | .env.ci.local 38 | .env -------------------------------------------------------------------------------- /src/routes/restaurants.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { InMemoryRepository } from '../repositories/InMemoryRepository'; 3 | 4 | const router = Router(); 5 | const repository = InMemoryRepository.getInstance(); 6 | 7 | /** 8 | * GET /api/restaurants 9 | * Obtiene todos los restaurantes 10 | */ 11 | router.get('/', (req: Request, res: Response) => { 12 | try { 13 | const { active } = req.query; 14 | let restaurants = repository.getAllRestaurants(); 15 | 16 | if (active === 'true') { 17 | restaurants = repository.getActiveRestaurants(); 18 | } 19 | 20 | res.json({ 21 | success: true, 22 | data: restaurants, 23 | count: restaurants.length 24 | }); 25 | } catch (error) { 26 | res.status(500).json({ 27 | success: false, 28 | error: 'Error obteniendo restaurantes', 29 | message: error instanceof Error ? error.message : 'Error desconocido' 30 | }); 31 | } 32 | }); 33 | 34 | /** 35 | * GET /api/restaurants/:id 36 | * Obtiene un restaurante por ID 37 | */ 38 | router.get('/:id', (req: Request, res: Response) => { 39 | try { 40 | const { id } = req.params; 41 | const restaurant = repository.getRestaurant(id); 42 | 43 | if (!restaurant) { 44 | return res.status(404).json({ 45 | success: false, 46 | error: 'Restaurante no encontrado' 47 | }); 48 | } 49 | 50 | res.json({ 51 | success: true, 52 | data: restaurant 53 | }); 54 | } catch (error) { 55 | res.status(500).json({ 56 | success: false, 57 | error: 'Error obteniendo restaurante', 58 | message: error instanceof Error ? error.message : 'Error desconocido' 59 | }); 60 | } 61 | }); 62 | 63 | /** 64 | * GET /api/restaurants/:id/menu 65 | * Obtiene el menú de un restaurante 66 | */ 67 | router.get('/:id/menu', (req: Request, res: Response) => { 68 | try { 69 | const { id } = req.params; 70 | const restaurant = repository.getRestaurant(id); 71 | 72 | if (!restaurant) { 73 | return res.status(404).json({ 74 | success: false, 75 | error: 'Restaurante no encontrado' 76 | }); 77 | } 78 | 79 | // Filtrar solo items disponibles 80 | const availableItems = restaurant.menu.filter(item => item.isAvailable); 81 | 82 | res.json({ 83 | success: true, 84 | data: { 85 | restaurantId: restaurant.id, 86 | restaurantName: restaurant.name, 87 | menu: availableItems 88 | } 89 | }); 90 | } catch (error) { 91 | res.status(500).json({ 92 | success: false, 93 | error: 'Error obteniendo menú', 94 | message: error instanceof Error ? error.message : 'Error desconocido' 95 | }); 96 | } 97 | }); 98 | 99 | export default router; -------------------------------------------------------------------------------- /src/events/EventInitializer.ts: -------------------------------------------------------------------------------- 1 | import { EventBus } from './EventBus'; 2 | import { OrderEventHandlers } from '../handlers/OrderEventHandlers'; 3 | import { DeliveryEventHandlers } from '../handlers/DeliveryEventHandlers'; 4 | 5 | export class EventInitializer { 6 | private eventBus: EventBus; 7 | private orderHandlers: OrderEventHandlers; 8 | private deliveryHandlers: DeliveryEventHandlers; 9 | 10 | constructor() { 11 | this.eventBus = EventBus.getInstance(); 12 | this.orderHandlers = new OrderEventHandlers(); 13 | this.deliveryHandlers = new DeliveryEventHandlers(); 14 | } 15 | 16 | /** 17 | * Inicializa todas las suscripciones de eventos 18 | */ 19 | public initialize(): void { 20 | console.log('🚀 Inicializando Event Bus y handlers...'); 21 | 22 | // Suscribir handlers de pedidos 23 | this.eventBus.subscribe('ORDER_CREATED', this.orderHandlers.handleOrderCreated.bind(this.orderHandlers)); 24 | this.eventBus.subscribe('PAYMENT_PROCESSED', this.orderHandlers.handlePaymentProcessed.bind(this.orderHandlers)); 25 | this.eventBus.subscribe('PAYMENT_FAILED', this.orderHandlers.handlePaymentFailed.bind(this.orderHandlers)); 26 | this.eventBus.subscribe('ORDER_READY', this.orderHandlers.handleOrderReady.bind(this.orderHandlers)); 27 | this.eventBus.subscribe('ORDER_DELIVERED', this.orderHandlers.handleOrderDelivered.bind(this.orderHandlers)); 28 | this.eventBus.subscribe('PAYMENT_REFUNDED', this.orderHandlers.handlePaymentRefunded.bind(this.orderHandlers)); 29 | 30 | // Suscribir handlers de entrega 31 | this.eventBus.subscribe('DRIVER_ASSIGNED', this.deliveryHandlers.handleDriverAssigned.bind(this.deliveryHandlers)); 32 | this.eventBus.subscribe('ORDER_PICKED_UP', this.deliveryHandlers.handleOrderPickedUp.bind(this.deliveryHandlers)); 33 | this.eventBus.subscribe('ORDER_IN_TRANSIT', this.deliveryHandlers.handleOrderInTransit.bind(this.deliveryHandlers)); 34 | this.eventBus.subscribe('DELIVERY_FAILED', this.deliveryHandlers.handleDeliveryFailed.bind(this.deliveryHandlers)); 35 | 36 | console.log('✅ Event Bus inicializado con todos los handlers'); 37 | console.log('📊 Handlers configurados:'); 38 | console.log(' - ORDER_CREATED → Iniciar pago'); 39 | console.log(' - PAYMENT_PROCESSED → Confirmar pedido'); 40 | console.log(' - PAYMENT_FAILED → Cancelar pedido'); 41 | console.log(' - ORDER_READY → Crear entrega'); 42 | console.log(' - DRIVER_ASSIGNED → Simular recogida'); 43 | console.log(' - ORDER_PICKED_UP → Actualizar estado y marcar en tránsito'); 44 | console.log(' - ORDER_IN_TRANSIT → Simular entrega'); 45 | console.log(' - ORDER_DELIVERED → Marcar pedido completado'); 46 | console.log(' - DELIVERY_FAILED → Cancelar pedido'); 47 | console.log(' - PAYMENT_REFUNDED → Cancelar pedido si es necesario'); 48 | } 49 | 50 | /** 51 | * Obtiene el EventBus para uso externo 52 | */ 53 | public getEventBus(): EventBus { 54 | return this.eventBus; 55 | } 56 | } -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | //ESTO OBVIAMENTE ES UNA PRUEBA/ SE PUEDE MEJORAR LA MODULARIZACION 2 | // Enums para estados 3 | export enum OrderStatus { 4 | PENDING = 'pending', 5 | CONFIRMED = 'confirmed', 6 | PREPARING = 'preparing', 7 | READY = 'ready', 8 | PICKED_UP = 'picked_up', 9 | DELIVERED = 'delivered', 10 | CANCELLED = 'cancelled' 11 | } 12 | 13 | export enum PaymentStatus { 14 | PENDING = 'pending', 15 | PROCESSING = 'processing', 16 | COMPLETED = 'completed', 17 | FAILED = 'failed', 18 | REFUNDED = 'refunded' 19 | } 20 | 21 | export enum DeliveryStatus { 22 | PENDING = 'pending', 23 | ASSIGNED = 'assigned', 24 | PICKED_UP = 'picked_up', 25 | IN_TRANSIT = 'in_transit', 26 | DELIVERED = 'delivered', 27 | FAILED = 'failed' 28 | } 29 | 30 | // Interfaces base 31 | export interface User { 32 | id: string; 33 | name: string; 34 | email: string; 35 | phone: string; 36 | address: Address; 37 | createdAt: Date; 38 | } 39 | 40 | export interface Address { 41 | street: string; 42 | city: string; 43 | state: string; 44 | zipCode: string; 45 | coordinates?: { 46 | lat: number; 47 | lng: number; 48 | }; 49 | } 50 | 51 | export interface Restaurant { 52 | id: string; 53 | name: string; 54 | description: string; 55 | address: Address; 56 | phone: string; 57 | rating: number; 58 | isActive: boolean; 59 | menu: MenuItem[]; 60 | createdAt: Date; 61 | } 62 | 63 | export interface MenuItem { 64 | id: string; 65 | name: string; 66 | description: string; 67 | price: number; 68 | category: string; 69 | isAvailable: boolean; 70 | preparationTime: number; // en minutos 71 | } 72 | 73 | export interface Order { 74 | id: string; 75 | userId: string; 76 | restaurantId: string; 77 | items: OrderItem[]; 78 | status: OrderStatus; 79 | totalAmount: number; 80 | deliveryAddress: Address; 81 | paymentId?: string; 82 | deliveryId?: string; 83 | estimatedDeliveryTime?: Date; 84 | createdAt: Date; 85 | updatedAt: Date; 86 | } 87 | 88 | export interface OrderItem { 89 | menuItemId: string; 90 | quantity: number; 91 | price: number; 92 | specialInstructions?: string; 93 | } 94 | 95 | export interface Payment { 96 | id: string; 97 | orderId: string; 98 | amount: number; 99 | status: PaymentStatus; 100 | method: string; 101 | transactionId?: string; 102 | createdAt: Date; 103 | updatedAt: Date; 104 | } 105 | 106 | export interface Delivery { 107 | id: string; 108 | orderId: string; 109 | driverId?: string; 110 | status: DeliveryStatus; 111 | pickupAddress: Address; 112 | deliveryAddress: Address; 113 | estimatedTime?: number; // en minutos 114 | actualDeliveryTime?: Date; 115 | createdAt: Date; 116 | updatedAt: Date; 117 | } 118 | 119 | export interface Driver { 120 | id: string; 121 | name: string; 122 | email: string; 123 | phone: string; 124 | vehicleType: string; 125 | rating: number; 126 | isAvailable: boolean; 127 | currentLocation?: { 128 | lat: number; 129 | lng: number; 130 | }; 131 | createdAt: Date; 132 | } -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { App } from './app'; 2 | 3 | const PORT = process.env.PORT || 3000; 4 | const HOST = process.env.HOST || 'localhost'; 5 | 6 | // Crear y configurar la aplicación 7 | const appInstance = new App(); 8 | const app = appInstance.getApp(); 9 | 10 | // Iniciar el servidor 11 | const server = app.listen(PORT, () => { 12 | console.log('🚀 ========================================'); 13 | console.log('🍕 Sistema de Pedidos de Comida Online'); 14 | console.log('⚡ Event-Driven Architecture con TypeScript'); 15 | console.log('🚀 ========================================'); 16 | console.log(`🌐 Servidor ejecutándose en: http://${HOST}:${PORT}`); 17 | console.log(`📊 Dashboard: http://${HOST}:${PORT}/api/events/dashboard`); 18 | console.log(`🏥 Health Check: http://${HOST}:${PORT}/health`); 19 | console.log('🚀 ========================================'); 20 | console.log('📋 Endpoints disponibles:'); 21 | console.log(' GET / - Información de la API'); 22 | console.log(' GET /health - Health check'); 23 | console.log(' GET /api/orders - Listar pedidos'); 24 | console.log(' POST /api/orders - Crear pedido'); 25 | console.log(' GET /api/orders/:id - Obtener pedido'); 26 | console.log(' GET /api/orders/user/:userId - Pedidos de usuario'); 27 | console.log(' POST /api/orders/:id/cancel - Cancelar pedido'); 28 | console.log(' GET /api/restaurants - Listar restaurantes'); 29 | console.log(' GET /api/restaurants/:id/menu - Obtener menú'); 30 | console.log(' GET /api/events/history - Historial de eventos'); 31 | console.log(' GET /api/events/dashboard - Dashboard del sistema'); 32 | console.log('🚀 ========================================'); 33 | console.log(''); 34 | console.log('💡 Ejemplo de pedido:'); 35 | console.log(`curl -X POST http://${HOST}:${PORT}/api/orders \\`); 36 | console.log(' -H "Content-Type: application/json" \\'); 37 | console.log(' -d \'{'); 38 | console.log(' "userId": "user-1",'); 39 | console.log(' "restaurantId": "restaurant-1",'); 40 | console.log(' "items": ['); 41 | console.log(' {"menuItemId": "item-1", "quantity": 2, "price": 12.50}'); 42 | console.log(' ],'); 43 | console.log(' "deliveryAddress": {'); 44 | console.log(' "street": "Calle Test 123",'); 45 | console.log(' "city": "Madrid",'); 46 | console.log(' "state": "Madrid",'); 47 | console.log(' "zipCode": "28001"'); 48 | console.log(' }'); 49 | console.log(' }\''); 50 | console.log(''); 51 | }); 52 | 53 | // Manejo graceful de cierre del servidor 54 | process.on('SIGTERM', () => { 55 | console.log('🔄 SIGTERM recibido, cerrando servidor...'); 56 | server.close(() => { 57 | console.log('✅ Servidor cerrado correctamente'); 58 | process.exit(0); 59 | }); 60 | }); 61 | 62 | process.on('SIGINT', () => { 63 | console.log('\n🔄 SIGINT recibido, cerrando servidor...'); 64 | server.close(() => { 65 | console.log('✅ Servidor cerrado correctamente'); 66 | process.exit(0); 67 | }); 68 | }); 69 | 70 | export default server; -------------------------------------------------------------------------------- /src/handlers/DeliveryEventHandlers.ts: -------------------------------------------------------------------------------- 1 | import { DeliveryService } from '../services/DeliveryService'; 2 | import { OrderService } from '../services/OrderService'; 3 | import { OrderStatus } from '../models'; 4 | import { 5 | DriverAssignedEvent, 6 | OrderPickedUpEvent, 7 | OrderInTransitEvent, 8 | DeliveryFailedEvent 9 | } from '../events/types'; 10 | 11 | export class DeliveryEventHandlers { 12 | private deliveryService: DeliveryService; 13 | private orderService: OrderService; 14 | 15 | constructor() { 16 | this.deliveryService = new DeliveryService(); 17 | this.orderService = new OrderService(); 18 | } 19 | 20 | /** 21 | * Maneja cuando se asigna un repartidor 22 | */ 23 | public async handleDriverAssigned(event: DriverAssignedEvent): Promise { 24 | const { deliveryId, driverId, orderId } = event.payload; 25 | 26 | console.log(`🔄 Repartidor asignado - Delivery: ${deliveryId}, Driver: ${driverId}`); 27 | 28 | // Simular tiempo para que el repartidor llegue al restaurante 29 | setTimeout(async () => { 30 | try { 31 | await this.deliveryService.markOrderPickedUp(deliveryId); 32 | } catch (error) { 33 | console.error(`❌ Error marcando pedido como recogido ${deliveryId}:`, error); 34 | } 35 | }, 5000); // 5 segundos para llegar al restaurante 36 | } 37 | 38 | /** 39 | * Maneja cuando un pedido es recogido por el repartidor 40 | */ 41 | public async handleOrderPickedUp(event: OrderPickedUpEvent): Promise { 42 | const { deliveryId, orderId } = event.payload; 43 | 44 | console.log(`🔄 Pedido recogido - Delivery: ${deliveryId}, Order: ${orderId}`); 45 | 46 | try { 47 | // Actualizar estado del pedido 48 | await this.orderService.updateOrderStatus(orderId, OrderStatus.PICKED_UP); 49 | 50 | // Marcar como en tránsito después de un momento 51 | setTimeout(async () => { 52 | try { 53 | await this.deliveryService.markOrderInTransit(deliveryId); 54 | } catch (error) { 55 | console.error(`❌ Error marcando pedido en tránsito ${deliveryId}:`, error); 56 | } 57 | }, 2000); // 2 segundos para salir del restaurante 58 | 59 | } catch (error) { 60 | console.error(`❌ Error actualizando estado del pedido ${orderId}:`, error); 61 | } 62 | } 63 | 64 | /** 65 | * Maneja cuando un pedido está en tránsito 66 | */ 67 | public async handleOrderInTransit(event: OrderInTransitEvent): Promise { 68 | const { deliveryId, orderId } = event.payload; 69 | 70 | console.log(`🔄 Pedido en tránsito - Delivery: ${deliveryId}, Order: ${orderId}`); 71 | 72 | // Simular tiempo de entrega 73 | setTimeout(async () => { 74 | try { 75 | // Simular posibilidad de fallo en la entrega (5% chance) 76 | const deliveryFails = Math.random() < 0.05; 77 | 78 | if (deliveryFails) { 79 | await this.deliveryService.markDeliveryFailed( 80 | deliveryId, 81 | 'Cliente no disponible en la dirección' 82 | ); 83 | } else { 84 | await this.deliveryService.markOrderDelivered(deliveryId); 85 | } 86 | } catch (error) { 87 | console.error(`❌ Error completando entrega ${deliveryId}:`, error); 88 | } 89 | }, 8000); // 8 segundos para llegar al destino 90 | } 91 | 92 | /** 93 | * Maneja cuando una entrega falla 94 | */ 95 | public async handleDeliveryFailed(event: DeliveryFailedEvent): Promise { 96 | const { deliveryId, orderId, reason } = event.payload; 97 | 98 | console.log(`🔄 Entrega fallida - Delivery: ${deliveryId}, Reason: ${reason}`); 99 | 100 | try { 101 | // Cancelar el pedido 102 | await this.orderService.cancelOrder( 103 | orderId, 104 | `Entrega fallida: ${reason}`, 105 | 'system' 106 | ); 107 | 108 | console.log(`❌ Pedido ${orderId} cancelado debido a fallo en entrega`); 109 | } catch (error) { 110 | console.error(`❌ Error cancelando pedido ${orderId} por fallo en entrega:`, error); 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /src/routes/events.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { EventBus } from '../events/EventBus'; 3 | import { InMemoryRepository } from '../repositories/InMemoryRepository'; 4 | import { OrderService } from '../services/OrderService'; 5 | import { PaymentService } from '../services/PaymentService'; 6 | import { DeliveryService } from '../services/DeliveryService'; 7 | 8 | const router = Router(); 9 | const eventBus = EventBus.getInstance(); 10 | const repository = InMemoryRepository.getInstance(); 11 | const orderService = new OrderService(); 12 | const paymentService = new PaymentService(); 13 | const deliveryService = new DeliveryService(); 14 | 15 | /** 16 | * GET /api/events/history 17 | * Obtiene el historial de eventos 18 | */ 19 | router.get('/history', (req: Request, res: Response) => { 20 | try { 21 | const events = eventBus.getEventHistory(); 22 | 23 | res.json({ 24 | success: true, 25 | data: events, 26 | count: events.length 27 | }); 28 | } catch (error) { 29 | res.status(500).json({ 30 | success: false, 31 | error: 'Error obteniendo historial de eventos', 32 | message: error instanceof Error ? error.message : 'Error desconocido' 33 | }); 34 | } 35 | }); 36 | 37 | /** 38 | * GET /api/events/stats 39 | * Obtiene estadísticas de eventos 40 | */ 41 | router.get('/stats', (req: Request, res: Response) => { 42 | try { 43 | const eventStats = eventBus.getStats(); 44 | 45 | res.json({ 46 | success: true, 47 | data: eventStats 48 | }); 49 | } catch (error) { 50 | res.status(500).json({ 51 | success: false, 52 | error: 'Error obteniendo estadísticas de eventos', 53 | message: error instanceof Error ? error.message : 'Error desconocido' 54 | }); 55 | } 56 | }); 57 | 58 | /** 59 | * GET /api/events/type/:eventType 60 | * Obtiene eventos de un tipo específico 61 | */ 62 | router.get('/type/:eventType', (req: Request, res: Response) => { 63 | try { 64 | const { eventType } = req.params; 65 | const events = eventBus.getEventsByType(eventType as any); 66 | 67 | res.json({ 68 | success: true, 69 | data: events, 70 | count: events.length 71 | }); 72 | } catch (error) { 73 | res.status(500).json({ 74 | success: false, 75 | error: 'Error obteniendo eventos por tipo', 76 | message: error instanceof Error ? error.message : 'Error desconocido' 77 | }); 78 | } 79 | }); 80 | 81 | /** 82 | * GET /api/events/dashboard 83 | * Obtiene un dashboard con estadísticas generales del sistema 84 | */ 85 | router.get('/dashboard', (req: Request, res: Response) => { 86 | try { 87 | const eventStats = eventBus.getStats(); 88 | const orderStats = orderService.getOrderStats(); 89 | const paymentStats = paymentService.getPaymentStats(); 90 | const deliveryStats = deliveryService.getDeliveryStats(); 91 | const repoStats = repository.getStats(); 92 | 93 | res.json({ 94 | success: true, 95 | data: { 96 | system: { 97 | uptime: process.uptime(), 98 | timestamp: new Date() 99 | }, 100 | events: eventStats, 101 | orders: orderStats, 102 | payments: paymentStats, 103 | deliveries: deliveryStats, 104 | database: repoStats 105 | } 106 | }); 107 | } catch (error) { 108 | res.status(500).json({ 109 | success: false, 110 | error: 'Error obteniendo dashboard', 111 | message: error instanceof Error ? error.message : 'Error desconocido' 112 | }); 113 | } 114 | }); 115 | 116 | /** 117 | * DELETE /api/events/history 118 | * Limpia el historial de eventos (útil para testing) 119 | */ 120 | router.delete('/history', (req: Request, res: Response) => { 121 | try { 122 | eventBus.clearHistory(); 123 | 124 | res.json({ 125 | success: true, 126 | message: 'Historial de eventos limpiado' 127 | }); 128 | } catch (error) { 129 | res.status(500).json({ 130 | success: false, 131 | error: 'Error limpiando historial', 132 | message: error instanceof Error ? error.message : 'Error desconocido' 133 | }); 134 | } 135 | }); 136 | 137 | export default router; -------------------------------------------------------------------------------- /src/events/EventBus.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent, EventHandler } from './types'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | export class EventBus { 5 | private static instance: EventBus; 6 | private handlers: Map = new Map(); 7 | private eventHistory: DomainEvent[] = []; 8 | 9 | private constructor() {} 10 | 11 | public static getInstance(): EventBus { 12 | if (!EventBus.instance) { 13 | EventBus.instance = new EventBus(); 14 | } 15 | return EventBus.instance; 16 | } 17 | 18 | /** 19 | * Suscribe un handler a un tipo de evento específico 20 | */ 21 | public subscribe( 22 | eventType: T['type'], 23 | handler: EventHandler 24 | ): void { 25 | if (!this.handlers.has(eventType)) { 26 | this.handlers.set(eventType, []); 27 | } 28 | 29 | this.handlers.get(eventType)!.push(handler as EventHandler); 30 | console.log(`🔔 Handler suscrito al evento: ${eventType}`); 31 | } 32 | 33 | /** 34 | * Publica un evento y ejecuta todos los handlers suscritos 35 | */ 36 | public async publish(event: Omit): Promise { 37 | // Crear el evento completo con metadata 38 | const completeEvent: T = { 39 | ...event, 40 | id: uuidv4(), 41 | timestamp: new Date(), 42 | version: 1 43 | } as T; 44 | 45 | // Guardar en el historial 46 | this.eventHistory.push(completeEvent); 47 | 48 | console.log(`📢 Publicando evento: ${completeEvent.type}`, { 49 | id: completeEvent.id, 50 | timestamp: completeEvent.timestamp 51 | }); 52 | 53 | // Obtener handlers para este tipo de evento 54 | const eventHandlers = this.handlers.get(completeEvent.type) || []; 55 | 56 | // Ejecutar todos los handlers 57 | const promises = eventHandlers.map(async (handler) => { 58 | try { 59 | await handler(completeEvent); 60 | console.log(`✅ Handler ejecutado exitosamente para: ${completeEvent.type}`); 61 | } catch (error) { 62 | console.error(`❌ Error en handler para evento ${completeEvent.type}:`, error); 63 | // En un entorno de producción, aquí podrías implementar retry logic 64 | // o enviar a una cola de eventos fallidos 65 | } 66 | }); 67 | 68 | await Promise.all(promises); 69 | } 70 | 71 | /** 72 | * Obtiene el historial de eventos 73 | */ 74 | public getEventHistory(): DomainEvent[] { 75 | return [...this.eventHistory]; 76 | } 77 | 78 | /** 79 | * Obtiene eventos filtrados por tipo 80 | */ 81 | public getEventsByType(eventType: T['type']): T[] { 82 | return this.eventHistory.filter(event => event.type === eventType) as T[]; 83 | } 84 | 85 | /** 86 | * Limpia el historial de eventos (útil para testing) 87 | */ 88 | public clearHistory(): void { 89 | this.eventHistory = []; 90 | } 91 | 92 | /** 93 | * Obtiene estadísticas de eventos 94 | */ 95 | public getStats(): { 96 | totalEvents: number; 97 | eventsByType: Record; 98 | lastEventTime?: Date; 99 | } { 100 | const eventsByType: Record = {}; 101 | 102 | this.eventHistory.forEach(event => { 103 | eventsByType[event.type] = (eventsByType[event.type] || 0) + 1; 104 | }); 105 | 106 | return { 107 | totalEvents: this.eventHistory.length, 108 | eventsByType, 109 | lastEventTime: this.eventHistory.length > 0 110 | ? this.eventHistory[this.eventHistory.length - 1].timestamp 111 | : undefined 112 | }; 113 | } 114 | 115 | /** 116 | * Desuscribe un handler de un evento (útil para testing y cleanup) 117 | */ 118 | public unsubscribe(eventType: string, handler: EventHandler): void { 119 | const handlers = this.handlers.get(eventType); 120 | if (handlers) { 121 | const index = handlers.indexOf(handler); 122 | if (index > -1) { 123 | handlers.splice(index, 1); 124 | console.log(`🔕 Handler desuscrito del evento: ${eventType}`); 125 | } 126 | } 127 | } 128 | 129 | /** 130 | * Limpia todas las suscripciones 131 | */ 132 | public clearAllSubscriptions(): void { 133 | this.handlers.clear(); 134 | console.log('🧹 Todas las suscripciones limpiadas'); 135 | } 136 | } -------------------------------------------------------------------------------- /src/events/types.ts: -------------------------------------------------------------------------------- 1 | //ESTO OBVIAMENTE ES UNA PRUEBA/ SE PUEDE MEJORAR LA MODULARIZACION 2 | 3 | import { Order, Payment, Delivery, OrderStatus, PaymentStatus, DeliveryStatus } from '../models'; 4 | 5 | // Base event interface 6 | export interface BaseEvent { 7 | type: string; 8 | timestamp: Date; 9 | id: string; 10 | version: number; 11 | } 12 | 13 | // Order Events 14 | export interface OrderCreatedEvent extends BaseEvent { 15 | type: 'ORDER_CREATED'; 16 | payload: { 17 | order: Order; 18 | }; 19 | } 20 | 21 | export interface OrderConfirmedEvent extends BaseEvent { 22 | type: 'ORDER_CONFIRMED'; 23 | payload: { 24 | orderId: string; 25 | restaurantId: string; 26 | estimatedPreparationTime: number; 27 | }; 28 | } 29 | 30 | export interface OrderPreparingEvent extends BaseEvent { 31 | type: 'ORDER_PREPARING'; 32 | payload: { 33 | orderId: string; 34 | restaurantId: string; 35 | }; 36 | } 37 | 38 | export interface OrderReadyEvent extends BaseEvent { 39 | type: 'ORDER_READY'; 40 | payload: { 41 | orderId: string; 42 | restaurantId: string; 43 | }; 44 | } 45 | 46 | export interface OrderCancelledEvent extends BaseEvent { 47 | type: 'ORDER_CANCELLED'; 48 | payload: { 49 | orderId: string; 50 | reason: string; 51 | cancelledBy: 'user' | 'restaurant' | 'system'; 52 | }; 53 | } 54 | 55 | export interface OrderStatusUpdatedEvent extends BaseEvent { 56 | type: 'ORDER_STATUS_UPDATED'; 57 | payload: { 58 | orderId: string; 59 | oldStatus: OrderStatus; 60 | newStatus: OrderStatus; 61 | }; 62 | } 63 | 64 | // Payment Events 65 | export interface PaymentInitiatedEvent extends BaseEvent { 66 | type: 'PAYMENT_INITIATED'; 67 | payload: { 68 | payment: Payment; 69 | }; 70 | } 71 | 72 | export interface PaymentProcessedEvent extends BaseEvent { 73 | type: 'PAYMENT_PROCESSED'; 74 | payload: { 75 | paymentId: string; 76 | orderId: string; 77 | amount: number; 78 | transactionId: string; 79 | }; 80 | } 81 | 82 | export interface PaymentFailedEvent extends BaseEvent { 83 | type: 'PAYMENT_FAILED'; 84 | payload: { 85 | paymentId: string; 86 | orderId: string; 87 | reason: string; 88 | }; 89 | } 90 | 91 | export interface PaymentRefundedEvent extends BaseEvent { 92 | type: 'PAYMENT_REFUNDED'; 93 | payload: { 94 | paymentId: string; 95 | orderId: string; 96 | amount: number; 97 | reason: string; 98 | }; 99 | } 100 | 101 | // Delivery Events 102 | export interface DeliveryCreatedEvent extends BaseEvent { 103 | type: 'DELIVERY_CREATED'; 104 | payload: { 105 | delivery: Delivery; 106 | }; 107 | } 108 | 109 | export interface DriverAssignedEvent extends BaseEvent { 110 | type: 'DRIVER_ASSIGNED'; 111 | payload: { 112 | deliveryId: string; 113 | driverId: string; 114 | orderId: string; 115 | estimatedArrival: Date; 116 | }; 117 | } 118 | 119 | export interface OrderPickedUpEvent extends BaseEvent { 120 | type: 'ORDER_PICKED_UP'; 121 | payload: { 122 | deliveryId: string; 123 | orderId: string; 124 | driverId: string; 125 | pickupTime: Date; 126 | }; 127 | } 128 | 129 | export interface OrderInTransitEvent extends BaseEvent { 130 | type: 'ORDER_IN_TRANSIT'; 131 | payload: { 132 | deliveryId: string; 133 | orderId: string; 134 | driverId: string; 135 | estimatedDeliveryTime: Date; 136 | }; 137 | } 138 | 139 | export interface OrderDeliveredEvent extends BaseEvent { 140 | type: 'ORDER_DELIVERED'; 141 | payload: { 142 | deliveryId: string; 143 | orderId: string; 144 | driverId: string; 145 | deliveryTime: Date; 146 | }; 147 | } 148 | 149 | export interface DeliveryFailedEvent extends BaseEvent { 150 | type: 'DELIVERY_FAILED'; 151 | payload: { 152 | deliveryId: string; 153 | orderId: string; 154 | driverId?: string; 155 | reason: string; 156 | }; 157 | } 158 | 159 | // Union type for all events 160 | export type DomainEvent = 161 | | OrderCreatedEvent 162 | | OrderConfirmedEvent 163 | | OrderPreparingEvent 164 | | OrderReadyEvent 165 | | OrderCancelledEvent 166 | | OrderStatusUpdatedEvent 167 | | PaymentInitiatedEvent 168 | | PaymentProcessedEvent 169 | | PaymentFailedEvent 170 | | PaymentRefundedEvent 171 | | DeliveryCreatedEvent 172 | | DriverAssignedEvent 173 | | OrderPickedUpEvent 174 | | OrderInTransitEvent 175 | | OrderDeliveredEvent 176 | | DeliveryFailedEvent; 177 | 178 | // Event handler type 179 | export type EventHandler = (event: T) => Promise | void; -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Application, Request, Response, NextFunction } from 'express'; 2 | import cors from 'cors'; 3 | import helmet from 'helmet'; 4 | import rateLimit from 'express-rate-limit'; 5 | 6 | // Importar rutas 7 | import ordersRouter from './routes/orders'; 8 | import restaurantsRouter from './routes/restaurants'; 9 | import eventsRouter from './routes/events'; 10 | 11 | // Importar inicializador de eventos 12 | import { EventInitializer } from './events/EventInitializer'; 13 | 14 | export class App { 15 | public app: Application; 16 | private eventInitializer: EventInitializer; 17 | 18 | constructor() { 19 | this.app = express(); 20 | this.eventInitializer = new EventInitializer(); 21 | 22 | this.initializeMiddlewares(); 23 | this.initializeRoutes(); 24 | this.initializeEventSystem(); 25 | this.initializeErrorHandling(); 26 | } 27 | 28 | private initializeMiddlewares(): void { 29 | // Seguridad básica 30 | this.app.use(helmet()); 31 | 32 | // CORS 33 | this.app.use(cors({ 34 | origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'], 35 | credentials: true 36 | })); 37 | 38 | // Rate limiting 39 | const limiter = rateLimit({ 40 | windowMs: 15 * 60 * 1000, // 15 minutos 41 | max: 100, // máximo 100 requests por ventana de tiempo 42 | message: { 43 | success: false, 44 | error: 'Demasiadas peticiones, intenta de nuevo más tarde' 45 | } 46 | }); 47 | this.app.use('/api', limiter); 48 | 49 | // Body parsing 50 | this.app.use(express.json({ limit: '10mb' })); 51 | this.app.use(express.urlencoded({ extended: true })); 52 | 53 | // Logging middleware 54 | this.app.use((req: Request, res: Response, next: NextFunction) => { 55 | console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); 56 | next(); 57 | }); 58 | } 59 | 60 | private initializeRoutes(): void { 61 | // Ruta de health check 62 | this.app.get('/health', (req: Request, res: Response) => { 63 | res.json({ 64 | success: true, 65 | message: 'Sistema de pedidos de comida funcionando correctamente', 66 | timestamp: new Date().toISOString(), 67 | uptime: process.uptime(), 68 | environment: process.env.NODE_ENV || 'development' 69 | }); 70 | }); 71 | 72 | // Ruta principal con información de la API 73 | this.app.get('/', (req: Request, res: Response) => { 74 | res.json({ 75 | success: true, 76 | message: '🍕 Bienvenido al Sistema de Pedidos de Comida con Event-Driven Architecture', 77 | version: '1.0.0', 78 | architecture: 'Event-Driven', 79 | endpoints: { 80 | orders: '/api/orders', 81 | restaurants: '/api/restaurants', 82 | events: '/api/events', 83 | dashboard: '/api/events/dashboard', 84 | health: '/health' 85 | }, 86 | documentation: { 87 | createOrder: 'POST /api/orders', 88 | listOrders: 'GET /api/orders', 89 | getOrder: 'GET /api/orders/:id', 90 | cancelOrder: 'POST /api/orders/:id/cancel', 91 | listRestaurants: 'GET /api/restaurants', 92 | getMenu: 'GET /api/restaurants/:id/menu', 93 | eventHistory: 'GET /api/events/history', 94 | dashboard: 'GET /api/events/dashboard' 95 | } 96 | }); 97 | }); 98 | 99 | // API routes 100 | this.app.use('/api/orders', ordersRouter); 101 | this.app.use('/api/restaurants', restaurantsRouter); 102 | this.app.use('/api/events', eventsRouter); 103 | 104 | // 404 handler 105 | this.app.use('*', (req: Request, res: Response) => { 106 | res.status(404).json({ 107 | success: false, 108 | error: 'Endpoint no encontrado', 109 | path: req.originalUrl, 110 | availableEndpoints: [ 111 | '/api/orders', 112 | '/api/restaurants', 113 | '/api/events', 114 | '/health' 115 | ] 116 | }); 117 | }); 118 | } 119 | 120 | private initializeEventSystem(): void { 121 | // Inicializar el sistema de eventos 122 | this.eventInitializer.initialize(); 123 | } 124 | 125 | private initializeErrorHandling(): void { 126 | // Error handling middleware 127 | this.app.use((error: Error, req: Request, res: Response, next: NextFunction) => { 128 | console.error('❌ Error no manejado:', error); 129 | 130 | res.status(500).json({ 131 | success: false, 132 | error: 'Error interno del servidor', 133 | message: process.env.NODE_ENV === 'development' ? error.message : 'Algo salió mal', 134 | timestamp: new Date().toISOString() 135 | }); 136 | }); 137 | 138 | // Manejo de promesas rechazadas no capturadas 139 | process.on('unhandledRejection', (reason: any, promise: Promise) => { 140 | console.error('❌ Promesa rechazada no manejada:', reason); 141 | }); 142 | 143 | // Manejo de excepciones no capturadas 144 | process.on('uncaughtException', (error: Error) => { 145 | console.error('❌ Excepción no capturada:', error); 146 | process.exit(1); 147 | }); 148 | } 149 | 150 | public getApp(): Application { 151 | return this.app; 152 | } 153 | 154 | public getEventBus() { 155 | return this.eventInitializer.getEventBus(); 156 | } 157 | } -------------------------------------------------------------------------------- /src/handlers/OrderEventHandlers.ts: -------------------------------------------------------------------------------- 1 | import { OrderService } from '../services/OrderService'; 2 | import { PaymentService } from '../services/PaymentService'; 3 | import { DeliveryService } from '../services/DeliveryService'; 4 | import { OrderStatus } from '../models'; 5 | import { 6 | OrderCreatedEvent, 7 | PaymentProcessedEvent, 8 | PaymentFailedEvent, 9 | OrderReadyEvent, 10 | OrderDeliveredEvent, 11 | PaymentRefundedEvent 12 | } from '../events/types'; 13 | 14 | export class OrderEventHandlers { 15 | private orderService: OrderService; 16 | private paymentService: PaymentService; 17 | private deliveryService: DeliveryService; 18 | 19 | constructor() { 20 | this.orderService = new OrderService(); 21 | this.paymentService = new PaymentService(); 22 | this.deliveryService = new DeliveryService(); 23 | } 24 | 25 | /** 26 | * Maneja la creación de un pedido iniciando el proceso de pago 27 | */ 28 | public async handleOrderCreated(event: OrderCreatedEvent): Promise { 29 | const { order } = event.payload; 30 | 31 | console.log(`🔄 Procesando pedido creado: ${order.id}`); 32 | 33 | try { 34 | // Iniciar el proceso de pago 35 | const payment = await this.paymentService.initiatePayment({ 36 | orderId: order.id, 37 | amount: order.totalAmount, 38 | method: 'tarjeta_credito' // Por defecto 39 | }); 40 | 41 | // Procesar el pago automáticamente (en un sistema real esto sería asíncrono) 42 | setTimeout(async () => { 43 | try { 44 | await this.paymentService.processPayment(payment.id); 45 | } catch (error) { 46 | console.error(`❌ Error procesando pago para pedido ${order.id}:`, error); 47 | } 48 | }, 2000); 49 | 50 | } catch (error) { 51 | console.error(`❌ Error iniciando pago para pedido ${order.id}:`, error); 52 | 53 | // Cancelar el pedido si no se puede procesar el pago 54 | await this.orderService.cancelOrder( 55 | order.id, 56 | 'Error al procesar el pago', 57 | 'system' 58 | ); 59 | } 60 | } 61 | 62 | /** 63 | * Maneja un pago exitoso confirmando el pedido 64 | */ 65 | public async handlePaymentProcessed(event: PaymentProcessedEvent): Promise { 66 | const { orderId } = event.payload; 67 | 68 | console.log(`🔄 Procesando pago exitoso para pedido: ${orderId}`); 69 | 70 | try { 71 | // Confirmar el pedido 72 | await this.orderService.updateOrderStatus(orderId, OrderStatus.CONFIRMED); 73 | 74 | // Simular confirmación del restaurante después de un tiempo 75 | setTimeout(async () => { 76 | try { 77 | await this.orderService.updateOrderStatus(orderId, OrderStatus.PREPARING); 78 | 79 | // Simular tiempo de preparación 80 | setTimeout(async () => { 81 | try { 82 | await this.orderService.updateOrderStatus(orderId, OrderStatus.READY); 83 | } catch (error) { 84 | console.error(`❌ Error actualizando estado a READY para pedido ${orderId}:`, error); 85 | } 86 | }, 10000); // 10 segundos para preparación 87 | 88 | } catch (error) { 89 | console.error(`❌ Error actualizando estado a PREPARING para pedido ${orderId}:`, error); 90 | } 91 | }, 3000); // 3 segundos para confirmación del restaurante 92 | 93 | } catch (error) { 94 | console.error(`❌ Error confirmando pedido ${orderId}:`, error); 95 | } 96 | } 97 | 98 | /** 99 | * Maneja un pago fallido cancelando el pedido 100 | */ 101 | public async handlePaymentFailed(event: PaymentFailedEvent): Promise { 102 | const { orderId, reason } = event.payload; 103 | 104 | console.log(`🔄 Procesando pago fallido para pedido: ${orderId}`); 105 | 106 | try { 107 | // Cancelar el pedido 108 | await this.orderService.cancelOrder( 109 | orderId, 110 | `Pago fallido: ${reason}`, 111 | 'system' 112 | ); 113 | } catch (error) { 114 | console.error(`❌ Error cancelando pedido ${orderId} por pago fallido:`, error); 115 | } 116 | } 117 | 118 | /** 119 | * Maneja cuando un pedido está listo para entrega 120 | */ 121 | public async handleOrderReady(event: OrderReadyEvent): Promise { 122 | const { orderId } = event.payload; 123 | 124 | console.log(`🔄 Pedido listo para entrega: ${orderId}`); 125 | 126 | try { 127 | // Crear la entrega 128 | const delivery = await this.deliveryService.createDelivery(orderId); 129 | 130 | // Asignar un repartidor automáticamente 131 | setTimeout(async () => { 132 | try { 133 | await this.deliveryService.assignDriver(delivery.id); 134 | } catch (error) { 135 | console.error(`❌ Error asignando repartidor para entrega ${delivery.id}:`, error); 136 | } 137 | }, 1000); 138 | 139 | } catch (error) { 140 | console.error(`❌ Error creando entrega para pedido ${orderId}:`, error); 141 | } 142 | } 143 | 144 | /** 145 | * Maneja cuando un pedido es entregado 146 | */ 147 | public async handleOrderDelivered(event: OrderDeliveredEvent): Promise { 148 | const { orderId } = event.payload; 149 | 150 | console.log(`🔄 Pedido entregado: ${orderId}`); 151 | 152 | try { 153 | // Actualizar estado del pedido a DELIVERED 154 | await this.orderService.updateOrderStatus(orderId, OrderStatus.DELIVERED); 155 | 156 | console.log(`✅ Pedido ${orderId} completado exitosamente`); 157 | } catch (error) { 158 | console.error(`❌ Error actualizando estado a DELIVERED para pedido ${orderId}:`, error); 159 | } 160 | } 161 | 162 | /** 163 | * Maneja cuando se procesa un reembolso 164 | */ 165 | public async handlePaymentRefunded(event: PaymentRefundedEvent): Promise { 166 | const { orderId, reason } = event.payload; 167 | 168 | console.log(`🔄 Procesando reembolso para pedido: ${orderId}`); 169 | 170 | try { 171 | // Si el pedido no está ya cancelado, cancelarlo 172 | const order = this.orderService.getOrder(orderId); 173 | if (order && order.status !== OrderStatus.CANCELLED) { 174 | await this.orderService.cancelOrder( 175 | orderId, 176 | `Reembolso procesado: ${reason}`, 177 | 'system' 178 | ); 179 | } 180 | 181 | console.log(`✅ Reembolso procesado para pedido ${orderId}`); 182 | } catch (error) { 183 | console.error(`❌ Error procesando reembolso para pedido ${orderId}:`, error); 184 | } 185 | } 186 | } -------------------------------------------------------------------------------- /src/routes/orders.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response } from 'express'; 2 | import { OrderService, CreateOrderRequest } from '../services/OrderService'; 3 | import { PaymentService } from '../services/PaymentService'; 4 | import { InMemoryRepository } from '../repositories/InMemoryRepository'; 5 | 6 | const router = Router(); 7 | const orderService = new OrderService(); 8 | const paymentService = new PaymentService(); 9 | const repository = InMemoryRepository.getInstance(); 10 | 11 | /** 12 | * GET /api/orders 13 | * Obtiene todos los pedidos 14 | */ 15 | router.get('/', (req: Request, res: Response) => { 16 | try { 17 | const orders = orderService.getAllOrders(); 18 | res.json({ 19 | success: true, 20 | data: orders, 21 | count: orders.length 22 | }); 23 | } catch (error) { 24 | res.status(500).json({ 25 | success: false, 26 | error: 'Error obteniendo pedidos', 27 | message: error instanceof Error ? error.message : 'Error desconocido' 28 | }); 29 | } 30 | }); 31 | 32 | /** 33 | * GET /api/orders/stats 34 | * Obtiene estadísticas de pedidos 35 | */ 36 | router.get('/stats', (req: Request, res: Response) => { 37 | try { 38 | const stats = orderService.getOrderStats(); 39 | res.json({ 40 | success: true, 41 | data: stats 42 | }); 43 | } catch (error) { 44 | res.status(500).json({ 45 | success: false, 46 | error: 'Error obteniendo estadísticas', 47 | message: error instanceof Error ? error.message : 'Error desconocido' 48 | }); 49 | } 50 | }); 51 | 52 | /** 53 | * GET /api/orders/:id 54 | * Obtiene un pedido por ID 55 | */ 56 | router.get('/:id', (req: Request, res: Response) => { 57 | try { 58 | const { id } = req.params; 59 | const order = orderService.getOrder(id); 60 | 61 | if (!order) { 62 | return res.status(404).json({ 63 | success: false, 64 | error: 'Pedido no encontrado' 65 | }); 66 | } 67 | 68 | // Incluir información adicional 69 | const payment = paymentService.getPaymentByOrderId(id); 70 | const user = repository.getUser(order.userId); 71 | const restaurant = repository.getRestaurant(order.restaurantId); 72 | 73 | res.json({ 74 | success: true, 75 | data: { 76 | order, 77 | payment, 78 | user: user ? { id: user.id, name: user.name, email: user.email } : null, 79 | restaurant: restaurant ? { id: restaurant.id, name: restaurant.name } : null 80 | } 81 | }); 82 | } catch (error) { 83 | res.status(500).json({ 84 | success: false, 85 | error: 'Error obteniendo pedido', 86 | message: error instanceof Error ? error.message : 'Error desconocido' 87 | }); 88 | } 89 | }); 90 | 91 | /** 92 | * POST /api/orders 93 | * Crea un nuevo pedido 94 | */ 95 | router.post('/', async (req: Request, res: Response) => { 96 | try { 97 | const orderData: CreateOrderRequest = req.body; 98 | 99 | // Validaciones básicas 100 | if (!orderData.userId || !orderData.restaurantId || !orderData.items || !orderData.deliveryAddress) { 101 | return res.status(400).json({ 102 | success: false, 103 | error: 'Faltan campos requeridos: userId, restaurantId, items, deliveryAddress' 104 | }); 105 | } 106 | 107 | if (!Array.isArray(orderData.items) || orderData.items.length === 0) { 108 | return res.status(400).json({ 109 | success: false, 110 | error: 'El pedido debe tener al menos un item' 111 | }); 112 | } 113 | 114 | const order = await orderService.createOrder(orderData); 115 | 116 | res.status(201).json({ 117 | success: true, 118 | data: order, 119 | message: 'Pedido creado exitosamente' 120 | }); 121 | 122 | } catch (error) { 123 | res.status(400).json({ 124 | success: false, 125 | error: 'Error creando pedido', 126 | message: error instanceof Error ? error.message : 'Error desconocido' 127 | }); 128 | } 129 | }); 130 | 131 | /** 132 | * PATCH /api/orders/:id/status 133 | * Actualiza el estado de un pedido 134 | */ 135 | router.patch('/:id/status', async (req: Request, res: Response) => { 136 | try { 137 | const { id } = req.params; 138 | const { status } = req.body; 139 | 140 | if (!status) { 141 | return res.status(400).json({ 142 | success: false, 143 | error: 'Se requiere el campo status' 144 | }); 145 | } 146 | 147 | const order = await orderService.updateOrderStatus(id, status); 148 | 149 | res.json({ 150 | success: true, 151 | data: order, 152 | message: `Estado del pedido actualizado a ${status}` 153 | }); 154 | 155 | } catch (error) { 156 | res.status(400).json({ 157 | success: false, 158 | error: 'Error actualizando estado del pedido', 159 | message: error instanceof Error ? error.message : 'Error desconocido' 160 | }); 161 | } 162 | }); 163 | 164 | /** 165 | * POST /api/orders/:id/cancel 166 | * Cancela un pedido 167 | */ 168 | router.post('/:id/cancel', async (req: Request, res: Response) => { 169 | try { 170 | const { id } = req.params; 171 | const { reason, cancelledBy } = req.body; 172 | 173 | if (!reason) { 174 | return res.status(400).json({ 175 | success: false, 176 | error: 'Se requiere especificar la razón de cancelación' 177 | }); 178 | } 179 | 180 | const order = await orderService.cancelOrder( 181 | id, 182 | reason, 183 | cancelledBy || 'user' 184 | ); 185 | 186 | res.json({ 187 | success: true, 188 | data: order, 189 | message: 'Pedido cancelado exitosamente' 190 | }); 191 | 192 | } catch (error) { 193 | res.status(400).json({ 194 | success: false, 195 | error: 'Error cancelando pedido', 196 | message: error instanceof Error ? error.message : 'Error desconocido' 197 | }); 198 | } 199 | }); 200 | 201 | /** 202 | * GET /api/orders/user/:userId 203 | * Obtiene pedidos de un usuario específico 204 | */ 205 | router.get('/user/:userId', (req: Request, res: Response) => { 206 | try { 207 | const { userId } = req.params; 208 | const orders = orderService.getUserOrders(userId); 209 | 210 | res.json({ 211 | success: true, 212 | data: orders, 213 | count: orders.length 214 | }); 215 | } catch (error) { 216 | res.status(500).json({ 217 | success: false, 218 | error: 'Error obteniendo pedidos del usuario', 219 | message: error instanceof Error ? error.message : 'Error desconocido' 220 | }); 221 | } 222 | }); 223 | 224 | /** 225 | * GET /api/orders/restaurant/:restaurantId 226 | * Obtiene pedidos de un restaurante específico 227 | */ 228 | router.get('/restaurant/:restaurantId', (req: Request, res: Response) => { 229 | try { 230 | const { restaurantId } = req.params; 231 | const orders = orderService.getRestaurantOrders(restaurantId); 232 | 233 | res.json({ 234 | success: true, 235 | data: orders, 236 | count: orders.length 237 | }); 238 | } catch (error) { 239 | res.status(500).json({ 240 | success: false, 241 | error: 'Error obteniendo pedidos del restaurante', 242 | message: error instanceof Error ? error.message : 'Error desconocido' 243 | }); 244 | } 245 | }); 246 | 247 | export default router; -------------------------------------------------------------------------------- /src/services/PaymentService.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { Payment, PaymentStatus } from '../models'; 3 | import { InMemoryRepository } from '../repositories/InMemoryRepository'; 4 | import { EventBus } from '../events/EventBus'; 5 | import { PaymentInitiatedEvent, PaymentProcessedEvent, PaymentFailedEvent, PaymentRefundedEvent } from '../events/types'; 6 | 7 | export interface ProcessPaymentRequest { 8 | orderId: string; 9 | amount: number; 10 | method: string; 11 | } 12 | 13 | export class PaymentService { 14 | private repository: InMemoryRepository; 15 | private eventBus: EventBus; 16 | 17 | constructor() { 18 | this.repository = InMemoryRepository.getInstance(); 19 | this.eventBus = EventBus.getInstance(); 20 | } 21 | 22 | /** 23 | * Inicia un pago 24 | */ 25 | public async initiatePayment(request: ProcessPaymentRequest): Promise { 26 | // Validar que el pedido existe 27 | const order = this.repository.getOrder(request.orderId); 28 | if (!order) { 29 | throw new Error('Pedido no encontrado'); 30 | } 31 | 32 | // Verificar que no existe ya un pago para este pedido 33 | const existingPayment = this.repository.getPaymentByOrderId(request.orderId); 34 | if (existingPayment) { 35 | throw new Error('Ya existe un pago para este pedido'); 36 | } 37 | 38 | // Crear el pago 39 | const payment: Payment = { 40 | id: uuidv4(), 41 | orderId: request.orderId, 42 | amount: request.amount, 43 | status: PaymentStatus.PENDING, 44 | method: request.method, 45 | createdAt: new Date(), 46 | updatedAt: new Date() 47 | }; 48 | 49 | this.repository.savePayment(payment); 50 | 51 | // Publicar evento 52 | await this.eventBus.publish({ 53 | type: 'PAYMENT_INITIATED', 54 | payload: { payment } 55 | }); 56 | 57 | console.log(`💳 Pago iniciado: ${payment.id} por €${payment.amount}`); 58 | return payment; 59 | } 60 | 61 | /** 62 | * Procesa un pago (simula el proceso con un proveedor de pagos) 63 | */ 64 | public async processPayment(paymentId: string): Promise { 65 | const payment = this.repository.getPayment(paymentId); 66 | if (!payment) { 67 | throw new Error('Pago no encontrado'); 68 | } 69 | 70 | if (payment.status !== PaymentStatus.PENDING) { 71 | throw new Error(`No se puede procesar un pago en estado ${payment.status}`); 72 | } 73 | 74 | // Simular procesamiento asíncrono 75 | payment.status = PaymentStatus.PROCESSING; 76 | payment.updatedAt = new Date(); 77 | this.repository.savePayment(payment); 78 | 79 | // Simular resultado aleatorio del procesamiento (95% éxito) 80 | const isSuccessful = Math.random() > 0.05; 81 | 82 | await new Promise(resolve => setTimeout(resolve, 1000)); // Simular tiempo de procesamiento 83 | 84 | if (isSuccessful) { 85 | // Pago exitoso 86 | payment.status = PaymentStatus.COMPLETED; 87 | payment.transactionId = `txn_${uuidv4().substring(0, 8)}`; 88 | payment.updatedAt = new Date(); 89 | this.repository.savePayment(payment); 90 | 91 | await this.eventBus.publish({ 92 | type: 'PAYMENT_PROCESSED', 93 | payload: { 94 | paymentId: payment.id, 95 | orderId: payment.orderId, 96 | amount: payment.amount, 97 | transactionId: payment.transactionId 98 | } 99 | }); 100 | 101 | console.log(`✅ Pago procesado exitosamente: ${payment.id}`); 102 | } else { 103 | // Pago fallido 104 | payment.status = PaymentStatus.FAILED; 105 | payment.updatedAt = new Date(); 106 | this.repository.savePayment(payment); 107 | 108 | await this.eventBus.publish({ 109 | type: 'PAYMENT_FAILED', 110 | payload: { 111 | paymentId: payment.id, 112 | orderId: payment.orderId, 113 | reason: 'Fondos insuficientes' 114 | } 115 | }); 116 | 117 | console.log(`❌ Pago fallido: ${payment.id}`); 118 | } 119 | 120 | return payment; 121 | } 122 | 123 | /** 124 | * Procesa un reembolso 125 | */ 126 | public async refundPayment(paymentId: string, reason: string): Promise { 127 | const payment = this.repository.getPayment(paymentId); 128 | if (!payment) { 129 | throw new Error('Pago no encontrado'); 130 | } 131 | 132 | if (payment.status !== PaymentStatus.COMPLETED) { 133 | throw new Error('Solo se pueden reembolsar pagos completados'); 134 | } 135 | 136 | // Actualizar estado 137 | payment.status = PaymentStatus.REFUNDED; 138 | payment.updatedAt = new Date(); 139 | this.repository.savePayment(payment); 140 | 141 | // Publicar evento 142 | await this.eventBus.publish({ 143 | type: 'PAYMENT_REFUNDED', 144 | payload: { 145 | paymentId: payment.id, 146 | orderId: payment.orderId, 147 | amount: payment.amount, 148 | reason 149 | } 150 | }); 151 | 152 | console.log(`💸 Reembolso procesado: ${payment.id} - €${payment.amount}`); 153 | return payment; 154 | } 155 | 156 | /** 157 | * Obtiene un pago por ID 158 | */ 159 | public getPayment(paymentId: string): Payment | undefined { 160 | return this.repository.getPayment(paymentId); 161 | } 162 | 163 | /** 164 | * Obtiene un pago por ID de pedido 165 | */ 166 | public getPaymentByOrderId(orderId: string): Payment | undefined { 167 | return this.repository.getPaymentByOrderId(orderId); 168 | } 169 | 170 | /** 171 | * Obtiene todos los pagos 172 | */ 173 | public getAllPayments(): Payment[] { 174 | return this.repository.getAllPayments(); 175 | } 176 | 177 | /** 178 | * Obtiene estadísticas de pagos 179 | */ 180 | public getPaymentStats(): { 181 | total: number; 182 | byStatus: Record; 183 | totalProcessed: number; 184 | totalRefunded: number; 185 | successRate: number; 186 | } { 187 | const payments = this.repository.getAllPayments(); 188 | const byStatus: Record = { 189 | [PaymentStatus.PENDING]: 0, 190 | [PaymentStatus.PROCESSING]: 0, 191 | [PaymentStatus.COMPLETED]: 0, 192 | [PaymentStatus.FAILED]: 0, 193 | [PaymentStatus.REFUNDED]: 0 194 | }; 195 | 196 | let totalProcessed = 0; 197 | let totalRefunded = 0; 198 | 199 | payments.forEach(payment => { 200 | byStatus[payment.status]++; 201 | if (payment.status === PaymentStatus.COMPLETED || payment.status === PaymentStatus.REFUNDED) { 202 | totalProcessed += payment.amount; 203 | } 204 | if (payment.status === PaymentStatus.REFUNDED) { 205 | totalRefunded += payment.amount; 206 | } 207 | }); 208 | 209 | const completedPayments = byStatus[PaymentStatus.COMPLETED] + byStatus[PaymentStatus.REFUNDED]; 210 | const totalAttempts = payments.length - byStatus[PaymentStatus.PENDING] - byStatus[PaymentStatus.PROCESSING]; 211 | const successRate = totalAttempts > 0 ? (completedPayments / totalAttempts) * 100 : 0; 212 | 213 | return { 214 | total: payments.length, 215 | byStatus, 216 | totalProcessed: Math.round(totalProcessed * 100) / 100, 217 | totalRefunded: Math.round(totalRefunded * 100) / 100, 218 | successRate: Math.round(successRate * 100) / 100 219 | }; 220 | } 221 | } -------------------------------------------------------------------------------- /src/services/OrderService.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { Order, OrderStatus, OrderItem, Address } from '../models'; 3 | import { InMemoryRepository } from '../repositories/InMemoryRepository'; 4 | import { EventBus } from '../events/EventBus'; 5 | import { OrderCreatedEvent, OrderStatusUpdatedEvent, OrderCancelledEvent } from '../events/types'; 6 | 7 | export interface CreateOrderRequest { 8 | userId: string; 9 | restaurantId: string; 10 | items: OrderItem[]; 11 | deliveryAddress: Address; 12 | } 13 | 14 | export class OrderService { 15 | private repository: InMemoryRepository; 16 | private eventBus: EventBus; 17 | 18 | constructor() { 19 | this.repository = InMemoryRepository.getInstance(); 20 | this.eventBus = EventBus.getInstance(); 21 | } 22 | 23 | /** 24 | * Crea un nuevo pedido 25 | */ 26 | public async createOrder(request: CreateOrderRequest): Promise { 27 | // Validaciones 28 | const user = this.repository.getUser(request.userId); 29 | if (!user) { 30 | throw new Error('Usuario no encontrado'); 31 | } 32 | 33 | const restaurant = this.repository.getRestaurant(request.restaurantId); 34 | if (!restaurant || !restaurant.isActive) { 35 | throw new Error('Restaurante no disponible'); 36 | } 37 | 38 | // Validar items y calcular total 39 | let totalAmount = 0; 40 | for (const item of request.items) { 41 | const menuItem = restaurant.menu.find(mi => mi.id === item.menuItemId); 42 | if (!menuItem || !menuItem.isAvailable) { 43 | throw new Error(`Item ${item.menuItemId} no disponible`); 44 | } 45 | if (item.quantity <= 0) { 46 | throw new Error('La cantidad debe ser mayor a 0'); 47 | } 48 | totalAmount += menuItem.price * item.quantity; 49 | } 50 | 51 | // Crear el pedido 52 | const order: Order = { 53 | id: uuidv4(), 54 | userId: request.userId, 55 | restaurantId: request.restaurantId, 56 | items: request.items, 57 | status: OrderStatus.PENDING, 58 | totalAmount: Math.round(totalAmount * 100) / 100, // Redondear a 2 decimales 59 | deliveryAddress: request.deliveryAddress, 60 | createdAt: new Date(), 61 | updatedAt: new Date() 62 | }; 63 | 64 | // Guardar en repositorio 65 | this.repository.saveOrder(order); 66 | 67 | // Publicar evento 68 | await this.eventBus.publish({ 69 | type: 'ORDER_CREATED', 70 | payload: { order } 71 | }); 72 | 73 | console.log(`✨ Pedido creado: ${order.id} por €${order.totalAmount}`); 74 | return order; 75 | } 76 | 77 | /** 78 | * Obtiene un pedido por ID 79 | */ 80 | public getOrder(orderId: string): Order | undefined { 81 | return this.repository.getOrder(orderId); 82 | } 83 | 84 | /** 85 | * Obtiene pedidos de un usuario 86 | */ 87 | public getUserOrders(userId: string): Order[] { 88 | return this.repository.getOrdersByUser(userId); 89 | } 90 | 91 | /** 92 | * Obtiene pedidos de un restaurante 93 | */ 94 | public getRestaurantOrders(restaurantId: string): Order[] { 95 | return this.repository.getOrdersByRestaurant(restaurantId); 96 | } 97 | 98 | /** 99 | * Actualiza el estado de un pedido 100 | */ 101 | public async updateOrderStatus(orderId: string, newStatus: OrderStatus): Promise { 102 | const order = this.repository.getOrder(orderId); 103 | if (!order) { 104 | throw new Error('Pedido no encontrado'); 105 | } 106 | 107 | const oldStatus = order.status; 108 | 109 | // Validar transición de estados 110 | if (!this.isValidStatusTransition(oldStatus, newStatus)) { 111 | throw new Error(`Transición inválida de ${oldStatus} a ${newStatus}`); 112 | } 113 | 114 | // Actualizar pedido 115 | order.status = newStatus; 116 | order.updatedAt = new Date(); 117 | 118 | // Si el pedido está confirmado, calcular tiempo estimado de entrega 119 | if (newStatus === OrderStatus.CONFIRMED) { 120 | const restaurant = this.repository.getRestaurant(order.restaurantId); 121 | if (restaurant) { 122 | const maxPreparationTime = Math.max(...order.items.map(item => { 123 | const menuItem = restaurant.menu.find(mi => mi.id === item.menuItemId); 124 | return menuItem?.preparationTime || 15; 125 | })); 126 | 127 | const estimatedTime = new Date(); 128 | estimatedTime.setMinutes(estimatedTime.getMinutes() + maxPreparationTime + 30); // Preparación + entrega 129 | order.estimatedDeliveryTime = estimatedTime; 130 | } 131 | } 132 | 133 | this.repository.saveOrder(order); 134 | 135 | // Publicar evento 136 | await this.eventBus.publish({ 137 | type: 'ORDER_STATUS_UPDATED', 138 | payload: { 139 | orderId, 140 | oldStatus, 141 | newStatus 142 | } 143 | }); 144 | 145 | console.log(`🔄 Estado del pedido ${orderId} actualizado: ${oldStatus} → ${newStatus}`); 146 | return order; 147 | } 148 | 149 | /** 150 | * Cancela un pedido 151 | */ 152 | public async cancelOrder( 153 | orderId: string, 154 | reason: string, 155 | cancelledBy: 'user' | 'restaurant' | 'system' 156 | ): Promise { 157 | const order = this.repository.getOrder(orderId); 158 | if (!order) { 159 | throw new Error('Pedido no encontrado'); 160 | } 161 | 162 | // Solo se pueden cancelar pedidos en ciertos estados 163 | const cancellableStatuses = [OrderStatus.PENDING, OrderStatus.CONFIRMED, OrderStatus.PREPARING]; 164 | if (!cancellableStatuses.includes(order.status)) { 165 | throw new Error(`No se puede cancelar un pedido en estado ${order.status}`); 166 | } 167 | 168 | const oldStatus = order.status; 169 | order.status = OrderStatus.CANCELLED; 170 | order.updatedAt = new Date(); 171 | 172 | this.repository.saveOrder(order); 173 | 174 | // Publicar eventos 175 | await this.eventBus.publish({ 176 | type: 'ORDER_CANCELLED', 177 | payload: { 178 | orderId, 179 | reason, 180 | cancelledBy 181 | } 182 | }); 183 | 184 | await this.eventBus.publish({ 185 | type: 'ORDER_STATUS_UPDATED', 186 | payload: { 187 | orderId, 188 | oldStatus, 189 | newStatus: OrderStatus.CANCELLED 190 | } 191 | }); 192 | 193 | console.log(`❌ Pedido ${orderId} cancelado por ${cancelledBy}: ${reason}`); 194 | return order; 195 | } 196 | 197 | /** 198 | * Obtiene todos los pedidos 199 | */ 200 | public getAllOrders(): Order[] { 201 | return this.repository.getAllOrders(); 202 | } 203 | 204 | /** 205 | * Obtiene estadísticas de pedidos 206 | */ 207 | public getOrderStats(): { 208 | total: number; 209 | byStatus: Record; 210 | totalRevenue: number; 211 | averageOrderValue: number; 212 | } { 213 | const orders = this.repository.getAllOrders(); 214 | const byStatus: Record = { 215 | [OrderStatus.PENDING]: 0, 216 | [OrderStatus.CONFIRMED]: 0, 217 | [OrderStatus.PREPARING]: 0, 218 | [OrderStatus.READY]: 0, 219 | [OrderStatus.PICKED_UP]: 0, 220 | [OrderStatus.DELIVERED]: 0, 221 | [OrderStatus.CANCELLED]: 0 222 | }; 223 | 224 | let totalRevenue = 0; 225 | 226 | orders.forEach(order => { 227 | byStatus[order.status]++; 228 | if (order.status !== OrderStatus.CANCELLED) { 229 | totalRevenue += order.totalAmount; 230 | } 231 | }); 232 | 233 | return { 234 | total: orders.length, 235 | byStatus, 236 | totalRevenue: Math.round(totalRevenue * 100) / 100, 237 | averageOrderValue: orders.length > 0 238 | ? Math.round((totalRevenue / orders.length) * 100) / 100 239 | : 0 240 | }; 241 | } 242 | 243 | /** 244 | * Valida si una transición de estado es válida 245 | */ 246 | private isValidStatusTransition(from: OrderStatus, to: OrderStatus): boolean { 247 | const validTransitions: Record = { 248 | [OrderStatus.PENDING]: [OrderStatus.CONFIRMED, OrderStatus.CANCELLED], 249 | [OrderStatus.CONFIRMED]: [OrderStatus.PREPARING, OrderStatus.CANCELLED], 250 | [OrderStatus.PREPARING]: [OrderStatus.READY, OrderStatus.CANCELLED], 251 | [OrderStatus.READY]: [OrderStatus.PICKED_UP], 252 | [OrderStatus.PICKED_UP]: [OrderStatus.DELIVERED], 253 | [OrderStatus.DELIVERED]: [], 254 | [OrderStatus.CANCELLED]: [] 255 | }; 256 | 257 | return validTransitions[from]?.includes(to) || false; 258 | } 259 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📚 Proyecto: Sistema de Pedidos con Event-Driven Architecture 2 | 3 | ## Para mis estudiantes 4 | 5 | Este proyecto es un ejemplo práctico de cómo implementar una **arquitectura basada en eventos** en el mundo real. Lo he diseñado pensando en que aprendan conceptos avanzados de backend mientras desarrollan una aplicación que podrían encontrar en cualquier empresa. 6 | 7 | ### 🎯 ¿Qué vas a aprender? 8 | 9 | - **Event-Driven Architecture**: Cómo los servicios se comunican sin conocerse directamente 10 | - **TypeScript avanzado**: Tipos, interfaces, genéricos en un proyecto real 11 | - **Patrones de diseño**: Singleton, Observer, Repository 12 | - **API REST**: Diseño profesional de endpoints 13 | - **Manejo de estados**: Transiciones válidas y consistencia de datos 14 | 15 | ## 🏗️ Conceptos Fundamentales 16 | 17 | ### ¿Qué es Event-Driven Architecture? 18 | 19 | Imagina que estás en un restaurante: 20 | - El **mesero** toma tu pedido (crear evento) 21 | - La **cocina** se entera automáticamente y empieza a cocinar (reaccionar al evento) 22 | - El **cajero** cobra automáticamente (otro handler del mismo evento) 23 | - El **repartidor** se prepara para entregar (reacción en cadena) 24 | 25 | **Nadie le dice a nadie qué hacer directamente**. Todo sucede por eventos. 26 | 27 | ### Ventajas vs. Arquitectura Tradicional 28 | 29 | **❌ Forma tradicional (acoplada):** 30 | ```typescript 31 | // OrderService llama directamente a otros servicios 32 | class OrderService { 33 | createOrder(order) { 34 | this.orderRepo.save(order); 35 | this.paymentService.processPayment(order); // ❌ Acoplado 36 | this.emailService.sendConfirmation(order); // ❌ Acoplado 37 | this.inventoryService.updateStock(order); // ❌ Acoplado 38 | } 39 | } 40 | ``` 41 | 42 | **✅ Con eventos (desacoplado):** 43 | ```typescript 44 | // OrderService solo publica un evento 45 | class OrderService { 46 | async createOrder(order) { 47 | this.orderRepo.save(order); 48 | await this.eventBus.publish('ORDER_CREATED', { order }); // ✅ Desacoplado 49 | } 50 | } 51 | ``` 52 | 53 | ## 🚀 Primeros Pasos 54 | 55 | ### 1. Instalar y ejecutar 56 | ```bash 57 | npm install 58 | npm run dev 59 | ``` 60 | 61 | ### 2. Probar el health check 62 | ```bash 63 | curl http://localhost:3000/health 64 | ``` 65 | 66 | ### 3. Ver la información de la API 67 | ```bash 68 | curl http://localhost:3000/ 69 | ``` 70 | 71 | ## 📖 Explorando el Código 72 | 73 | ### Estructura que debes entender 74 | 75 | ``` 76 | src/ 77 | ├── events/ # 🎯 El corazón del sistema 78 | │ ├── EventBus.ts # Singleton que maneja todos los eventos 79 | │ └── types.ts # Definiciones de cada evento 80 | ├── services/ # 🏢 Lógica de negocio 81 | ├── handlers/ # 👂 Quién escucha y reacciona a eventos 82 | ├── models/ # 📋 Interfaces y tipos 83 | └── routes/ # 🌐 Endpoints HTTP 84 | ``` 85 | 86 | ### El EventBus - Tu nuevo mejor amigo 87 | 88 | ```typescript 89 | // Publicar un evento 90 | await eventBus.publish('ORDER_CREATED', { order }); 91 | 92 | // Suscribirse a un evento 93 | eventBus.subscribe('ORDER_CREATED', (event) => { 94 | console.log('¡Nuevo pedido!', event.payload.order); 95 | }); 96 | ``` 97 | 98 | ## 🧪 Experimentos que debes hacer 99 | 100 | ### Experimento 1: Crear tu primer pedido 101 | ```bash 102 | curl -X POST http://localhost:3000/api/orders \ 103 | -H "Content-Type: application/json" \ 104 | -d '{ 105 | "userId": "user-1", 106 | "restaurantId": "restaurant-1", 107 | "items": [{"menuItemId": "item-1", "quantity": 1, "price": 12.50}], 108 | "deliveryAddress": { 109 | "street": "Tu calle 123", 110 | "city": "Tu ciudad", 111 | "state": "Tu estado", 112 | "zipCode": "12345" 113 | } 114 | }' 115 | ``` 116 | 117 | **📝 Pregunta**: ¿Qué eventos se generaron automáticamente? 118 | 119 | ### Experimento 2: Observar el flujo de eventos 120 | ```bash 121 | # Inmediatamente después de crear el pedido 122 | curl http://localhost:3000/api/events/history 123 | ``` 124 | 125 | **📝 Análisis**: Cuenta cuántos eventos se generaron y en qué orden. 126 | 127 | ### Experimento 3: Dashboard en tiempo real 128 | ```bash 129 | curl http://localhost:3000/api/events/dashboard 130 | ``` 131 | 132 | **📝 Reflexión**: ¿Qué métricas te parecen más importantes para un negocio real? 133 | 134 | ## 🔍 Puntos Clave del Código 135 | 136 | ### 1. Cómo definir un evento (src/events/types.ts) 137 | ```typescript 138 | export interface OrderCreatedEvent extends BaseEvent { 139 | type: 'ORDER_CREATED'; // Nombre único del evento 140 | payload: { // Datos que transporta 141 | order: Order; 142 | }; 143 | } 144 | ``` 145 | 146 | ### 2. Cómo publicar eventos (src/services/OrderService.ts) 147 | ```typescript 148 | // Guardar el pedido 149 | this.repository.saveOrder(order); 150 | 151 | // Avisar al mundo que se creó un pedido 152 | await this.eventBus.publish({ 153 | type: 'ORDER_CREATED', 154 | payload: { order } 155 | }); 156 | ``` 157 | 158 | ### 3. Cómo escuchar eventos (src/handlers/OrderEventHandlers.ts) 159 | ```typescript 160 | public async handleOrderCreated(event: OrderCreatedEvent): Promise { 161 | const { order } = event.payload; 162 | 163 | // Reaccionar al evento: iniciar el pago 164 | await this.paymentService.initiatePayment({ 165 | orderId: order.id, 166 | amount: order.totalAmount, 167 | method: 'tarjeta_credito' 168 | }); 169 | } 170 | ``` 171 | 172 | ## 💡 Desafíos para Practicar 173 | 174 | ### Nivel Principiante 175 | 1. **Agregar un nuevo campo**: Añade un campo `customerNotes` al modelo Order 176 | 2. **Nuevo endpoint**: Crea `GET /api/orders/recent` que devuelva los últimos 5 pedidos 177 | 3. **Filtros**: Modifica `GET /api/orders` para filtrar por estado 178 | 179 | ### Nivel Intermedio 180 | 4. **Nuevo evento**: Crea un evento `ORDER_CANCELLED_BY_RESTAURANT` 181 | 5. **Nuevo handler**: Implementa un handler que envíe notificaciones (simuladas) 182 | 6. **Validaciones**: Añade validación para que no se puedan hacer pedidos de más de €100 183 | 184 | ### Nivel Avanzado 185 | 7. **Nuevo servicio**: Crea un `NotificationService` que se suscriba a múltiples eventos 186 | 8. **Rollback**: Implementa reversión automática cuando falla un pago 187 | 9. **Métricas**: Agrega un endpoint que calcule el tiempo promedio de entrega 188 | 189 | ## 🐛 Debugging y Logs 190 | 191 | ### Ver qué está pasando 192 | ```bash 193 | # Los logs del servidor te dirán todo 194 | # Busca estos símbolos: 195 | # 📢 = Evento publicado 196 | # 🔄 = Handler procesando 197 | # ✅ = Handler completado 198 | # ❌ = Error en handler 199 | ``` 200 | 201 | ### Endpoints útiles para debugging 202 | - `GET /api/events/stats` - Estadísticas de eventos 203 | - `GET /api/events/type/ORDER_CREATED` - Solo eventos de un tipo 204 | - `DELETE /api/events/history` - Limpiar historial para pruebas 205 | 206 | ## 🤔 Preguntas de Reflexión 207 | 208 | 1. **¿Qué pasaría si un handler falla?** Busca en EventBus.ts cómo se maneja esto. 209 | 210 | 2. **¿Cómo agregarías un nuevo servicio sin modificar código existente?** Piensa en términos de eventos. 211 | 212 | 3. **¿Qué eventos agregarías para un sistema de calificaciones?** Diseña el flujo completo. 213 | 214 | 4. **¿Cómo implementarías notificaciones push?** ¿Qué eventos necesitarías escuchar? 215 | 216 | ## 📚 Conceptos Teóricos Aplicados 217 | 218 | ### Patrón Observer 219 | - **EventBus** = Subject 220 | - **Handlers** = Observers 221 | - **Eventos** = Notifications 222 | 223 | ### Patrón Singleton 224 | - EventBus y Repository son únicos en toda la aplicación 225 | - ¿Por qué es importante esto? 226 | 227 | ### Separación de Responsabilidades 228 | - Services = Lógica de negocio 229 | - Handlers = Reacciones a eventos 230 | - Routes = Interfaz HTTP 231 | - Models = Estructura de datos 232 | 233 | ## 🚀 Próximos Pasos 234 | 235 | Una vez que domines este proyecto, estarás listo para: 236 | - Implementar Event Sourcing completo 237 | - Usar message queues reales (RabbitMQ, Apache Kafka) 238 | - Microservicios con eventos distribuidos 239 | - CQRS (Command Query Responsibility Segregation) 240 | 241 | ## 📬 Ejercicio Final: Tu Propio Evento 242 | 243 | **Reto**: Implementa un sistema de cupones de descuento que: 244 | 1. Se active cuando un usuario haga su 5° pedido 245 | 2. Genere un evento `LOYALTY_REWARD_EARNED` 246 | 3. Aplique automáticamente un 15% de descuento en el siguiente pedido 247 | 248 | **Pistas**: 249 | - ¿Qué eventos necesitas escuchar? 250 | - ¿Dónde guardarías el contador de pedidos? 251 | - ¿Cómo modificarías el cálculo del precio? 252 | 253 | --- 254 | 255 | ## 💬 Notas del Profesor 256 | 257 | Este proyecto simula decisiones reales que toman empresas como Uber, Netflix o Amazon. Cada evento que implementamos resuelve un problema de escalabilidad o mantenibilidad que enfrentarían en producción. 258 | 259 | **Recuerda**: En programación, como en la vida, es mejor que los componentes cooperen sin depender unos de otros. Los eventos son tu herramienta para lograr esa independencia. 260 | 261 | ¡Diviértete explorando y haciendo experimentos! 🎉 -------------------------------------------------------------------------------- /src/repositories/InMemoryRepository.ts: -------------------------------------------------------------------------------- 1 | import { User, Restaurant, Order, Payment, Delivery, Driver, MenuItem } from '../models'; 2 | 3 | export class InMemoryRepository { 4 | private static instance: InMemoryRepository; 5 | 6 | // Almacenes de datos en memoria 7 | private users: Map = new Map(); 8 | private restaurants: Map = new Map(); 9 | private orders: Map = new Map(); 10 | private payments: Map = new Map(); 11 | private deliveries: Map = new Map(); 12 | private drivers: Map = new Map(); 13 | 14 | private constructor() { 15 | this.seedData(); 16 | } 17 | 18 | public static getInstance(): InMemoryRepository { 19 | if (!InMemoryRepository.instance) { 20 | InMemoryRepository.instance = new InMemoryRepository(); 21 | } 22 | return InMemoryRepository.instance; 23 | } 24 | 25 | // Users 26 | public getUser(id: string): User | undefined { 27 | return this.users.get(id); 28 | } 29 | 30 | public getUserByEmail(email: string): User | undefined { 31 | return Array.from(this.users.values()).find(user => user.email === email); 32 | } 33 | 34 | public saveUser(user: User): void { 35 | this.users.set(user.id, user); 36 | } 37 | 38 | public getAllUsers(): User[] { 39 | return Array.from(this.users.values()); 40 | } 41 | 42 | // Restaurants 43 | public getRestaurant(id: string): Restaurant | undefined { 44 | return this.restaurants.get(id); 45 | } 46 | 47 | public saveRestaurant(restaurant: Restaurant): void { 48 | this.restaurants.set(restaurant.id, restaurant); 49 | } 50 | 51 | public getAllRestaurants(): Restaurant[] { 52 | return Array.from(this.restaurants.values()); 53 | } 54 | 55 | public getActiveRestaurants(): Restaurant[] { 56 | return Array.from(this.restaurants.values()).filter(r => r.isActive); 57 | } 58 | 59 | // Orders 60 | public getOrder(id: string): Order | undefined { 61 | return this.orders.get(id); 62 | } 63 | 64 | public saveOrder(order: Order): void { 65 | this.orders.set(order.id, order); 66 | } 67 | 68 | public getAllOrders(): Order[] { 69 | return Array.from(this.orders.values()); 70 | } 71 | 72 | public getOrdersByUser(userId: string): Order[] { 73 | return Array.from(this.orders.values()).filter(order => order.userId === userId); 74 | } 75 | 76 | public getOrdersByRestaurant(restaurantId: string): Order[] { 77 | return Array.from(this.orders.values()).filter(order => order.restaurantId === restaurantId); 78 | } 79 | 80 | // Payments 81 | public getPayment(id: string): Payment | undefined { 82 | return this.payments.get(id); 83 | } 84 | 85 | public getPaymentByOrderId(orderId: string): Payment | undefined { 86 | return Array.from(this.payments.values()).find(payment => payment.orderId === orderId); 87 | } 88 | 89 | public savePayment(payment: Payment): void { 90 | this.payments.set(payment.id, payment); 91 | } 92 | 93 | public getAllPayments(): Payment[] { 94 | return Array.from(this.payments.values()); 95 | } 96 | 97 | // Deliveries 98 | public getDelivery(id: string): Delivery | undefined { 99 | return this.deliveries.get(id); 100 | } 101 | 102 | public getDeliveryByOrderId(orderId: string): Delivery | undefined { 103 | return Array.from(this.deliveries.values()).find(delivery => delivery.orderId === orderId); 104 | } 105 | 106 | public saveDelivery(delivery: Delivery): void { 107 | this.deliveries.set(delivery.id, delivery); 108 | } 109 | 110 | public getAllDeliveries(): Delivery[] { 111 | return Array.from(this.deliveries.values()); 112 | } 113 | 114 | // Drivers 115 | public getDriver(id: string): Driver | undefined { 116 | return this.drivers.get(id); 117 | } 118 | 119 | public saveDriver(driver: Driver): void { 120 | this.drivers.set(driver.id, driver); 121 | } 122 | 123 | public getAllDrivers(): Driver[] { 124 | return Array.from(this.drivers.values()); 125 | } 126 | 127 | public getAvailableDrivers(): Driver[] { 128 | return Array.from(this.drivers.values()).filter(driver => driver.isAvailable); 129 | } 130 | 131 | // Seed data 132 | private seedData(): void { 133 | this.seedUsers(); 134 | this.seedRestaurants(); 135 | this.seedDrivers(); 136 | } 137 | 138 | private seedUsers(): void { 139 | const users: User[] = [ 140 | { 141 | id: 'user-1', 142 | name: 'Juan Pérez', 143 | email: 'juan@example.com', 144 | phone: '+34 600 123 456', 145 | address: { 146 | street: 'Calle Mayor 123', 147 | city: 'Madrid', 148 | state: 'Madrid', 149 | zipCode: '28001', 150 | coordinates: { lat: 40.4168, lng: -3.7038 } 151 | }, 152 | createdAt: new Date() 153 | }, 154 | { 155 | id: 'user-2', 156 | name: 'María García', 157 | email: 'maria@example.com', 158 | phone: '+34 600 654 321', 159 | address: { 160 | street: 'Avenida Libertad 45', 161 | city: 'Barcelona', 162 | state: 'Cataluña', 163 | zipCode: '08001', 164 | coordinates: { lat: 41.3851, lng: 2.1734 } 165 | }, 166 | createdAt: new Date() 167 | } 168 | ]; 169 | 170 | users.forEach(user => this.saveUser(user)); 171 | } 172 | 173 | private seedRestaurants(): void { 174 | const restaurants: Restaurant[] = [ 175 | { 176 | id: 'restaurant-1', 177 | name: 'La Pizzería Italiana', 178 | description: 'Auténtica comida italiana con ingredientes importados', 179 | address: { 180 | street: 'Calle de la Pizza 10', 181 | city: 'Madrid', 182 | state: 'Madrid', 183 | zipCode: '28002', 184 | coordinates: { lat: 40.4200, lng: -3.7000 } 185 | }, 186 | phone: '+34 91 123 4567', 187 | rating: 4.5, 188 | isActive: true, 189 | menu: [ 190 | { 191 | id: 'item-1', 192 | name: 'Pizza Margherita', 193 | description: 'Tomate, mozzarella y albahaca fresca', 194 | price: 12.50, 195 | category: 'Pizzas', 196 | isAvailable: true, 197 | preparationTime: 15 198 | }, 199 | { 200 | id: 'item-2', 201 | name: 'Pasta Carbonara', 202 | description: 'Pasta con huevo, panceta y queso parmesano', 203 | price: 14.00, 204 | category: 'Pastas', 205 | isAvailable: true, 206 | preparationTime: 10 207 | } 208 | ], 209 | createdAt: new Date() 210 | }, 211 | { 212 | id: 'restaurant-2', 213 | name: 'Sushi Tokyo', 214 | description: 'Sushi fresco y auténtica cocina japonesa', 215 | address: { 216 | street: 'Calle del Sushi 25', 217 | city: 'Barcelona', 218 | state: 'Cataluña', 219 | zipCode: '08002', 220 | coordinates: { lat: 41.3900, lng: 2.1800 } 221 | }, 222 | phone: '+34 93 987 6543', 223 | rating: 4.8, 224 | isActive: true, 225 | menu: [ 226 | { 227 | id: 'item-3', 228 | name: 'Sashimi Variado', 229 | description: 'Selección de pescado fresco cortado en láminas', 230 | price: 18.00, 231 | category: 'Sashimi', 232 | isAvailable: true, 233 | preparationTime: 5 234 | }, 235 | { 236 | id: 'item-4', 237 | name: 'Maki California', 238 | description: 'Rollo con cangrejo, aguacate y pepino', 239 | price: 8.50, 240 | category: 'Makis', 241 | isAvailable: true, 242 | preparationTime: 8 243 | } 244 | ], 245 | createdAt: new Date() 246 | } 247 | ]; 248 | 249 | restaurants.forEach(restaurant => this.saveRestaurant(restaurant)); 250 | } 251 | 252 | private seedDrivers(): void { 253 | const drivers: Driver[] = [ 254 | { 255 | id: 'driver-1', 256 | name: 'Carlos Rodríguez', 257 | email: 'carlos@delivery.com', 258 | phone: '+34 600 111 222', 259 | vehicleType: 'Motocicleta', 260 | rating: 4.7, 261 | isAvailable: true, 262 | currentLocation: { lat: 40.4168, lng: -3.7038 }, 263 | createdAt: new Date() 264 | }, 265 | { 266 | id: 'driver-2', 267 | name: 'Ana López', 268 | email: 'ana@delivery.com', 269 | phone: '+34 600 333 444', 270 | vehicleType: 'Bicicleta', 271 | rating: 4.9, 272 | isAvailable: true, 273 | currentLocation: { lat: 41.3851, lng: 2.1734 }, 274 | createdAt: new Date() 275 | } 276 | ]; 277 | 278 | drivers.forEach(driver => this.saveDriver(driver)); 279 | } 280 | 281 | // Utility methods 282 | public clearAll(): void { 283 | this.users.clear(); 284 | this.restaurants.clear(); 285 | this.orders.clear(); 286 | this.payments.clear(); 287 | this.deliveries.clear(); 288 | this.drivers.clear(); 289 | } 290 | 291 | public getStats(): { 292 | users: number; 293 | restaurants: number; 294 | orders: number; 295 | payments: number; 296 | deliveries: number; 297 | drivers: number; 298 | } { 299 | return { 300 | users: this.users.size, 301 | restaurants: this.restaurants.size, 302 | orders: this.orders.size, 303 | payments: this.payments.size, 304 | deliveries: this.deliveries.size, 305 | drivers: this.drivers.size 306 | }; 307 | } 308 | } -------------------------------------------------------------------------------- /src/services/DeliveryService.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { Delivery, DeliveryStatus, Driver, Order } from '../models'; 3 | import { InMemoryRepository } from '../repositories/InMemoryRepository'; 4 | import { EventBus } from '../events/EventBus'; 5 | import { 6 | DeliveryCreatedEvent, 7 | DriverAssignedEvent, 8 | OrderPickedUpEvent, 9 | OrderInTransitEvent, 10 | OrderDeliveredEvent, 11 | DeliveryFailedEvent 12 | } from '../events/types'; 13 | 14 | export class DeliveryService { 15 | private repository: InMemoryRepository; 16 | private eventBus: EventBus; 17 | 18 | constructor() { 19 | this.repository = InMemoryRepository.getInstance(); 20 | this.eventBus = EventBus.getInstance(); 21 | } 22 | 23 | /** 24 | * Crea una entrega para un pedido 25 | */ 26 | public async createDelivery(orderId: string): Promise { 27 | const order = this.repository.getOrder(orderId); 28 | if (!order) { 29 | throw new Error('Pedido no encontrado'); 30 | } 31 | 32 | const restaurant = this.repository.getRestaurant(order.restaurantId); 33 | if (!restaurant) { 34 | throw new Error('Restaurante no encontrado'); 35 | } 36 | 37 | // Verificar que no existe ya una entrega para este pedido 38 | const existingDelivery = this.repository.getDeliveryByOrderId(orderId); 39 | if (existingDelivery) { 40 | throw new Error('Ya existe una entrega para este pedido'); 41 | } 42 | 43 | // Crear la entrega 44 | const delivery: Delivery = { 45 | id: uuidv4(), 46 | orderId, 47 | status: DeliveryStatus.PENDING, 48 | pickupAddress: restaurant.address, 49 | deliveryAddress: order.deliveryAddress, 50 | createdAt: new Date(), 51 | updatedAt: new Date() 52 | }; 53 | 54 | this.repository.saveDelivery(delivery); 55 | 56 | // Publicar evento 57 | await this.eventBus.publish({ 58 | type: 'DELIVERY_CREATED', 59 | payload: { delivery } 60 | }); 61 | 62 | console.log(`🚚 Entrega creada: ${delivery.id} para pedido ${orderId}`); 63 | return delivery; 64 | } 65 | 66 | /** 67 | * Asigna un repartidor a una entrega 68 | */ 69 | public async assignDriver(deliveryId: string, driverId?: string): Promise { 70 | const delivery = this.repository.getDelivery(deliveryId); 71 | if (!delivery) { 72 | throw new Error('Entrega no encontrada'); 73 | } 74 | 75 | if (delivery.status !== DeliveryStatus.PENDING) { 76 | throw new Error(`No se puede asignar repartidor a entrega en estado ${delivery.status}`); 77 | } 78 | 79 | let driver: Driver | undefined; 80 | 81 | if (driverId) { 82 | // Asignar repartidor específico 83 | driver = this.repository.getDriver(driverId); 84 | if (!driver) { 85 | throw new Error('Repartidor no encontrado'); 86 | } 87 | if (!driver.isAvailable) { 88 | throw new Error('Repartidor no disponible'); 89 | } 90 | } else { 91 | // Encontrar repartidor disponible automáticamente 92 | const availableDrivers = this.repository.getAvailableDrivers(); 93 | if (availableDrivers.length === 0) { 94 | throw new Error('No hay repartidores disponibles'); 95 | } 96 | 97 | // Seleccionar el repartidor con mejor rating 98 | driver = availableDrivers.reduce((best, current) => 99 | current.rating > best.rating ? current : best 100 | ); 101 | } 102 | 103 | // Actualizar entrega y repartidor 104 | delivery.driverId = driver.id; 105 | delivery.status = DeliveryStatus.ASSIGNED; 106 | delivery.estimatedTime = 30; // 30 minutos estimados 107 | delivery.updatedAt = new Date(); 108 | 109 | driver.isAvailable = false; 110 | 111 | this.repository.saveDelivery(delivery); 112 | this.repository.saveDriver(driver); 113 | 114 | // Calcular tiempo estimado de llegada 115 | const estimatedArrival = new Date(); 116 | estimatedArrival.setMinutes(estimatedArrival.getMinutes() + (delivery.estimatedTime || 30)); 117 | 118 | // Publicar evento 119 | await this.eventBus.publish({ 120 | type: 'DRIVER_ASSIGNED', 121 | payload: { 122 | deliveryId: delivery.id, 123 | driverId: driver.id, 124 | orderId: delivery.orderId, 125 | estimatedArrival 126 | } 127 | }); 128 | 129 | console.log(`👨‍💼 Repartidor ${driver.name} asignado a entrega ${delivery.id}`); 130 | return delivery; 131 | } 132 | 133 | /** 134 | * Marca el pedido como recogido 135 | */ 136 | public async markOrderPickedUp(deliveryId: string): Promise { 137 | const delivery = this.repository.getDelivery(deliveryId); 138 | if (!delivery) { 139 | throw new Error('Entrega no encontrada'); 140 | } 141 | 142 | if (delivery.status !== DeliveryStatus.ASSIGNED) { 143 | throw new Error(`No se puede marcar como recogido una entrega en estado ${delivery.status}`); 144 | } 145 | 146 | if (!delivery.driverId) { 147 | throw new Error('La entrega no tiene repartidor asignado'); 148 | } 149 | 150 | // Actualizar estado 151 | delivery.status = DeliveryStatus.PICKED_UP; 152 | delivery.updatedAt = new Date(); 153 | this.repository.saveDelivery(delivery); 154 | 155 | const pickupTime = new Date(); 156 | 157 | // Publicar evento 158 | await this.eventBus.publish({ 159 | type: 'ORDER_PICKED_UP', 160 | payload: { 161 | deliveryId: delivery.id, 162 | orderId: delivery.orderId, 163 | driverId: delivery.driverId, 164 | pickupTime 165 | } 166 | }); 167 | 168 | console.log(`📦 Pedido ${delivery.orderId} recogido por repartidor`); 169 | return delivery; 170 | } 171 | 172 | /** 173 | * Marca el pedido en tránsito 174 | */ 175 | public async markOrderInTransit(deliveryId: string): Promise { 176 | const delivery = this.repository.getDelivery(deliveryId); 177 | if (!delivery) { 178 | throw new Error('Entrega no encontrada'); 179 | } 180 | 181 | if (delivery.status !== DeliveryStatus.PICKED_UP) { 182 | throw new Error(`No se puede marcar en tránsito una entrega en estado ${delivery.status}`); 183 | } 184 | 185 | // Actualizar estado 186 | delivery.status = DeliveryStatus.IN_TRANSIT; 187 | delivery.updatedAt = new Date(); 188 | this.repository.saveDelivery(delivery); 189 | 190 | const estimatedDeliveryTime = new Date(); 191 | estimatedDeliveryTime.setMinutes(estimatedDeliveryTime.getMinutes() + 15); // 15 min estimados para entrega 192 | 193 | // Publicar evento 194 | await this.eventBus.publish({ 195 | type: 'ORDER_IN_TRANSIT', 196 | payload: { 197 | deliveryId: delivery.id, 198 | orderId: delivery.orderId, 199 | driverId: delivery.driverId!, 200 | estimatedDeliveryTime 201 | } 202 | }); 203 | 204 | console.log(`🚚 Pedido ${delivery.orderId} en tránsito`); 205 | return delivery; 206 | } 207 | 208 | /** 209 | * Marca el pedido como entregado 210 | */ 211 | public async markOrderDelivered(deliveryId: string): Promise { 212 | const delivery = this.repository.getDelivery(deliveryId); 213 | if (!delivery) { 214 | throw new Error('Entrega no encontrada'); 215 | } 216 | 217 | if (delivery.status !== DeliveryStatus.IN_TRANSIT) { 218 | throw new Error(`No se puede marcar como entregado una entrega en estado ${delivery.status}`); 219 | } 220 | 221 | if (!delivery.driverId) { 222 | throw new Error('La entrega no tiene repartidor asignado'); 223 | } 224 | 225 | // Actualizar entrega 226 | delivery.status = DeliveryStatus.DELIVERED; 227 | delivery.actualDeliveryTime = new Date(); 228 | delivery.updatedAt = new Date(); 229 | 230 | // Liberar repartidor 231 | const driver = this.repository.getDriver(delivery.driverId); 232 | if (driver) { 233 | driver.isAvailable = true; 234 | this.repository.saveDriver(driver); 235 | } 236 | 237 | this.repository.saveDelivery(delivery); 238 | 239 | // Publicar evento 240 | await this.eventBus.publish({ 241 | type: 'ORDER_DELIVERED', 242 | payload: { 243 | deliveryId: delivery.id, 244 | orderId: delivery.orderId, 245 | driverId: delivery.driverId, 246 | deliveryTime: delivery.actualDeliveryTime 247 | } 248 | }); 249 | 250 | console.log(`✅ Pedido ${delivery.orderId} entregado exitosamente`); 251 | return delivery; 252 | } 253 | 254 | /** 255 | * Marca la entrega como fallida 256 | */ 257 | public async markDeliveryFailed(deliveryId: string, reason: string): Promise { 258 | const delivery = this.repository.getDelivery(deliveryId); 259 | if (!delivery) { 260 | throw new Error('Entrega no encontrada'); 261 | } 262 | 263 | const validStatuses = [DeliveryStatus.ASSIGNED, DeliveryStatus.PICKED_UP, DeliveryStatus.IN_TRANSIT]; 264 | if (!validStatuses.includes(delivery.status)) { 265 | throw new Error(`No se puede marcar como fallida una entrega en estado ${delivery.status}`); 266 | } 267 | 268 | // Liberar repartidor si estaba asignado 269 | if (delivery.driverId) { 270 | const driver = this.repository.getDriver(delivery.driverId); 271 | if (driver) { 272 | driver.isAvailable = true; 273 | this.repository.saveDriver(driver); 274 | } 275 | } 276 | 277 | // Actualizar entrega 278 | delivery.status = DeliveryStatus.FAILED; 279 | delivery.updatedAt = new Date(); 280 | this.repository.saveDelivery(delivery); 281 | 282 | // Publicar evento 283 | await this.eventBus.publish({ 284 | type: 'DELIVERY_FAILED', 285 | payload: { 286 | deliveryId: delivery.id, 287 | orderId: delivery.orderId, 288 | driverId: delivery.driverId, 289 | reason 290 | } 291 | }); 292 | 293 | console.log(`❌ Entrega ${delivery.id} marcada como fallida: ${reason}`); 294 | return delivery; 295 | } 296 | 297 | /** 298 | * Obtiene una entrega por ID 299 | */ 300 | public getDelivery(deliveryId: string): Delivery | undefined { 301 | return this.repository.getDelivery(deliveryId); 302 | } 303 | 304 | /** 305 | * Obtiene una entrega por ID de pedido 306 | */ 307 | public getDeliveryByOrderId(orderId: string): Delivery | undefined { 308 | return this.repository.getDeliveryByOrderId(orderId); 309 | } 310 | 311 | /** 312 | * Obtiene todas las entregas 313 | */ 314 | public getAllDeliveries(): Delivery[] { 315 | return this.repository.getAllDeliveries(); 316 | } 317 | 318 | /** 319 | * Obtiene estadísticas de entregas 320 | */ 321 | public getDeliveryStats(): { 322 | total: number; 323 | byStatus: Record; 324 | averageDeliveryTime: number; 325 | successRate: number; 326 | } { 327 | const deliveries = this.repository.getAllDeliveries(); 328 | const byStatus: Record = { 329 | [DeliveryStatus.PENDING]: 0, 330 | [DeliveryStatus.ASSIGNED]: 0, 331 | [DeliveryStatus.PICKED_UP]: 0, 332 | [DeliveryStatus.IN_TRANSIT]: 0, 333 | [DeliveryStatus.DELIVERED]: 0, 334 | [DeliveryStatus.FAILED]: 0 335 | }; 336 | 337 | let totalDeliveryTime = 0; 338 | let deliveredCount = 0; 339 | 340 | deliveries.forEach(delivery => { 341 | byStatus[delivery.status]++; 342 | 343 | if (delivery.status === DeliveryStatus.DELIVERED && delivery.actualDeliveryTime) { 344 | const deliveryTime = delivery.actualDeliveryTime.getTime() - delivery.createdAt.getTime(); 345 | totalDeliveryTime += deliveryTime / (1000 * 60); // Convertir a minutos 346 | deliveredCount++; 347 | } 348 | }); 349 | 350 | const totalCompleted = byStatus[DeliveryStatus.DELIVERED] + byStatus[DeliveryStatus.FAILED]; 351 | const successRate = totalCompleted > 0 ? (byStatus[DeliveryStatus.DELIVERED] / totalCompleted) * 100 : 0; 352 | 353 | return { 354 | total: deliveries.length, 355 | byStatus, 356 | averageDeliveryTime: deliveredCount > 0 ? Math.round(totalDeliveryTime / deliveredCount) : 0, 357 | successRate: Math.round(successRate * 100) / 100 358 | }; 359 | } 360 | } --------------------------------------------------------------------------------