1737 lines
86 KiB
JavaScript
1737 lines
86 KiB
JavaScript
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);
|
||
});
|