require('dotenv').config() const express = require('express') const cors = require('cors') const cookieParser = require('cookie-parser') const rateLimit = require('express-rate-limit') const path = require('path') const { initialize } = require('./database/database') const { query, queryOne, execute } = require('./database/postgres') const PluginManager = require('./plugins/index') const { startWorker } = require('./workers/raffleWorker') const { startClubWorker } = require('./workers/clubWorker') require('./workers/whatsappWorker') const PORT = process.env.PORT || 4001 async function startServer() { // Initialize database await initialize(); // Start background workers startWorker(); startClubWorker(); const app = express(); // Middleware const allowedOrigins = (process.env.ALLOWED_ORIGINS || '').split(',').map(s => s.trim()).filter(Boolean) app.use(cors({ origin: allowedOrigins.length ? allowedOrigins : false, credentials: true })); app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8') }, })); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'), { setHeaders(res, filePath) { if (filePath.endsWith('.html')) { // Impede cache de HTML para que o browser sempre busque a versão mais recente res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') res.setHeader('Pragma', 'no-cache') res.setHeader('Expires', '0') } }, })); // Favicon handler to avoid 404 app.get('/favicon.ico', (req, res) => res.status(204).end()); const { verifyToken, verifyUserToken, JWT_SECRET: _JWT_SECRET } = require('./middleware/auth'); const jwt = require('jsonwebtoken'); // Rate limiting — público mais restrito; admins com token válido têm limite maior const publicLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 300, message: { error: 'Muitas requisições' }, skip: (req) => { const token = req.cookies?.cloud_token if (!token) return false try { jwt.verify(token, _JWT_SECRET); return true } catch { return false } }, }); app.use('/api/', publicLimiter); const { verifyAnyToken, verifyTeamToken, signTeamToken: _signTeamToken } = require('./middleware/teamAuth'); const { tenantMiddleware, tenantAdminMiddleware, invalidateTenantCache } = require('./middleware/tenant'); const adminController = require('./controllers/adminController'); const sseService = require('./services/sse'); // Aplica detecção de tenant em todas as rotas /api/ e páginas públicas app.use('/api/', tenantMiddleware); app.use('/', tenantMiddleware); // API Routes const apiUsers = require('./routes/apiUsers'); const apiPromotions = require('./routes/apiPromotions'); const apiRaffle = require('./routes/apiRaffle'); const apiAnalytics = require('./routes/apiAnalytics'); const analyticsController = require('./controllers/analyticsController'); const webhookController = require('./controllers/webhookController'); const apiClientes = require('./routes/apiClientes'); const apiProdutos = require('./routes/apiProdutos'); const apiCotacoes = require('./routes/apiCotacoes'); const apiWhatsappTools = require('./routes/apiWhatsappTools'); const apiRecorrentes = require('./routes/apiRecorrentes'); app.use('/api', apiUsers); app.use('/api/promotions', apiPromotions); app.use('/api/raffle', apiRaffle); app.use('/api/analytics', apiAnalytics); app.post('/api/webhooks/asaas', webhookController.asaasWebhook); // Módulo Cotação Online app.use('/api/clientes', apiClientes); app.use('/api/produtos', apiProdutos); app.use('/api/cotacoes', apiCotacoes); app.use('/api/recorrentes', apiRecorrentes); // Ferramentas WhatsApp IA (WPP-05/06/07/09) app.use('/api/whatsapp', apiWhatsappTools); // Club routes const clubController = require('./controllers/clubController'); app.get('/api/clubs', clubController.listClubs); app.get('/api/clubs/card/:cpf', clubController.getCard); app.get('/api/clubs/:slug/faqs', clubController.listFaqsBySlug); app.get('/api/clubs/:slug', clubController.getClub); app.get('/api/clubs/:slug/partners', clubController.listPartnersBySlug); app.get('/api/clubs/:slug/validate/:cpf', clubController.validateMember); app.post('/api/clubs/:slug/subscribe', clubController.subscribe); app.post('/api/clubs/member/cancel', verifyUserToken, clubController.cancelMembership); // Club reports app.get('/api/admin/club/reports', verifyToken, async (req, res) => { try { const { query: dbQuery } = require('./database/postgres') const tenantId = req.tenant?.id || 1 // MRR por mês (últimos 12 meses) const mrr = await dbQuery( `SELECT TO_CHAR(DATE_TRUNC('month', cm.started_at), 'YYYY-MM') AS month, bc.id AS club_id, bc.name AS club_name, SUM(cp.price_monthly) AS mrr, COUNT(cm.id) AS members FROM club_members cm JOIN benefit_clubs bc ON bc.id = cm.club_id JOIN club_plans cp ON cp.id = cm.plan_id WHERE cm.tenant_id = $1 AND cm.started_at >= NOW() - INTERVAL '12 months' AND cm.status NOT IN ('canceled') GROUP BY 1,2,3 ORDER BY 1`, [tenantId] ) // Novos vs cancelamentos por mês const growth = await dbQuery( `SELECT TO_CHAR(DATE_TRUNC('month', started_at), 'YYYY-MM') AS month, COUNT(*) AS new_members FROM club_members WHERE tenant_id = $1 AND started_at >= NOW() - INTERVAL '12 months' GROUP BY 1 ORDER BY 1`, [tenantId] ) const churn = await dbQuery( `SELECT TO_CHAR(DATE_TRUNC('month', canceled_at), 'YYYY-MM') AS month, COUNT(*) AS canceled FROM club_members WHERE tenant_id = $1 AND canceled_at >= NOW() - INTERVAL '12 months' GROUP BY 1 ORDER BY 1`, [tenantId] ) // Distribuição de planos const planDist = await dbQuery( `SELECT bc.name AS club_name, cp.name AS plan_name, COUNT(cm.id) AS total FROM club_members cm JOIN benefit_clubs bc ON bc.id = cm.club_id JOIN club_plans cp ON cp.id = cm.plan_id WHERE cm.tenant_id = $1 AND cm.status = 'active' GROUP BY 1,2`, [tenantId] ) // Inadimplência const overdue = await dbQuery( `SELECT bc.name AS club_name, COUNT(CASE WHEN cm.status = 'suspended' THEN 1 END) AS suspended, COUNT(CASE WHEN cm.status = 'active' THEN 1 END) AS active FROM club_members cm JOIN benefit_clubs bc ON bc.id = cm.club_id WHERE cm.tenant_id = $1 GROUP BY 1`, [tenantId] ) res.json({ mrr: mrr ?? [], growth: growth ?? [], churn: churn ?? [], planDist: planDist ?? [], overdue: overdue ?? [], }) } catch (err) { console.error('[club reports]', err.message) res.status(500).json({ error: 'Erro interno' }) } }) // Admin club management app.get('/api/admin/club/members', verifyToken, clubController.adminListMembers); app.patch('/api/admin/club/members/:id', verifyToken, clubController.adminUpdateMemberStatus); app.get('/api/admin/club/partners', verifyToken, clubController.adminListPartners); app.post('/api/admin/club/partners', verifyToken, clubController.adminCreatePartner); app.put('/api/admin/club/partners/:id', verifyToken, clubController.adminUpdatePartner); app.delete('/api/admin/club/partners/:id', verifyToken, clubController.adminDeletePartner); // Admin club — planos app.get('/api/admin/club/plans', verifyToken, clubController.adminListPlans); app.post('/api/admin/club/plans', verifyToken, clubController.adminCreatePlan); app.put('/api/admin/club/plans/:id', verifyToken, clubController.adminUpdatePlan); app.delete('/api/admin/club/plans/:id', verifyToken, clubController.adminDeletePlan); // Admin club — conteúdo (condições + regras) app.put('/api/admin/club/:id/content', verifyToken, clubController.adminSaveContent); // Admin club — FAQs app.get('/api/admin/club/faqs', verifyToken, clubController.adminListFaqs); app.post('/api/admin/club/faqs', verifyToken, clubController.adminCreateFaq); app.put('/api/admin/club/faqs/:id', verifyToken, clubController.adminUpdateFaq); app.delete('/api/admin/club/faqs/:id', verifyToken, clubController.adminDeleteFaq); // Asaas club webhook app.post('/api/webhooks/asaas/club', clubController.asaasClubWebhook); // ── Banco de Talentos ───────────────────────────────────────────────────── const jobController = require('./controllers/jobController'); // Público app.get('/api/jobs', jobController.listJobs); app.get('/api/jobs/:id', jobController.getJob); app.post('/api/jobs/:id/apply', jobController.applyToJob); // Candidato autenticado app.get('/api/user/resume', verifyUserToken, jobController.getMyResume); app.post('/api/user/resume', verifyUserToken, jobController.saveMyResume); app.put('/api/user/resume', verifyUserToken, jobController.saveMyResume); app.get('/api/user/applications',verifyUserToken, jobController.getMyApplications); app.delete('/api/user/applications/:id', verifyUserToken, jobController.cancelMyApplication); // Admin app.get('/api/admin/jobs/kpi', verifyToken, jobController.adminJobsKpi); app.get('/api/admin/jobs', verifyToken, jobController.adminListJobs); app.post('/api/admin/jobs', verifyToken, jobController.adminCreateJob); app.put('/api/admin/jobs/:id', verifyToken, jobController.adminUpdateJob); app.patch('/api/admin/jobs/:id/toggle', verifyToken, jobController.adminToggleJob); app.delete('/api/admin/jobs/:id', verifyToken, jobController.adminDeleteJob); app.get('/api/admin/jobs/:id/applications', verifyToken, jobController.adminGetApplications); app.get('/api/admin/applications/:id', verifyToken, jobController.adminGetApplication); app.patch('/api/admin/applications/:id', verifyToken, jobController.adminUpdateApplication); app.get('/api/admin/resumes', verifyToken, jobController.adminSearchResumes); app.get('/api/admin/resumes/:cpf', verifyToken, jobController.adminGetResume); // ── Fase 12 — Equipe Virtual ────────────────────────────────────────────── const equipeCtrl = require('./controllers/equipeController') const nwTeamsCtrl = require('./controllers/nwTeamsController') // nw-teams: proxy de sectors/teams do WA-Inbox app.get('/api/admin/nw-teams', verifyAnyToken, nwTeamsCtrl.listNwTeams) // Perfil do colaborador logado (admin OU team) — rota exclusiva para a // gestão de equipe no painel. NÃO pode ser /api/admin/me: o Express usa a // primeira rota registrada, e /api/admin/me já é registrada mais abaixo // (verifyToken → dados do admin + branding do tenant). Qualquer duplicata // aqui interceptaria aquela rota, causando 401/404 para admins legítimos. app.get('/api/admin/equipe/me', verifyAnyToken, equipeCtrl.me) // accounts app.get ('/api/admin/accounts', verifyToken, equipeCtrl.listAccounts) app.get ('/api/admin/accounts/:id', verifyToken, equipeCtrl.getAccount) app.post ('/api/admin/accounts', verifyToken, equipeCtrl.createAccount) app.put ('/api/admin/accounts/:id', verifyToken, equipeCtrl.updateAccount) app.delete('/api/admin/accounts/:id', verifyToken, equipeCtrl.deleteAccount) // sectors app.get ('/api/admin/sectors', verifyToken, equipeCtrl.listSectors) app.get ('/api/admin/sectors/:id', verifyToken, equipeCtrl.getSector) app.post ('/api/admin/sectors', verifyToken, equipeCtrl.createSector) app.put ('/api/admin/sectors/:id', verifyToken, equipeCtrl.updateSector) app.delete('/api/admin/sectors/:id', verifyToken, equipeCtrl.deleteSector) // team members app.get ('/api/admin/team-members', verifyToken, equipeCtrl.listTeamMembers) app.post ('/api/admin/team-members', verifyToken, equipeCtrl.addTeamMember) app.put ('/api/admin/team-members/:id',verifyToken, equipeCtrl.updateTeamMember) app.delete('/api/admin/team-members/:id',verifyToken, equipeCtrl.removeTeamMember) // sector brain nodes app.get ('/api/admin/sectors/:sectorId/brain', verifyToken, equipeCtrl.listSectorBrains) app.post ('/api/admin/sectors/:sectorId/brain', verifyToken, equipeCtrl.createSectorBrain) app.put ('/api/admin/sectors/brain/:id', verifyToken, equipeCtrl.updateSectorBrain) app.delete('/api/admin/sectors/brain/:id', verifyToken, equipeCtrl.deleteSectorBrain) // inbox interna app.get ('/api/admin/inbox/threads', verifyAnyToken, equipeCtrl.listThreads) app.get ('/api/admin/inbox/threads/:threadId', verifyAnyToken, equipeCtrl.getThread) app.post('/api/admin/inbox/messages', verifyAnyToken, equipeCtrl.postMessage) app.post('/api/admin/inbox/threads/:threadId/read', verifyAnyToken, equipeCtrl.markRead) app.get ('/api/admin/inbox/unread', verifyAnyToken, equipeCtrl.unreadCount) // direct messages (DMs entre colaboradores) — Fase 13 const dmCtrl = require('./controllers/directMessageController') app.post('/api/admin/inbox/direct-message', verifyAnyToken, (req, res) => dmCtrl.send(req, res)) app.get ('/api/admin/inbox/direct/:otherAccountId', verifyAnyToken, (req, res) => dmCtrl.getConversation(req, res)) app.get ('/api/admin/inbox/direct-messages', verifyAnyToken, (req, res) => dmCtrl.getLastMessages(req, res)) app.get ('/api/admin/inbox/colleagues', verifyAnyToken, (req, res) => dmCtrl.getColleagues(req, res)) app.patch('/api/admin/inbox/direct/:messageId/read', verifyAnyToken, (req, res) => dmCtrl.markAsRead(req, res)) // group chats por setor — Fase 14 const gcCtrl = require('./controllers/groupChatController') app.get ('/api/admin/inbox/groups', verifyAnyToken, (req, res) => gcCtrl.list(req, res)) app.get ('/api/admin/inbox/groups/:id/messages', verifyAnyToken, (req, res) => gcCtrl.getMessages(req, res)) app.post('/api/admin/inbox/groups/:id/message', verifyAnyToken, (req, res) => gcCtrl.sendMessage(req, res)) app.get ('/api/admin/inbox/groups/:id/members', verifyAnyToken, (req, res) => gcCtrl.getMembers(req, res)) app.post('/api/admin/inbox/groups/:id/read', verifyAnyToken, (req, res) => gcCtrl.markRead(req, res)) // broadcasts — Fase 15 const bcCtrl = require('./controllers/broadcastController') app.post('/api/admin/inbox/broadcast', verifyAnyToken, (req, res) => bcCtrl.send(req, res)) app.get ('/api/admin/inbox/broadcasts', verifyAnyToken, (req, res) => bcCtrl.list(req, res)) app.post('/api/admin/inbox/broadcasts/seen', verifyAnyToken, (req, res) => bcCtrl.markSeen(req, res)) // ── BullMQ — filas (declaradas antes das rotas que as usam) ─────────────── const { Queue, Worker } = require('bullmq') const redisConn = { connection: { host: process.env.REDIS_HOST || '127.0.0.1', port: parseInt(process.env.REDIS_PORT || '6379') } } const sseHandoffQueue = new Queue('handoff-expire', redisConn) const humanApiQueue = new Queue('human-api-timeout', redisConn) // ── Assumir escalação ────────────────────────────────────────────────────── app.post('/api/admin/escalation/assume', verifyAnyToken, async (req, res) => { try { const { chatId, protocolNumber, chatName } = req.body const accountId = req.teamAccount?.accountId || req.admin?.id const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id if (!accountId || !chatId) return res.status(400).json({ error: 'chatId e accountId obrigatórios' }) const threadId = `esc-${chatId.replace(/[^a-z0-9]/gi, '')}-${Date.now()}` const content = `Escalação assumida${chatName ? ` — cliente: ${chatName}` : ''}${protocolNumber ? ` — protocolo #${protocolNumber}` : ''}` const msg = await queryOne( `INSERT INTO internal_messages (tenant_id, thread_id, from_account, type, content, ref_id, chat_id) VALUES ($1,$2,$3,'escalation',$4,$5,$6) RETURNING *`, [tenantId, threadId, accountId, content, protocolNumber || null, chatId || null] ) // Pausa IA para o chat const humanUntil = new Date(Date.now() + 15 * 60 * 1000) await execute( `INSERT INTO nw_handoff (chat_id, tenant_id, human_until, updated_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (chat_id, tenant_id) DO UPDATE SET human_until=$3, updated_at=NOW()`, [chatId, tenantId, humanUntil] ) // Agenda expiração do handoff via SSE sseHandoffQueue.add('handoff-expire', { chatId, tenantId }, { delay: 15 * 60 * 1000 }) res.json({ threadId, msgId: msg.id, protocolNumber }) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── Transferir protocolo ─────────────────────────────────────────────────── app.post('/api/admin/protocol/transfer', verifyAnyToken, async (req, res) => { try { const { threadId, toAccountId, protocolNumber, message } = req.body const fromAccountId = req.teamAccount?.accountId || req.admin?.id const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id if (!toAccountId) return res.status(400).json({ error: 'toAccountId obrigatório' }) const content = message?.trim() || `Protocolo${protocolNumber ? ` #${protocolNumber}` : ''} transferido para você.` const newThreadId = threadId || `trf-${Date.now()}-${Math.random().toString(36).slice(2,6)}` const msg = await queryOne( `INSERT INTO internal_messages (tenant_id, thread_id, from_account, to_account, type, content, ref_id) VALUES ($1,$2,$3,$4,'collaboration',$5,$6) RETURNING *`, [tenantId, newThreadId, fromAccountId, toAccountId, content, protocolNumber || null] ) res.json({ threadId: newThreadId, msgId: msg.id }) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── SSE — Server-Sent Events ─────────────────────────────────────────────── const { register: sseRegister, unregister: sseUnregister, sseBroadcast, sseBroadcastTenant } = sseService app.get('/api/sse', verifyAnyToken, async (req, res) => { res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.setHeader('Connection', 'keep-alive') res.flushHeaders() const isTeam = !!req.teamAccount const accountId = req.teamAccount?.accountId || req.admin?.id || 'admin' const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id || 1 sseRegister(accountId, tenantId, res) // Colaborador conectado → marcar online e notificar colegas if (isTeam && req.teamAccount?.accountId) { try { await execute('UPDATE accounts SET availability=$1 WHERE id=$2', ['online', accountId]) sseBroadcastTenant(tenantId, 'presence.change', { accountId, availability: 'online', }, accountId) } catch {} } // Enviar estado inicial confirmando conexão res.write(`event: connected\ndata: ${JSON.stringify({ accountId })}\n\n`) const heartbeat = setInterval(() => res.write(': ping\n\n'), 25000) req.on('close', async () => { clearInterval(heartbeat) sseUnregister(accountId) // Colaborador desconectado → marcar offline e notificar colegas if (isTeam && req.teamAccount?.accountId) { try { await execute('UPDATE accounts SET availability=$1 WHERE id=$2', ['offline', accountId]) sseBroadcastTenant(tenantId, 'presence.change', { accountId, availability: 'offline', }, accountId) } catch {} } }) }) // ── Heartbeat de presença — colaborador altera seu status manualmente ────── app.patch('/api/team/availability', verifyTeamToken, async (req, res) => { try { const { availability } = req.body const accountId = req.teamAccount.accountId const tenantId = req.teamAccount.tenantId if (!['online', 'busy', 'offline'].includes(availability)) { return res.status(400).json({ error: 'availability inválido: online | busy | offline' }) } await execute('UPDATE accounts SET availability=$1 WHERE id=$2', [availability, accountId]) sseBroadcastTenant(tenantId, 'presence.change', { accountId, availability }, accountId) res.json({ ok: true, availability }) } catch (err) { console.error('[team/availability]', err) res.status(500).json({ error: 'Erro ao atualizar status' }) } }) app.patch('/api/team/me/duty', verifyTeamToken, async (req, res) => { try { const { on_duty } = req.body const accountId = req.teamAccount.accountId if (typeof on_duty !== 'boolean') return res.status(400).json({ error: 'on_duty deve ser boolean' }) await execute( `UPDATE team_members SET on_duty=$1 WHERE account_id=$2`, [on_duty, accountId] ) res.json({ ok: true, on_duty }) } catch (err) { console.error('[team/duty]', err) res.status(500).json({ error: 'Erro ao atualizar plantão' }) } }) // ── BullMQ — workers ─────────────────────────────────────────────────────── new Worker('handoff-expire', async job => { const { chatId, tenantId } = job.data await execute( `UPDATE nw_handoff SET human_until=NULL, updated_at=NOW() WHERE chat_id=$1 AND tenant_id=$2 AND human_until IS NOT NULL AND human_until <= NOW()`, [chatId, tenantId] ) sseBroadcast('handoff.expired', { chatId }) }, redisConn) new Worker('human-api-timeout', async job => { const { requestId } = job.data const row = await queryOne( `UPDATE human_api_requests SET status='timeout', answered_at=NOW() WHERE id=$1 AND status='pending' RETURNING id, thread_id, tenant_id, conversation_id, question`, [requestId] ) if (row) { await execute( `INSERT INTO internal_messages (tenant_id, thread_id, type, content, ref_id) VALUES ($1,$2,'system','⏱ Nenhum atendente respondeu no prazo — IA retomará sem essa informação.',$3)`, [row.tenant_id, row.thread_id, String(requestId)] ) sseBroadcast('human_api.timeout', { requestId, threadId: row.thread_id }) // Notifica o motor para retomar a conversa sem a resposta humana if (row.conversation_id) { try { const cfg = {} const cfgRows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id='newwhats'`) for (const r of cfgRows) cfg[r.key] = r.value if (cfg.newwhats_url && cfg.integration_key) { await fetch(`${cfg.newwhats_url}/api/ext/v1/secretaria/ask`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integration_key }, body: JSON.stringify({ conversationId: row.conversation_id, message: '[SISTEMA] Nenhum especialista respondeu no prazo. Informe ao cliente de forma cordial e ofereça alternativas.', systemExtra: 'O timeout da Human API ocorreu. Retome o atendimento sem a informação solicitada.', }), }) } } catch (e) { console.warn('[human-api-timeout] motor notify failed:', e.message) } } } }, redisConn) const INTERNAL_SECRET = process.env.INTERNAL_WEBHOOK_SECRET // ── Human API — IA pede resposta de especialista humano ─────────────────── app.post('/api/human-api/request', async (req, res) => { try { const internalKey = req.headers['x-internal-secret'] if (!INTERNAL_SECRET || internalKey !== INTERNAL_SECRET) return res.status(403).json({ error: 'Proibido' }) const { chatId, conversationId, instanceId, question, sectorName } = req.body const tenantId = 1 // single-tenant por enquanto // Busca setor e conta disponível via on_duty const sectorRow = sectorName ? await queryOne(`SELECT id FROM sectors WHERE name ILIKE $1 AND tenant_id=$2 LIMIT 1`, [sectorName, tenantId]) : null const accountRow = sectorRow ? await queryOne( `SELECT tm.account_id FROM team_members tm WHERE tm.sector_id=$1 AND tm.on_duty=TRUE AND tm.notify_human_api=TRUE ORDER BY RANDOM() LIMIT 1`, [sectorRow.id] ) : null const threadId = `hapi-${chatId.replace(/[^a-z0-9]/gi,'')}-${Date.now()}` const timeoutAt = new Date(Date.now() + 5 * 60 * 1000) // ── Enriquecer a mensagem com contexto do banco ──────────────────────── let contexto = '' try { const { extractMentions, findCotacaoById, findClienteByPhone, normalizePhone } = require('./plugins/newwhats/context-builder') // Descobrir telefone do cliente via nw_event_logs const logRow = await queryOne( `SELECT payload->>'author' AS author, payload->>'pushName' AS push_name FROM nw_event_logs WHERE chat_id=$1 AND direction='in' ORDER BY created_at DESC LIMIT 1`, [chatId] ) const phone = normalizePhone(logRow?.author || '') const pushName = logRow?.push_name || '' // Cliente cadastrado const cliente = phone ? await findClienteByPhone(phone, tenantId) : null if (cliente || pushName) { contexto += `\n👤 Cliente: ${cliente?.nome || pushName || 'Não identificado'}` if (cliente?.tipo) contexto += ` (${cliente.tipo})` if (cliente?.cpf_cnpj) contexto += ` — ${cliente.cpf_cnpj}` if (phone) contexto += `\n📱 WhatsApp: ${phone}` } // Menções a cotações/pedidos na pergunta const mencoes = extractMentions(question) for (const m of mencoes) { if (m.type === 'cotacao_id') { const cot = await findCotacaoById(m.value, tenantId) if (cot) { const jaEPedido = cot.cotacao_status === 'finalizada' && cot.pedido_id if (jaEPedido) { // Pedido é a entidade principal — cotação virou histórico const prot = cot.pedido_protocolo || `#${cot.pedido_id}` contexto += `\n\n📦 PEDIDO ${prot}:` contexto += `\n• Status: ${cot.pedido_status || '—'}` contexto += `\n• Total: R$ ${parseFloat(cot.total || 0).toFixed(2)}` contexto += `\n• Pagamento: ${cot.forma_pagto || '—'}` if (cot.financeiro_status && cot.financeiro_status !== 'n/a') contexto += ` (financeiro: ${cot.financeiro_status})` contexto += `\n• Entrega: ${cot.tipo_entrega || '—'}` if (cot.pedido_criado_em) contexto += `\n• Pedido criado em: ${new Date(cot.pedido_criado_em).toLocaleDateString('pt-BR')}` // Itens do pedido (mais atualizados que os da cotação) const itensPed = await query( `SELECT nome_produto, quantidade, subtotal, observacao FROM pedido_itens WHERE pedido_id=$1 ORDER BY id LIMIT 10`, [cot.pedido_id] ) if (itensPed.length) { contexto += `\n• Itens (${itensPed.length}):` itensPed.forEach(i => { contexto += `\n - ${i.quantidade}x ${i.nome_produto} — R$ ${parseFloat(i.subtotal).toFixed(2)}` if (i.observacao) contexto += ` [obs: ${i.observacao}]` }) } } else { // Cotação ainda em aberto contexto += `\n\n📋 Cotação #${m.value}:` contexto += `\n• Status: ${cot.cotacao_status}` contexto += `\n• Total: R$ ${parseFloat(cot.total || 0).toFixed(2)}` const itensCot = await query( `SELECT nome_produto, quantidade, subtotal, observacao FROM cotacao_itens WHERE cotacao_id=$1 ORDER BY id LIMIT 10`, [m.value] ) if (itensCot.length) { contexto += `\n• Itens (${itensCot.length}):` itensCot.forEach(i => { contexto += `\n - ${i.quantidade}x ${i.nome_produto} — R$ ${parseFloat(i.subtotal).toFixed(2)}` if (i.observacao) contexto += ` [obs: ${i.observacao}]` }) } } } } } } catch (e) { console.warn('[human-api/request] enrich failed:', e.message) } const mensagemOperador = `🤖 A IA precisa de ajuda:\n"${question}"${contexto}\n\n↩️ Responda com as informações para que a IA possa continuar o atendimento.` const reqRow = await queryOne( `INSERT INTO human_api_requests (tenant_id, conversation_id, chat_id, sector_id, account_id, question, status, delivery_mode, timeout_at) VALUES ($1,$2,$3,$4,$5,$6,'pending','inbox_interna',$7) RETURNING id`, [tenantId, conversationId || null, chatId || null, sectorRow?.id || null, accountRow?.account_id || null, question, timeoutAt] ) await execute( `INSERT INTO internal_messages (tenant_id, thread_id, from_account, to_account, sector_id, type, content, ref_id) VALUES ($1,$2,NULL,$3,$4,'human_api',$5,$6)`, [tenantId, threadId, accountRow?.account_id || null, sectorRow?.id || null, mensagemOperador, String(reqRow.id)] ) await humanApiQueue.add('timeout', { requestId: reqRow.id }, { delay: 5 * 60 * 1000, jobId: `hapi-${reqRow.id}` }) sseBroadcast('human_api.new', { requestId: reqRow.id, threadId, question, sectorName }) res.json({ requestId: reqRow.id, threadId }) } catch (err) { res.status(500).json({ error: err.message }) } }) app.post('/api/human-api/answer/:id', verifyAnyToken, async (req, res) => { try { const requestId = parseInt(req.params.id) const { answer } = req.body const accountId = req.teamAccount?.accountId || req.admin?.id const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id if (!answer?.trim()) return res.status(400).json({ error: 'answer obrigatório' }) const reqRow = await queryOne( `UPDATE human_api_requests SET status='answered', answer=$1, account_id=$2, answered_at=NOW() WHERE id=$3 AND status='pending' RETURNING id, thread_id, conversation_id`, [answer.trim(), accountId, requestId] ) if (!reqRow) return res.status(404).json({ error: 'Pedido não encontrado ou já respondido' }) await execute( `INSERT INTO internal_messages (tenant_id, thread_id, from_account, type, content, ref_id) VALUES ($1,$2,$3,'collaboration',$4,$5)`, [tenantId, reqRow.thread_id, accountId, answer.trim(), String(requestId)] ) // Remove job de timeout do BullMQ try { const job = await humanApiQueue.getJob(`hapi-${requestId}`) if (job) await job.remove() } catch { /* sem Redis ou job já processado */ } sseBroadcast('human_api.answered', { requestId, threadId: reqRow.thread_id, answer: answer.trim() }) // Retoma a IA no motor com a resposta do especialista if (reqRow.conversation_id) { try { const cfgRows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id='newwhats'`) const cfg = {}; for (const r of cfgRows) cfg[r.key] = r.value if (cfg.newwhats_url && cfg.integration_key) { await fetch(`${cfg.newwhats_url}/api/ext/v1/secretaria/ask`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integration_key }, body: JSON.stringify({ conversationId: reqRow.conversation_id, message: `[SISTEMA] Especialista respondeu: ${answer.trim()}`, systemExtra: 'Use esta informação para responder ao cliente de forma adequada.', }), }) } } catch (e) { console.warn('[human-api/answer] motor notify failed:', e.message) } } res.json({ ok: true, requestId, threadId: reqRow.thread_id }) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── Sincronizar setor do chat (motor → banco local) ──────────────────────── app.post('/api/nw/chat-sector', async (req, res) => { try { const internalKey = req.headers['x-internal-secret'] if (!INTERNAL_SECRET || internalKey !== INTERNAL_SECRET) return res.status(403).json({ error: 'Proibido' }) const { chatId, sectorId, tenantId = 1 } = req.body if (!chatId || !sectorId) return res.status(400).json({ error: 'chatId e sectorId obrigatórios' }) await execute( `INSERT INTO nw_chat_sectors (chat_id, tenant_id, sector_id, updated_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (chat_id, tenant_id) DO UPDATE SET sector_id=$3, updated_at=NOW()`, [chatId, tenantId, sectorId] ) res.json({ ok: true }) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── SLA monitor — verifica escalações sem resposta (cron a cada minuto) ──── const cron = require('node-cron') cron.schedule('* * * * *', async () => { try { const breaches = await query( `SELECT im.thread_id, im.sector_id, s.name AS sector_name, s.sla_minutes, im.created_at AS escalated_at, EXTRACT(EPOCH FROM (NOW() - im.created_at))/60 AS elapsed_minutes FROM internal_messages im JOIN sectors s ON s.id = im.sector_id WHERE im.type = 'escalation' AND s.sla_minutes IS NOT NULL AND im.created_at > NOW() - INTERVAL '24 hours' AND NOT EXISTS ( SELECT 1 FROM internal_messages r WHERE r.thread_id = im.thread_id AND r.from_account IS NOT NULL AND r.created_at > im.created_at ) AND EXTRACT(EPOCH FROM (NOW() - im.created_at))/60 > s.sla_minutes` ) for (const b of breaches) { sseBroadcast('sla.breach', { threadId: b.thread_id, sectorId: b.sector_id, sectorName: b.sector_name, slaMins: b.sla_minutes, elapsedMins: Math.round(b.elapsed_minutes), }) } } catch { /* silencioso */ } }) // ── Encerrar protocolo + pesquisa de satisfação ─────────────────────────── app.post('/api/admin/protocol/close', verifyAnyToken, async (req, res) => { try { const { threadId, chatId, protocolNumber } = req.body const accountId = req.teamAccount?.accountId || req.admin?.id const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id || 1 if (!threadId || !chatId) return res.status(400).json({ error: 'threadId e chatId obrigatórios' }) // Cria registro de avaliação pendente const ratingRow = await queryOne( `INSERT INTO protocol_ratings (tenant_id, thread_id, chat_id, account_id) VALUES ($1,$2,$3,$4) RETURNING id`, [tenantId, threadId, chatId, accountId || null] ) // Insere mensagem de sistema na thread await execute( `INSERT INTO internal_messages (tenant_id, thread_id, type, content, ref_id) VALUES ($1,$2,'system','✅ Protocolo encerrado. Pesquisa de satisfação enviada ao cliente.',$3)`, [tenantId, threadId, protocolNumber || null] ) // Envia mensagem WhatsApp ao cliente via motor const cfgRows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id='newwhats'`) const cfg = {}; for (const r of cfgRows) cfg[r.key] = r.value if (cfg.newwhats_url && cfg.integration_key) { const surveyText = `Seu atendimento foi encerrado${protocolNumber ? ` (protocolo #${protocolNumber})` : ''}.\n\nComo você avalia o atendimento que recebeu?\n\nResponda com um número de *1 a 5*:\n1️⃣ Muito ruim\n2️⃣ Ruim\n3️⃣ Regular\n4️⃣ Bom\n5️⃣ Excelente` await fetch(`${cfg.newwhats_url}/api/ext/v1/inbox/${encodeURIComponent(chatId)}/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integration_key }, body: JSON.stringify({ text: surveyText }), }).catch(e => console.warn('[protocol/close] send survey failed:', e.message)) } // Retoma IA (zera handoff) await execute( `UPDATE nw_handoff SET human_until=NULL, updated_at=NOW() WHERE chat_id=$1 AND tenant_id=$2`, [chatId, tenantId] ) sseBroadcast('handoff.expired', { chatId }) res.json({ ok: true, ratingId: ratingRow.id }) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── Flags de protocolo (reclamação / sugestão / elogio) ─────────────────── app.post('/api/admin/protocol/flag', verifyAnyToken, async (req, res) => { try { const { threadId, type, note } = req.body const accountId = req.teamAccount?.accountId || req.admin?.id const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id || 1 if (!threadId || !['complaint','suggestion','compliment'].includes(type)) return res.status(400).json({ error: 'threadId e type obrigatórios' }) const row = await queryOne( `INSERT INTO protocol_flags (tenant_id, thread_id, account_id, type, note) VALUES ($1,$2,$3,$4,$5) RETURNING id`, [tenantId, threadId, accountId || null, type, note?.trim() || null] ) const typeLabel = { complaint: '🔴 Reclamação', suggestion: '💡 Sugestão', compliment: '⭐ Elogio' }[type] await execute( `INSERT INTO internal_messages (tenant_id, thread_id, from_account, type, content) VALUES ($1,$2,$3,'system',$4)`, [tenantId, threadId, accountId || null, `${typeLabel} registrado${note ? `: ${note.trim()}` : ''}`] ) res.json({ ok: true, flagId: row.id }) } catch (err) { res.status(500).json({ error: err.message }) } }) app.get('/api/admin/protocol/flags', verifyAnyToken, async (req, res) => { try { const tenantId = req.tenantId || req.admin?.tenant_id || req.teamAccount?.tenantId || 1 const { period = '7d' } = req.query const intervalMap = { today: '1 day', '7d': '7 days', '30d': '30 days' } const interval = intervalMap[period] || '7 days' const rows = await query( `SELECT pf.id, pf.type, pf.note, pf.thread_id, pf.created_at, a.name AS account_name FROM protocol_flags pf LEFT JOIN accounts a ON a.id = pf.account_id WHERE pf.tenant_id=$1 AND pf.created_at > NOW()-INTERVAL '${interval}' ORDER BY pf.created_at DESC LIMIT 200`, [tenantId] ) const summary = await queryOne( `SELECT COUNT(*) FILTER (WHERE type='complaint') AS complaints, COUNT(*) FILTER (WHERE type='suggestion') AS suggestions, COUNT(*) FILTER (WHERE type='compliment') AS compliments FROM protocol_flags WHERE tenant_id=$1 AND created_at > NOW()-INTERVAL '${interval}'`, [tenantId] ) res.json({ rows, summary }) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── Knowledge Learning — salvar e promover respostas ao brain ───────────── app.post('/api/admin/knowledge/save', verifyAnyToken, async (req, res) => { try { const { question, answer, sectorId } = req.body const accountId = req.teamAccount?.accountId || req.admin?.id const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id || 1 if (!question?.trim() || !answer?.trim()) return res.status(400).json({ error: 'question e answer obrigatórios' }) const row = await queryOne( `INSERT INTO knowledge_learning (tenant_id, sector_id, account_id, question, answer) VALUES ($1,$2,$3,$4,$5) RETURNING id`, [tenantId, sectorId || null, accountId || null, question.trim(), answer.trim()] ) res.json({ ok: true, id: row.id }) } catch (err) { res.status(500).json({ error: err.message }) } }) app.post('/api/admin/knowledge/promote/:id', verifyAnyToken, async (req, res) => { try { const tenantId = req.tenantId || req.admin?.tenant_id || req.teamAccount?.tenantId || 1 const kl = await queryOne( `UPDATE knowledge_learning SET promoted=TRUE, promoted_at=NOW() WHERE id=$1 AND tenant_id=$2 AND promoted=FALSE RETURNING *`, [req.params.id, tenantId] ) if (!kl) return res.status(404).json({ error: 'Registro não encontrado ou já promovido' }) if (!kl.sector_id) return res.status(400).json({ error: 'Conhecimento sem setor associado não pode ser promovido' }) const brain = await queryOne( `INSERT INTO sector_brain_nodes (sector_id, type, content) VALUES ($1,'knowledge',$2) RETURNING id`, [kl.sector_id, `P: ${kl.question}\nR: ${kl.answer}`] ) res.json({ ok: true, brainNodeId: brain.id }) } catch (err) { res.status(500).json({ error: err.message }) } }) app.get('/api/admin/knowledge', verifyAnyToken, async (req, res) => { try { const tenantId = req.tenantId || req.admin?.tenant_id || req.teamAccount?.tenantId || 1 const { promoted } = req.query const promotedFilter = promoted === 'false' || promoted === 'true' const rows = await query( `SELECT kl.*, a.name AS account_name, s.name AS sector_name FROM knowledge_learning kl LEFT JOIN accounts a ON a.id = kl.account_id LEFT JOIN sectors s ON s.id = kl.sector_id WHERE kl.tenant_id=$1 AND ($2::boolean IS NULL OR kl.promoted = $2) ORDER BY kl.created_at DESC LIMIT 100`, [tenantId, promotedFilter ? promoted === 'true' : null] ) res.json(rows) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── Métricas de atendimento ──────────────────────────────────────────────── app.get('/api/admin/metrics/attendance', verifyAnyToken, async (req, res) => { try { const tenantId = req.tenantId || req.admin?.tenant_id || req.teamAccount?.tenantId || 1 const rawPeriod = req.query.period || '7d' const intervalMap = { today: '1 day', '7d': '7 days', '30d': '30 days' } const interval = intervalMap[rawPeriod] || '7 days' const [byAccount, bySector, humanApiStats, autoReplyStats, dailyEscalations, flagsStats, ratingsStats, resolutionStats] = await Promise.all([ // Por colaborador: escalações assumidas, human_api respondidas, transferências, tempo médio 1ª resposta query(` SELECT a.id AS account_id, a.name, a.email, COUNT(DISTINCT CASE WHEN im.type='escalation' THEN im.thread_id END) AS escalations_assumed, COUNT(DISTINCT CASE WHEN im.type='collaboration' THEN im.id END) AS messages_sent, COUNT(DISTINCT har.id) AS human_api_answered, ROUND(AVG( CASE WHEN im.type='escalation' THEN EXTRACT(EPOCH FROM ( (SELECT MIN(r.created_at) FROM internal_messages r WHERE r.thread_id=im.thread_id AND r.from_account IS NOT NULL AND r.created_at > im.created_at) - im.created_at ))/60 END )::numeric, 1) AS avg_first_response_min FROM accounts a LEFT JOIN internal_messages im ON im.from_account=a.id AND im.tenant_id=$1 AND im.created_at > NOW()-INTERVAL '${interval}' LEFT JOIN human_api_requests har ON har.account_id=a.id AND har.status='answered' AND har.answered_at > NOW()-INTERVAL '${interval}' WHERE a.tenant_id=$1 GROUP BY a.id, a.name, a.email ORDER BY escalations_assumed DESC, messages_sent DESC `, [tenantId]), // Por setor: total de escalações, breaches de SLA, tempo médio de resposta query(` SELECT s.id AS sector_id, s.name, s.color, s.sla_minutes, COUNT(DISTINCT im.thread_id) AS escalations_total, COUNT(DISTINCT CASE WHEN EXTRACT(EPOCH FROM ( (SELECT MIN(r.created_at) FROM internal_messages r WHERE r.thread_id=im.thread_id AND r.from_account IS NOT NULL AND r.created_at > im.created_at) - im.created_at ))/60 > s.sla_minutes THEN im.thread_id END) AS sla_breaches, ROUND(AVG( EXTRACT(EPOCH FROM ( (SELECT MIN(r.created_at) FROM internal_messages r WHERE r.thread_id=im.thread_id AND r.from_account IS NOT NULL AND r.created_at > im.created_at) - im.created_at ))/60 )::numeric, 1) AS avg_response_min FROM sectors s LEFT JOIN internal_messages im ON im.sector_id=s.id AND im.type='escalation' AND im.tenant_id=$1 AND im.created_at > NOW()-INTERVAL '${interval}' WHERE s.tenant_id=$1 AND s.active=TRUE GROUP BY s.id, s.name, s.color, s.sla_minutes ORDER BY escalations_total DESC `, [tenantId]), // Human API: total, respondidas, timeout queryOne(` SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE status='answered') AS answered, COUNT(*) FILTER (WHERE status='timeout') AS timeout, COUNT(*) FILTER (WHERE status='ignored') AS ignored, ROUND( 100.0 * COUNT(*) FILTER (WHERE status='answered') / NULLIF(COUNT(*),0), 1 ) AS rate_pct FROM human_api_requests WHERE tenant_id=$1 AND asked_at > NOW()-INTERVAL '${interval}' `, [tenantId]), // Auto-reply IA: mensagens recebidas vs respondidas (com isolamento por tenant) queryOne(` SELECT (SELECT COUNT(*) FROM nw_event_logs WHERE event='message.new' AND direction='in' AND tenant_id=$1 AND created_at > NOW()-INTERVAL '${interval}') AS total_in, (SELECT COUNT(*) FROM nw_auto_replies WHERE status='sent' AND tenant_id=$1 AND created_at > NOW()-INTERVAL '${interval}') AS total_replied `, [tenantId]), // Escalações por dia (gráfico temporal) query(` SELECT TO_CHAR(DATE_TRUNC('day', created_at), 'DD/MM') AS day, COUNT(DISTINCT thread_id) AS count FROM internal_messages WHERE tenant_id=$1 AND type='escalation' AND created_at > NOW()-INTERVAL '${interval}' GROUP BY DATE_TRUNC('day', created_at) ORDER BY DATE_TRUNC('day', created_at) `, [tenantId]), // Flags: reclamações, sugestões, elogios queryOne(` SELECT COUNT(*) FILTER (WHERE type='complaint') AS complaints, COUNT(*) FILTER (WHERE type='suggestion') AS suggestions, COUNT(*) FILTER (WHERE type='compliment') AS compliments FROM protocol_flags WHERE tenant_id=$1 AND created_at > NOW()-INTERVAL '${interval}' `, [tenantId]), // Avaliações de satisfação queryOne(` SELECT COUNT(*) AS total_sent, COUNT(*) FILTER (WHERE status='rated') AS total_rated, ROUND(AVG(rating) FILTER (WHERE rating IS NOT NULL), 1) AS avg_rating FROM protocol_ratings WHERE tenant_id=$1 AND sent_at > NOW()-INTERVAL '${interval}' `, [tenantId]), // Tempo médio de resolução (escalação → encerramento do protocolo) queryOne(` SELECT ROUND( AVG( EXTRACT(EPOCH FROM (pr.sent_at - esc.first_escalation)) / 60 )::numeric, 1 ) AS avg_resolution_min, COUNT(pr.id) AS total_resolved FROM protocol_ratings pr JOIN ( SELECT thread_id, MIN(created_at) AS first_escalation FROM internal_messages WHERE tenant_id=$1 AND type='escalation' GROUP BY thread_id ) esc ON esc.thread_id = pr.thread_id WHERE pr.tenant_id=$1 AND pr.sent_at > NOW()-INTERVAL '${interval}' `, [tenantId]) ]) const tin = parseInt(autoReplyStats?.total_in ?? 0) const trep = parseInt(autoReplyStats?.total_replied ?? 0) res.json({ period: rawPeriod, by_account: byAccount, by_sector: bySector, human_api: { total: parseInt(humanApiStats?.total ?? 0), answered: parseInt(humanApiStats?.answered ?? 0), timeout: parseInt(humanApiStats?.timeout ?? 0), ignored: parseInt(humanApiStats?.ignored ?? 0), rate_pct: parseFloat(humanApiStats?.rate_pct ?? 0), }, auto_replies: { total_in: tin, total_replied: trep, rate_pct: tin > 0 ? Math.round((trep / tin) * 100) : 0, }, daily_escalations: dailyEscalations, flags: { complaints: parseInt(flagsStats?.complaints ?? 0), suggestions: parseInt(flagsStats?.suggestions ?? 0), compliments: parseInt(flagsStats?.compliments ?? 0), }, ratings: { total_sent: parseInt(ratingsStats?.total_sent ?? 0), total_rated: parseInt(ratingsStats?.total_rated ?? 0), avg_rating: ratingsStats?.avg_rating ? parseFloat(ratingsStats.avg_rating) : null, }, resolution: { avg_minutes: resolutionStats?.avg_resolution_min ? parseFloat(resolutionStats.avg_resolution_min) : null, total_resolved: parseInt(resolutionStats?.total_resolved ?? 0), }, }) } catch (err) { console.error('[metrics/attendance]', err.message) res.status(500).json({ error: err.message }) } }) // ── Histórico de avaliações de satisfação (paginado) ────────────────────── app.get('/api/admin/metrics/ratings-history', verifyAnyToken, async (req, res) => { try { const tenantId = req.tenantId || req.admin?.tenant_id || req.teamAccount?.tenantId || 1 const limit = Math.min(parseInt(req.query.limit || '20', 10), 100) const offset = Math.max(parseInt(req.query.offset || '0', 10), 0) const rating = req.query.rating ? parseInt(req.query.rating, 10) : null // filtro por nota (1–5) const rows = await query(` SELECT pr.id, pr.thread_id, pr.chat_id, pr.rating, pr.status, pr.sent_at, pr.rated_at, a.name AS account_name, a.email AS account_email FROM protocol_ratings pr LEFT JOIN accounts a ON a.id = pr.account_id WHERE pr.tenant_id = $1 AND ($2::smallint IS NULL OR pr.rating = $2) ORDER BY pr.sent_at DESC LIMIT $3 OFFSET $4 `, [tenantId, rating, limit, offset]) const { count } = await queryOne(` SELECT COUNT(*) AS count FROM protocol_ratings WHERE tenant_id=$1 AND ($2::smallint IS NULL OR rating=$2) `, [tenantId, rating]) res.json({ rows, total: parseInt(count), limit, offset }) } catch (err) { console.error('[ratings-history]', err.message) res.status(500).json({ error: err.message }) } }) // ── Falhas definitivas de envio WhatsApp ─────────────────────────────────── app.get('/api/admin/notification-errors', verifyToken, async (req, res) => { try { const { phone } = req.query const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000) const offset = Math.max(parseInt(req.query.offset || '0', 10), 0) const rows = await query( `SELECT id, job_id, log_id, phone, type, error_message, payload, tenant_id, created_at FROM notification_errors WHERE ($1::text IS NULL OR phone ILIKE '%' || $1 || '%') ORDER BY created_at DESC LIMIT $2 OFFSET $3`, [phone || null, limit, offset] ) const total = await queryOne( `SELECT COUNT(*) AS cnt FROM notification_errors WHERE ($1::text IS NULL OR phone ILIKE '%' || $1 || '%')`, [phone || null] ) res.json({ rows, total: parseInt(total?.cnt ?? 0) }) } catch (err) { console.error('[notification-errors]', err.message) res.status(500).json({ error: 'Erro interno' }) } }) // ── Sync Knowledge — sincroniza base de conhecimento com o motor ────────── app.post('/api/admin/nw/sync-knowledge', verifyToken, async (req, res) => { try { const { syncKnowledge } = require('./plugins/newwhats/sync-knowledge') const result = await syncKnowledge() res.json(result) } catch (err) { console.error('[sync-knowledge]', err.message) res.status(500).json({ error: err.message }) } }) // ── Preview knowledge — retorna o texto sem sincronizar ─────────────────── app.get('/api/admin/nw/sync-knowledge/preview', verifyToken, async (req, res) => { try { const { buildKnowledgeText } = require('./plugins/newwhats/sync-knowledge') const preview = await buildKnowledgeText() res.json({ preview, chars: preview.length }) } catch (err) { console.error('[sync-knowledge/preview]', err.message) res.status(500).json({ error: err.message }) } }) // ── Métricas de auto-resposta IA (Frente 5 v2.2) ────────────────────────── app.get('/api/admin/nw/metrics', verifyToken, async (req, res) => { try { const period = req.query.period === '30d' ? 30 : req.query.period === '1d' ? 1 : 7 const [total_in, total_replied, by_instance, recent_replies] = await Promise.all([ queryOne( `SELECT COUNT(*) AS cnt FROM nw_event_logs WHERE event = 'message.new' AND direction = 'in' AND created_at > NOW() - ($1::int * INTERVAL '1 day')`, [period] ), queryOne( `SELECT COUNT(*) AS cnt FROM nw_auto_replies WHERE status = 'sent' AND created_at > NOW() - ($1::int * INTERVAL '1 day')`, [period] ), query( `SELECT instance_id, COUNT(*) AS total FROM nw_auto_replies WHERE created_at > NOW() - ($1::int * INTERVAL '1 day') GROUP BY instance_id ORDER BY total DESC LIMIT 5`, [period] ), query( `SELECT chat_id, reply, created_at FROM nw_auto_replies WHERE status = 'sent' ORDER BY created_at DESC LIMIT 10` ), ]) const tin = parseInt(total_in?.cnt ?? 0, 10) const trep = parseInt(total_replied?.cnt ?? 0, 10) res.json({ period_days: period, total_in: tin, total_replied: trep, auto_rate: tin > 0 ? Math.round((trep / tin) * 100) : 0, by_instance, recent_replies, }) } catch (err) { console.error('[nw/metrics]', err.message) res.status(500).json({ error: 'Erro interno' }) } }) // ── Handoff local por chat (pausa IA 15 min) ────────────────────────────── app.get('/api/nw/handoff/:chatId', verifyAnyToken, async (req, res) => { try { const tenantId = req.tenantId || req.admin?.tenant_id || req.teamAccount?.tenantId const row = await queryOne( `SELECT human_until FROM nw_handoff WHERE chat_id=$1 AND tenant_id=$2`, [req.params.chatId, tenantId] ) const humanUntil = row?.human_until ? new Date(row.human_until) : null const isHuman = humanUntil && humanUntil > new Date() res.json({ mode: isHuman ? 'humano' : 'ia', handoffHumanAt: humanUntil?.toISOString() ?? null }) } catch (err) { res.status(500).json({ error: err.message }) } }) app.patch('/api/nw/handoff/:chatId', verifyAnyToken, async (req, res) => { try { const tenantId = req.tenantId || req.admin?.tenant_id || req.teamAccount?.tenantId const { mode } = req.body if (!['ia', 'humano'].includes(mode)) return res.status(400).json({ error: 'mode inválido' }) const humanUntil = mode === 'humano' ? new Date(Date.now() + 15 * 60 * 1000) : null await execute( `INSERT INTO nw_handoff (chat_id, tenant_id, human_until, updated_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (chat_id, tenant_id) DO UPDATE SET human_until=$3, updated_at=NOW()`, [req.params.chatId, tenantId, humanUntil] ) if (mode === 'humano') { sseHandoffQueue.add('handoff-expire', { chatId: req.params.chatId, tenantId }, { delay: 15 * 60 * 1000 }) } res.json({ mode, handoffHumanAt: humanUntil?.toISOString() ?? null }) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── Modo auto-resposta (lê e salva) ──────────────────────────────────────── app.get('/api/admin/nw/auto-reply-mode', verifyAnyToken, async (req, res) => { try { const row = await queryOne( `SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'auto_reply_mode'` ) res.json({ mode: row?.value ?? 'off' }) } catch (err) { res.status(500).json({ error: err.message }) } }) app.put('/api/admin/nw/auto-reply-mode', verifyToken, async (req, res) => { try { const { mode } = req.body if (!['off', 'automatico'].includes(mode)) return res.status(400).json({ error: 'mode deve ser off ou automatico' }) await execute( `INSERT INTO plugin_configs (plugin_id, key, value) VALUES ('newwhats', 'auto_reply_mode', $1) ON CONFLICT (plugin_id, key) DO UPDATE SET value = $1`, [mode] ) res.json({ ok: true, mode }) } catch (err) { res.status(500).json({ error: err.message }) } }) // ── Eventos recebidos via webhook NewWhats (nw_event_logs) ───────────────── app.get('/api/admin/nw-events', verifyToken, async (req, res) => { try { const { event, instance_id, direction, limit = 100, offset = 0 } = req.query const rows = await query( `SELECT id, instance_id, event, direction, chat_id, from_me, msg_type, payload, created_at FROM nw_event_logs WHERE ($1::text IS NULL OR event = $1) AND ($2::text IS NULL OR instance_id = $2) AND ($3::text IS NULL OR direction = $3) ORDER BY created_at DESC LIMIT $4 OFFSET $5`, [event || null, instance_id || null, direction || null, +limit, +offset] ) const total = await queryOne( `SELECT COUNT(*) AS cnt FROM nw_event_logs WHERE ($1::text IS NULL OR event = $1) AND ($2::text IS NULL OR instance_id = $2) AND ($3::text IS NULL OR direction = $3)`, [event || null, instance_id || null, direction || null] ) res.json({ rows, total: parseInt(total?.cnt ?? 0) }) } catch (err) { console.error('[nw-events]', err.message) res.status(500).json({ error: 'Erro interno' }) } }) // ── Super Admin API ──────────────────────────────────────────────────────── const { verifySuperToken } = require('./middleware/superAuth') const superAdminCtrl = require('./controllers/superAdminController') app.post('/superadmin/api/login', superAdminCtrl.login) app.post('/superadmin/api/logout', superAdminCtrl.logout) app.get( '/superadmin/api/me', verifySuperToken, superAdminCtrl.me) app.get( '/superadmin/api/tenants', verifySuperToken, superAdminCtrl.listTenants) app.post('/superadmin/api/tenants', verifySuperToken, superAdminCtrl.createTenant) app.put( '/superadmin/api/tenants/:id', verifySuperToken, superAdminCtrl.updateTenant) app.delete('/superadmin/api/tenants/:id', verifySuperToken, superAdminCtrl.deleteTenant) app.get( '/superadmin/api/tenants/:id/stats',verifySuperToken, superAdminCtrl.getTenantStats) app.post('/superadmin/api/tenants/:id/admins',verifySuperToken, superAdminCtrl.createTenantAdmin) // Serve superadmin SPA app.get('/superadmin*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'superadmin', 'index.html')) }) // Status endpoint app.get('/api/status', (req, res) => { res.json({ status: 'online', system: 'nuvem', timestamp: new Date().toISOString() }); }); // ── Tenant config pública ───────────────────────────────────────────────── app.get('/api/tenant/config', (req, res) => { const t = req.tenant if (!t) return res.status(404).json({ error: 'Tenant não encontrado' }) res.json({ id: t.id, slug: t.slug, name: t.name, primary_color: t.primary_color || '#f59e0b', secondary_color: t.secondary_color || '#10b981', logo_url: t.logo_url || null, favicon_url: t.favicon_url || null, contact_email: t.contact_email || null, contact_phone: t.contact_phone || null, }) }) // ── Tenant config admin (atualizar branding) ────────────────────────────── app.put('/api/tenant/config', verifyToken, async (req, res) => { try { const { primary_color, secondary_color, logo_url, favicon_url, contact_email, contact_phone, app_url, asaas_key } = req.body const tenantId = req.tenant?.id if (!tenantId) return res.status(400).json({ error: 'Tenant não identificado' }) await execute( `UPDATE tenant_config SET primary_color=$1, secondary_color=$2, logo_url=$3, favicon_url=$4, contact_email=$5, contact_phone=$6, app_url=$7, asaas_key=$8 WHERE tenant_id=$9`, [primary_color, secondary_color, logo_url || null, favicon_url || null, contact_email || null, contact_phone || null, app_url || null, asaas_key || null, tenantId] ) invalidateTenantCache(req.tenant.slug) invalidateTenantCache(req.tenant.domain) res.json({ ok: true }) } catch (err) { console.error('[tenant config update]', err.message) res.status(500).json({ error: 'Erro ao atualizar configuração' }) } }) // Customer App (React SPA) — serve at /app/* app.get('/app', (req, res) => res.redirect('/app/')) app.get('/app/*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'app', 'index.html')) }) // React Admin (V2) SPA Route app.get('/admin-v2*', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin-v2', 'index.html')); }); // Admin Auth routes — legacy HTML desabilitado, redireciona para o React app app.get('/admin', (req, res) => res.redirect('/admin-v2/')); app.post('/admin/login', adminController.login); app.get('/admin/dashboard', (req, res) => res.redirect('/admin-v2/')); app.get('/admin/logout', adminController.logout); // ── Equipe: login e perfil de funcionários ──────────────────────────────── const bcryptTeam = require('bcryptjs') const signTeamToken = _signTeamToken app.post('/api/team/login', async (req, res) => { try { const { email, password } = req.body if (!email || !password) return res.status(400).json({ error: 'Email e senha obrigatórios' }) const account = await queryOne( `SELECT id, tenant_id, name, email, password_hash, role, availability, active FROM accounts WHERE email = $1`, [email.trim().toLowerCase()] ) if (!account) return res.status(401).json({ error: 'Credenciais inválidas' }) if (!account.active) return res.status(403).json({ error: 'Conta suspensa' }) if (!account.password_hash) return res.status(401).json({ error: 'Senha não configurada. Peça ao administrador para definir sua senha.' }) const valid = await bcryptTeam.compare(password, account.password_hash) if (!valid) return res.status(401).json({ error: 'Credenciais inválidas' }) const token = signTeamToken(account) // Remove sessão de admin antes de criar sessão de equipe res.clearCookie('cloud_token') res.cookie('team_token', token, { httpOnly: true, maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'lax', }) // atualiza availability para online await execute('UPDATE accounts SET availability=$1 WHERE id=$2', ['online', account.id]) res.json({ account: { id: account.id, name: account.name, email: account.email, role: account.role }, tenantId: account.tenant_id, }) } catch (e) { console.error('[team/login]', e) res.status(500).json({ error: 'Erro interno' }) } }) // Aceita cloud_token (admin) OU team_token (colaborador). // Admin pode ter um account vinculado (campo account_id em admins) que o // identifica na Inbox Interna — nesse caso retorna o perfil desse account. // Se admin não tiver account vinculado, retorna 404 (sem acesso à inbox). // NOTA: account_id NÃO está no payload JWT do admin (evita token inchado), // por isso é lido do banco quando necessário. app.get('/api/team/me', verifyAnyToken, async (req, res) => { try { // Resolve o ID do account: // colaborador → req.teamAccount.accountId (do JWT team_token) // admin → account_id vinculado na tabela admins (não está no JWT) let accountId = req.teamAccount?.accountId ?? null if (!accountId && req.admin?.id) { const adminRow = await queryOne('SELECT account_id FROM admins WHERE id=$1', [req.admin.id]) accountId = adminRow?.account_id ?? null } if (!accountId) return res.status(404).json({ error: 'Sem conta de colaborador vinculada' }) const account = await queryOne( `SELECT a.id, a.tenant_id, a.name, a.email, a.role, a.availability, a.phone, a.phone_connected, a.avatar_url, COALESCE( json_agg(json_build_object( 'sector_id', tm.sector_id, 'sector_name', s.name, 'sector_color', s.color, 'function', tm.function, 'is_primary', tm.is_primary, 'on_duty', tm.on_duty, 'notify_escalation', tm.notify_escalation, 'notify_human_api', tm.notify_human_api )) FILTER (WHERE tm.id IS NOT NULL), '[]' ) AS sectors FROM accounts a LEFT JOIN team_members tm ON tm.account_id = a.id LEFT JOIN sectors s ON s.id = tm.sector_id WHERE a.id = $1 GROUP BY a.id`, [accountId] ) if (!account) return res.status(404).json({ error: 'Conta não encontrada' }) res.json({ account }) } catch (e) { console.error('[team/me]', e) res.status(500).json({ error: 'Erro interno' }) } }) app.post('/api/team/logout', (req, res) => { res.clearCookie('team_token') res.clearCookie('cloud_token') // garante que nenhuma sessão cruzada persiste res.json({ ok: true }) }) // Upload de imagens de produtos const multer = require('multer'); const uploadStorage = multer.diskStorage({ destination: path.join(__dirname, 'public', 'images', 'products'), filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); const name = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, '-').toLowerCase(); cb(null, `${name}-${Date.now()}${ext}`); }, }); const upload = multer({ storage: uploadStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { cb(null, /^image\/(jpeg|png|webp|gif)$/.test(file.mimetype)); }, }); app.post('/api/promotions/upload', verifyToken, upload.single('image'), (req, res) => { if (!req.file) return res.status(400).json({ error: 'Ficheiro inválido' }); res.json({ path: `/images/products/${req.file.filename}` }); }); // Admin "me" — retorna info do admin logado + branding do seu tenant. // ATENÇÃO: esta DEVE ser a única rota GET /api/admin/me. Uma segunda // definição acima (Fase 12) foi movida para /api/admin/equipe/me porque // o Express executa apenas a primeira rota que bate — duplicatas silenciosas // são a causa dos erros 401 no frontend mesmo com admin autenticado. app.get('/api/admin/me', verifyToken, async (req, res) => { try { const tenantId = req.admin?.tenant_id || 1 const [admin, tenantRow] = await Promise.all([ // Incluímos account_id no SELECT: quando preenchido, o frontend usa // esse valor como identidade na Inbox Interna sem exigir login duplo. queryOne('SELECT id, username, nome, email, tenant_id, account_id FROM admins WHERE id=$1', [req.admin.id]), queryOne(`SELECT t.id, t.slug, t.name, tc.primary_color, tc.secondary_color, tc.logo_url FROM tenants t LEFT JOIN tenant_config tc ON tc.tenant_id = t.id WHERE t.id=$1`, [tenantId]), ]) // Se o admin tem um account vinculado, carrega os dados completos do // colaborador (nome, avatar, role) para exibir na Inbox Interna. let linkedAccount = null if (admin?.account_id) { linkedAccount = await queryOne( 'SELECT id, name, email, role, avatar_url FROM accounts WHERE id=$1 AND tenant_id=$2', [admin.account_id, tenantId] ) } res.json({ admin, tenant: tenantRow, linkedAccount }) } catch (err) { res.status(500).json({ error: 'Erro interno' }) } }) // ── Vincular admin a um account de colaborador ──────────────────────────── // Permite que o admin indique qual account da equipe é a sua identidade. // Chamado uma única vez pelo painel (ex: Settings ou EquipeTab). // Depois disso, /api/admin/me devolve linkedAccount e a Inbox Interna // funciona normalmente sem segundo login. app.patch('/api/admin/me/account', verifyToken, async (req, res) => { try { const { account_id } = req.body const tenantId = req.admin?.tenant_id || 1 // Valida que o account pertence ao mesmo tenant (segurança multi-tenant) if (account_id !== null) { const acc = await queryOne( 'SELECT id FROM accounts WHERE id=$1 AND tenant_id=$2', [account_id, tenantId] ) if (!acc) return res.status(404).json({ error: 'Account não encontrado neste tenant' }) } await execute( 'UPDATE admins SET account_id=$1 WHERE id=$2', [account_id || null, req.admin.id] ) const updated = await queryOne( 'SELECT id, username, nome, email, tenant_id, account_id FROM admins WHERE id=$1', [req.admin.id] ) res.json({ ok: true, admin: updated }) } catch (err) { res.status(500).json({ error: err.message }) } }) // Admin User Management API app.get('/api/admins', verifyToken, adminController.listAdmins); app.post('/api/admins', verifyToken, adminController.createAdmin); app.put('/api/admins/:id', verifyToken, adminController.updateAdmin); app.delete('/api/admins/:id', verifyToken, adminController.deleteAdmin); // Admin Order Management API app.get('/api/orders', verifyToken, adminController.listOrders); // Admin Claim Management API app.get('/api/claims', verifyToken, adminController.listClaims); app.patch('/api/claims/:id', verifyToken, adminController.updateClaimStatus); // Settings Asaas — lê/salva por tenant em tenant_config app.get('/api/settings/asaas', verifyToken, async (req, res) => { try { const tenantId = req.admin?.tenant_id || 1 const row = await queryOne('SELECT asaas_key FROM tenant_config WHERE tenant_id=$1', [tenantId]) // Fallback para variável de ambiente para compatibilidade res.json({ key: row?.asaas_key || process.env.ASAAS_API_KEY || '' }) } catch { res.json({ key: process.env.ASAAS_API_KEY || '' }) } }); app.post('/api/settings/asaas', verifyToken, async (req, res) => { const { key } = req.body; if (!key) return res.status(400).json({ error: 'Key is required' }); try { const tenantId = req.admin?.tenant_id || 1 await execute( `UPDATE tenant_config SET asaas_key=$1 WHERE tenant_id=$2`, [key, tenantId] ) res.json({ success: true }); } catch (err) { res.status(500).json({ error: 'Erro ao salvar chave' }) } }); // ── Rotas públicas �� customer-app React ────────────────────────────────── // Legado: redirecionar URLs antigas para o novo app React em /app/ app.get('/login', (req, res) => res.redirect('/app/login')) app.get('/area-do-usuario',(req, res) => res.redirect('/app/minha-area')) app.get('/sorteio', (req, res) => res.redirect('/app/raffle')) app.get('/raffle', (req, res) => res.redirect('/app/raffle')) app.get('/meus-premios', (req, res) => res.redirect('/app/minha-area')) app.get('/clube', (req, res) => res.redirect('/app/')) app.get('/clube/:slug', (req, res) => res.redirect(`/app/clube/${req.params.slug}`)) app.get('/carteirinha/:cpf',(req, res) => res.redirect(`/app/carteirinha/${req.params.cpf}`)) app.get('/user/logout', (req, res) => { res.clearCookie('user_token'); res.clearCookie('_usr'); res.redirect('/app/login'); }); // Landing page → customer-app React app.get('/', (req, res) => { // Preserva os query params (session, gateway) ao redirecionar para o app res.redirect(302, '/app/' + (req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '')); }); // Blocked Warning Page (Squidguard Redirect) — mantém HTML estático app.get('/bloqueado', (req, res) => { res.sendFile(path.join(__dirname, 'views', 'landing', 'bloqueado.html')); }); // ── Plugin System ───────────────────────────────────────────────────────── // httpServer criado antes dos plugins para que possam registrar handlers de upgrade (WS proxy) const http = require('http') const httpServer = http.createServer(app) const pluginManager = new PluginManager(app, { query, queryOne, execute }, httpServer) await pluginManager.loadAll() // Admin API — Plugins (protegido por verifyToken) app.get('/api/plugins', verifyToken, async (req, res) => { res.json(await pluginManager.list()) }) app.post('/api/plugins/:id/activate', verifyToken, async (req, res) => { try { await pluginManager.activate(req.params.id) res.json({ ok: true }) } catch (err) { res.status(400).json({ error: err.message }) } }) app.post('/api/plugins/:id/deactivate', verifyToken, async (req, res) => { try { await pluginManager.deactivate(req.params.id) res.json({ ok: true }) } catch (err) { res.status(400).json({ error: err.message }) } }) app.get('/api/plugins/:id/config', verifyToken, async (req, res) => { const config = await pluginManager.getConfig(req.params.id) res.json(config) }) app.put('/api/plugins/:id/config', verifyToken, async (req, res) => { try { await pluginManager.setConfig(req.params.id, req.body) res.json({ ok: true }) } catch (err) { res.status(400).json({ error: err.message }) } }) // ── Proxy de pareamento NewWhats (evita CORS no browser) ───────────────── app.post('/api/pair/proxy/request', verifyToken, async (req, res) => { const { email, nwUrl } = req.body if (!email || !nwUrl) return res.status(400).json({ error: 'email e nwUrl obrigatórios' }) try { const r = await fetch(`${nwUrl}/api/pair/request`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, clientSystem: 'alemao-conveniencias', clientName: 'Alemão Conveniências', clientUrl: `http://localhost:${PORT}`, }), }) const data = await r.json() res.status(r.status).json(data) } catch (err) { res.status(502).json({ error: `Não foi possível conectar ao NewWhats: ${err.message}` }) } }) app.post('/api/pair/proxy/confirm', verifyToken, async (req, res) => { const { email, code, nwUrl } = req.body if (!email || !code || !nwUrl) return res.status(400).json({ error: 'email, code e nwUrl obrigatórios' }) try { const r = await fetch(`${nwUrl}/api/pair/confirm`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, code, clientSystem: 'alemao-conveniencias', clientName: 'Alemão Conveniências', clientUrl: `http://localhost:${PORT}`, }), }) const data = await r.json() if (!r.ok) return res.status(r.status).json(data) res.json({ integration_key: data.integration_key, account: data.account }) } catch (err) { res.status(502).json({ error: `Não foi possível conectar ao NewWhats: ${err.message}` }) } }) // WPP-09 — Job de reativação de clientes inativos try { const { startReativacaoJob } = require('./jobs/reativacaoClientes') startReativacaoJob() } catch (e) { console.warn('[reativacao] Job não iniciado:', e.message) } // FUT-02 — Job de pedidos recorrentes PJ try { const { startRecorrentesJob } = require('./jobs/pedidosRecorrentes') startRecorrentesJob() } catch (e) { console.warn('[recorrentes] Job não iniciado:', e.message) } // MON-01 — Monitor de mensagens WhatsApp sem resposta try { const { startMonitorJob } = require('./jobs/monitorMsgsSemResposta') startMonitorJob() } catch (e) { console.warn('[monitor-wpp] Job não iniciado:', e.message) } // Start server httpServer.listen(PORT, '0.0.0.0', () => { console.log(''); console.log('╔══════════════════════════════════════════════╗'); console.log('║ ☁️ SISTEMA NUVEM - PROMOÇÕES + SORTEIO ║'); console.log('║ 🌐 http://localhost:' + PORT + ' ║'); console.log('║ 🔒 Admin: http://localhost:' + PORT + '/admin ║'); console.log('╚══════════════════════════════════════════════╝'); console.log(''); }); } startServer().catch(err => { console.error('❌ Erro ao iniciar servidor nuvem:', err); process.exit(1); });