import 'dotenv/config'; import express from 'express'; import axios from 'axios'; import { createServer } from 'http'; import { Server } from 'socket.io'; import jwt from 'jsonwebtoken'; import path from 'path'; import cors from 'cors'; import helmet from 'helmet'; import rateLimit from 'express-rate-limit'; import authRoutes from './src/routes/auth.js'; import tenantRoutes from './src/routes/tenant.js'; import analyticsRoutes from './src/routes/analytics.js'; import ownerRoutes from './src/routes/owner.js'; import empresaRoutes from './src/routes/empresa.js'; import userRoutes from './src/routes/users.js'; import logsRoutes from './src/routes/logs.js'; import planesRoutes from './src/routes/planes.js'; import catalogRoutes from './src/routes/catalog.js'; import superCartaRoutes from './src/routes/superCarta.js'; import resourcesRoutes from './src/routes/resources.js'; import internalRoutes from './src/routes/internal.js'; import adminRoutes from './src/routes/admin.js'; import iotRoutes from './src/routes/iot.js'; import ordersRoutes from './src/routes/orders.js'; import notificationsRoutes from './src/routes/notifications.js'; import webhooksRoutes from './src/routes/webhooks.js'; import billingRoutes from './src/routes/billing.js'; import clientRoutes from './src/routes/client.js'; // ... import { initMqttService } from './src/services/mqttService.js'; import { initRedisService } from './src/services/redisService.js'; import whatsappService from './src/services/whatsappService.js'; import { getBranchContacts, getAssignedWaiter } from './src/utils/userUtils.js'; const app = express(); const PORT = process.env.PORT || 3001; // OBLIGATORIO PARA PRODUCCIÓN: Lee la IP real del cliente y no la del Nginx/Cloudflare app.set('trust proxy', 1); // Crear servidor HTTP para Socket.IO const server = createServer(app); const io = new Server(server, { cors: { origin: "*", methods: ["GET", "POST"] }, transports: ['websocket', 'polling'] // Flutter v3 corrige el bug del :0, WebSocket nativo habilitado }); // Middleware de autenticación para Socket.IO io.use(async (socket, next) => { try { const token = socket.handshake.auth?.token || socket.handshake.headers?.token; if (!token) return next(new Error('Authentication error')); const decoded = jwt.verify(token, process.env.JWT_SECRET); socket.userId = decoded.id; socket.userRole = decoded.rol; socket.empresaId = decoded.empresa_id; socket.sucursalId = decoded.sucursal_id || decoded.sucursal_asignada_id; // Obtener RUC para temas de IoT (Persistencia en Laravel) if (socket.empresaId) { try { const { getPool, sql } = await import('./src/config/database.js'); const pool = await getPool(); const result = await pool.request() .input('id', sql.UniqueIdentifier, socket.empresaId) .query("SELECT ruc_nit FROM Nucleo_Empresas WHERE id_empresa = @id"); if (result.recordset.length > 0) { socket.ruc = result.recordset[0].ruc_nit; } } catch (dbErr) { console.error('❌ Error recuperando RUC para socket:', dbErr.message); } } next(); } catch (error) { next(new Error('Authentication error')); } }); // Manejar conexiones Socket.IO io.on('connection', (socket) => { console.log(`🔌 CHISMOSO SOCKET: Usuario conectado: ${socket.userId} (${socket.userRole})`); // Unir usuario a salas base if (socket.empresaId) { socket.join(`empresa_${socket.empresaId}`); console.log(` 🏠 Sala: empresa_${socket.empresaId}`); } if (socket.userId) { socket.join(`user_${socket.userId}`); console.log(` 👤 Sala: user_${socket.userId}`); } if (socket.sucursalId) { socket.join(`branch_${socket.sucursalId}`); console.log(` 🏢 Sala: branch_${socket.sucursalId}`); } if (socket.userRole === 'owner') { socket.join(`dashboard_${socket.empresaId}`); console.log(` 📊 Sala: dashboard_${socket.empresaId}`); } // Evento manual para unirse a sucursal si no vino en el token socket.on('join_branch', (branchId) => { socket.join(`branch_${branchId}`); console.log(`🔌 CHISMOSO SOCKET: Usuario ${socket.userId} se unió MANUALMENTE a sala: branch_${branchId}`); }); socket.on('join_dashboard', () => { socket.join(`dashboard_${socket.empresaId}`); console.log(`🔌 CHISMOSO SOCKET: Usuario ${socket.userId} se unió a Dashboard`); }); // RETRANSMISIÓN DE ATENCIÓN AL CLIENTE (Mesero, Cuenta, Reporte, Utensilios) socket.on('atencion:mesero', async (data) => { const branchId = data.branchId || socket.sucursalId; if (branchId) { const room = `branch_${branchId}`; console.log(`🛎️ RETRANSMISIÓN: Cliente en Rama ${branchId} solicita: ${data.tipo} (${data.mensaje})`); // 1. Emitir localmente para rapidez UI (Soporte para utensilios/otros que no requieren persistencia IoT) io.to(room).emit('atencion:mesero', data); // 2. Persistir en Laravel para que la mesa "se prenda" (ilumine) // Mapear el tipo de la app al 'accion' del IoT Bridge let iotAccion = 'llamar_mesero'; if (data.tipo === 'cuenta') iotAccion = 'pedir_cuenta'; if (data.tipo === 'reporte') iotAccion = 'reclamo'; let ruc = socket.ruc || data.ruc || 'cliente'; const mesaNum = data.mesaNumero; if (ruc && branchId && mesaNum) { try { const laravelUrl = `${process.env.LARAVEL_API}/Empleado_App/Peticiones`; const topic = `/ruc/${ruc}/sucursal/${branchId}/mesa/${mesaNum}`; console.log(`🚀 [Node -> Laravel] Sincronizando atención: ${topic} | Acción: ${iotAccion}`); // 📱 [NUEVO] NOTIFICACIÓN WHATSAPP PARA RECLAMOS DESDE APP // Lo ejecutamos de forma independiente para que no dependa del éxito de Laravel if (data.tipo === 'reporte') { (async () => { try { console.log(`📱 [WhatsApp] Iniciando flujo de notificación para reclamo en mesa ${mesaNum}...`); const contacts = await getBranchContacts(branchId); if (contacts.length > 0) { const mesero = await getAssignedWaiter(branchId, mesaNum); const isDigital = mesero.includes('Digital App'); const branchDisplayName = contacts[0].branchName || branchId; // Enviar a todos los contactos con mensajes personalizados por rol for (const contact of contacts) { const isOwner = contact.role.includes('DUEÑO') || contact.role.includes('OWNER'); const header = isOwner ? '🔔 *NOTIFICACIÓN DE RECLAMO*' : '⚠️ *ALERTA DE RECLAMO - ATENCIÓN INMEDIATA*'; const footer = isOwner ? '_Supervisar atención de este reclamo._' : '_Por favor, atienda este requerimiento lo antes posible._'; const message = `${header}\n📍 *Mesa:* ${mesaNum}\n👷 *Mesero:* ${mesero}\n🏢 *Sucursal:* ${branchDisplayName}\n💬 *Detalle:* ${data.mensaje}\n⚠️ *Nota:* ${isDigital ? 'El cliente se está atendiendo solo desde la App.' : 'Mesa con mesero asignado.'}\n\n${footer}\n\n_Enviado automáticamente por Piter Pro_`; console.log(` 📤 Enviando a ${contact.role}: ${contact.phone}`); await whatsappService.sendMessage(contact.phone, message); } } else { console.warn(`⚠️ [WhatsApp] No se encontraron contactos (Gerente/Encargado) para sucursal ${branchId}`); } } catch (wsErr) { console.error('❌ [WhatsApp] Error en flujo de notificación App:', wsErr.message); } })(); } // Sincronización con Laravel (Independiente) axios.post(laravelUrl, { topic: topic, payload: { accion: iotAccion, producto_nombre: data.mensaje // Passed all the way back to UI } }).catch(err => { console.error('❌ Error persistiendo atención en Laravel:', err.message); }); } catch (err) { console.error('❌ Error en el flujo de atención:', err.message); } } } }); socket.on('leave_dashboard', () => { socket.leave(`dashboard_${socket.empresaId}`); console.log(`🔌 CHISMOSO SOCKET: Usuario ${socket.userId} salió de Dashboard`); }); socket.on('disconnect', () => { console.log(`🔌 CHISMOSO SOCKET: Usuario desconectado: ${socket.userId}`); }); }); // Hacer io disponible globalmente para los controladores global.io = io; // Security middleware // Desactivar Same-Origin de Helmet temporalmente para servir imágenes en otros dominios o en web.php app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); // Servir uploads desde la carpeta compartida (nivel sistema_piter) app.use('/api/Security/uploads', express.static(path.join(process.cwd(), '../../uploads'))); app.use('/api/uploads', express.static(path.join(process.cwd(), '../../uploads'))); app.use('/uploads', express.static(path.join(process.cwd(), '../../uploads'))); app.use(cors({ origin: [ 'https://piterpro.com', 'https://www.piterpro.com', 'https://app.piterpro.com' ], credentials: true, })); // 1. Rate limiting Global (Ajustado para Restaurantes - 4000 peticiones) const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 4000, message: { success: false, message: 'Has superado el límite de peticiones. Espera un momento.' }, standardHeaders: true, legacyHeaders: false, }); app.use(limiter); // 2. Auth specific rate limit (Relajado para evitar bloqueos innecesarios) const authLimiter = rateLimit({ windowMs: 60 * 1000, max: 300, message: { success: false, message: 'Demasiadas peticiones. Espera unos segundos.' }, standardHeaders: true, legacyHeaders: false, }); // Body parsing app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); // --- CHISMOSO INTELIGENTE: Logs seguros para desarrollo uwu --- app.use((req, res, next) => { // 1. Apagado automático de emergencia: Si se olvidan de quitarlo en producción, no pasa nada. if (process.env.NODE_ENV === 'production') return next(); console.log(`\n📡 REQUEST: ${req.method} ${req.originalUrl}`); if (Object.keys(req.body).length > 0) { // 2. SEGURIDAD: Nunca imprimir las rutas de login/auth para no fugar contraseñas o JWTs if (req.originalUrl.includes('/auth') || req.originalUrl.includes('/login')) { console.log('📦 BODY: [DATOS DE AUTENTICACIÓN OCULTOS POR SEGURIDAD 🛡️]'); } // 3. RENDIMIENTO: Evitar que Node.js colapse al intentar imprimir imágenes o JSONs gigantes else if (JSON.stringify(req.body).length > 2000) { console.log('📦 BODY: [PAYLOAD MUY GRANDE PARA LA CONSOLA, REVISA POSTMAN 😅]'); } // 4. DEPURACIÓN: Si es una petición normal, ¡imprime todo bonito! else { console.log('📦 BODY:', JSON.stringify(req.body, null, 2)); } } next(); }); // Routes // APLICAMOS EL CANDADO ANTI-HACKERS SOLO AL LOGIN Y REGISTRO: app.use('/api/auth/login', authLimiter); app.use('/api/auth/register', authLimiter); app.use('/api/auth', authRoutes); app.use('/api/tenant', tenantRoutes); app.use('/api/analytics', analyticsRoutes); app.use('/api/owner', ownerRoutes); app.use('/api/empresas', empresaRoutes); app.use('/api/users', userRoutes); app.use('/api/logs', logsRoutes); app.use('/api/planes', planesRoutes); app.use('/api/catalog', catalogRoutes); app.use('/api/super-carta', superCartaRoutes); app.use('/api/resources', resourcesRoutes); app.use('/api/internal', internalRoutes); app.use('/api/admin', adminRoutes); app.use('/api/iot', iotRoutes); app.use('/api/client', clientRoutes); app.use('/api/orders', ordersRoutes); app.use('/api/webhooks', webhooksRoutes); app.use('/api/Billing', billingRoutes); app.use('/api/Notifications', notificationsRoutes); // --- RUTAS PÚBLICAS WEB (PROXY A LARAVEL VIEWS) --- // Cuando el usuario escanea el QR en producción (e.g. piterpro.com/m/123), // Node pide el HTML a Laravel y lo devuelve al cliente. app.get('/m/:mesaId', async (req, res) => { try { const laravelUrl = process.env.LARAVEL_API || 'https://www.piterpro.com/api/api'; const baseUrl = laravelUrl.replace('/api', ''); // Quitar /api para llegar a web.php const targetUrl = `${baseUrl}/m/${req.params.mesaId}`; console.log(`[CHISMOSO PROXY] 🚀 Solicitando Menú a Laravel en: ${targetUrl}`); // Configurar proxy como navegador HTML const response = await axios.get(targetUrl, { headers: { 'Accept': 'text/html', 'User-Agent': req.headers['user-agent'] || 'NodeProxy' }, responseType: 'arraybuffer' // Aseguramos que el HTML/assets no se rompan por encoding JSON }); if (response.headers['content-type']) { res.setHeader('Content-Type', response.headers['content-type']); } res.status(response.status).send(response.data); } catch (error) { console.error(`❌ Error proxying /m/${req.params.mesaId} to Laravel:`, error.message); if (error.response) { res.status(error.response.status).send(error.response.data); } else { res.status(500).send('Error interno cargando el menú público.'); } } }); // Proxear la llamada al mesero (POST) desde el cliente Web QR app.post('/m/:mesaId/llamar', async (req, res) => { try { const laravelUrl = process.env.LARAVEL_API || 'https://www.piterpro.com/api/api'; const baseUrl = laravelUrl.replace('/api', ''); const targetUrl = `${baseUrl}/m/${req.params.mesaId}/llamar`; console.log(`[CHISMOSO PROXY] 🚀 Llamando mesero en Laravel: ${targetUrl}`); const response = await axios.post(targetUrl, req.body, { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': req.headers['user-agent'] || 'NodeProxy' } }); res.status(response.status).json(response.data); } catch (error) { console.error(`❌ Error proxying POST /m/${req.params.mesaId}/llamar to Laravel:`, error.message); if (error.response) { res.status(error.response.status).json(error.response.data); } else { res.status(500).json({ success: false, message: 'Error interno conectando con el servidor.' }); } } }); // --- PROXY TRANSPARENTE A BLADE DE CONSULTA --- // Usamos un wildcard flexible para capturar /comprobante/ID o /comprobanteID app.get('/comprobante*', async (req, res) => { try { const queryStr = new URLSearchParams(req.query).toString(); // Quitar /api del final de BILLING_API si existe para llegar a las rutas de web.php const billingBaseUrl = (process.env.BILLING_API || '').replace(/\/api$/, ''); // Extraer el ID limpiamente (manejando si viene con o sin slash pegado) let fullPath = req.path; // e.g. "/comprobante000002" o "/comprobante/F001-1" let id = fullPath.replace('/comprobante', ''); if (id.startsWith('/')) id = id.substring(1); // Quitar slash inicial si existe if (!id) { return res.status(400).send('ID de comprobante no proporcionado.'); } const targetUrl = `${billingBaseUrl}/comprobante/${id}${queryStr ? '?' + queryStr : ''}`; console.log(`[CHISMOSO PROXY] 🚀 Redirigiendo consulta de comprobante a: ${targetUrl}`); const response = await axios.get(targetUrl, { headers: { 'Accept': 'text/html', 'User-Agent': req.headers['user-agent'] || 'NodeProxy' }, responseType: 'arraybuffer' }); res.setHeader('Content-Type', 'text/html'); res.status(response.status).send(response.data); } catch (error) { console.error(`❌ Error proxying comprobante to Laravel:`, error.message); res.status(500).send('Error interno cargando la consulta de comprobante.'); } }); // --- PROXY TRANSPARENTE A STORAGE DE LARAVEL (IMÁGENES) --- app.get('/storage/*', async (req, res) => { try { const targetUrl = `https://www.piterpro.com${req.originalUrl}`; const response = await axios.get(targetUrl, { responseType: 'arraybuffer' }); if (response.headers['content-type']) { res.setHeader('Content-Type', response.headers['content-type']); } res.status(response.status).send(response.data); } catch (error) { res.status(404).send('Imagen no encontrada en el storage.'); } }); // Health check app.get('/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString() }); }); // Error handling middleware app.use((err, req, res, next) => { console.error('Error:', err.stack); res.status(500).json({ success: false, message: 'Something went wrong!' }); }); // 404 handler app.use((req, res) => { res.status(404).json({ success: false, message: 'Endpoint not found' }); }); server.listen(PORT, '0.0.0.0', () => { console.log(`API Middleware with WebSocket running on port ${PORT}`); console.log(`Health check: http://localhost:${PORT}/health`); console.log(`WebSocket endpoint: ws://localhost:${PORT}`); // Initialize MQTT Listener initMqttService(); initRedisService(io); });