feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker
@@ -0,0 +1,40 @@
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Database files
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
backend/config.json
|
||||
aleprojeto_completo/
|
||||
@@ -0,0 +1,226 @@
|
||||
'use strict'
|
||||
|
||||
const Admin = require('../models/Admin')
|
||||
const bcrypt = require('bcryptjs')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const path = require('path')
|
||||
const { JWT_SECRET } = require('../middleware/auth')
|
||||
const Claim = require('../models/Claim')
|
||||
const NotificationService = require('../services/notificationService')
|
||||
const { query, queryOne } = require('../database/postgres')
|
||||
|
||||
const adminController = {
|
||||
getLoginPage(req, res) {
|
||||
res.sendFile(path.join(__dirname, '..', 'views', 'admin', 'login.html'))
|
||||
},
|
||||
|
||||
getDashboardPage(req, res) {
|
||||
res.sendFile(path.join(__dirname, '..', 'views', 'admin', 'dashboard.html'))
|
||||
},
|
||||
|
||||
async login(req, res) {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
const cleanUser = username?.trim();
|
||||
const admin = await Admin.findByUsername(cleanUser)
|
||||
|
||||
if (!admin || !bcrypt.compareSync(password, admin.password_hash)) {
|
||||
return res.status(401).json({ error: 'Credenciais inválidas' })
|
||||
}
|
||||
const token = jwt.sign(
|
||||
{ id: admin.id, username: admin.username, tenant_id: admin.tenant_id || 1 },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
)
|
||||
// Remove sessão de equipe antes de criar sessão de admin
|
||||
res.clearCookie('team_token')
|
||||
res.cookie('cloud_token', token, {
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
sameSite: 'lax',
|
||||
})
|
||||
return res.json({ success: true, redirect: '/admin/dashboard' })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno no login' })
|
||||
}
|
||||
},
|
||||
|
||||
logout(req, res) {
|
||||
res.clearCookie('cloud_token')
|
||||
res.clearCookie('team_token')
|
||||
res.redirect('/admin')
|
||||
},
|
||||
|
||||
async listAdmins(req, res) {
|
||||
try {
|
||||
const admins = await Admin.findAll()
|
||||
return res.json(admins)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao listar administradores' })
|
||||
}
|
||||
},
|
||||
|
||||
async createAdmin(req, res) {
|
||||
try {
|
||||
const { username, password } = req.body
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Usuário e senha são obrigatórios' })
|
||||
}
|
||||
if (username.length < 3) return res.status(400).json({ error: 'Usuário curto' })
|
||||
if (password.length < 8) return res.status(400).json({ error: 'Senha deve ter no mínimo 8 caracteres' })
|
||||
|
||||
const existing = await Admin.findByUsername(username)
|
||||
if (existing) return res.status(400).json({ error: 'Usuário existente' })
|
||||
|
||||
const hash = bcrypt.hashSync(password, 10)
|
||||
const result = await Admin.create(username, hash)
|
||||
return res.json({ success: true, id: result.lastInsertRowid, message: 'Sucesso' })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao criar' })
|
||||
}
|
||||
},
|
||||
|
||||
async updateAdmin(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { password, username, nome, email, telefone } = req.body
|
||||
|
||||
const admin = await Admin.findById(parseInt(id))
|
||||
if (!admin) return res.status(404).json({ error: 'Não encontrado' })
|
||||
|
||||
const updateData = {}
|
||||
if (username && username !== admin.username) {
|
||||
const existing = await Admin.findByUsername(username)
|
||||
if (existing && existing.id !== parseInt(id)) return res.status(400).json({ error: 'Nome em uso' })
|
||||
updateData.username = username
|
||||
}
|
||||
if (password) {
|
||||
if (password.length < 8) return res.status(400).json({ error: 'Senha deve ter no mínimo 8 caracteres' })
|
||||
updateData.password_hash = bcrypt.hashSync(password, 10)
|
||||
}
|
||||
updateData.nome = nome || null
|
||||
updateData.email = email || null
|
||||
updateData.telefone = telefone || null
|
||||
|
||||
await Admin.update(parseInt(id), updateData)
|
||||
return res.json({ success: true, message: 'Atualizado' })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao atualizar' })
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAdmin(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
if (parseInt(id) === req.admin.id) return res.status(400).json({ error: 'Não pode se excluir' })
|
||||
|
||||
const count = await Admin.count()
|
||||
if (count <= 1) return res.status(400).json({ error: 'Último admin' })
|
||||
|
||||
await Admin.delete(parseInt(id))
|
||||
return res.json({ success: true, message: 'Removido' })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao deletar' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Histórico de Pedidos ──────────────────────────────────────────────────
|
||||
|
||||
async listOrders(req, res) {
|
||||
try {
|
||||
const { status, from, to } = req.query
|
||||
const params = []
|
||||
const where = []
|
||||
if (req.tenant?.id) { where.push(`o.tenant_id = $${params.length + 1}`); params.push(req.tenant.id) }
|
||||
if (status) { where.push(`o.status = $${params.length + 1}`); params.push(status) }
|
||||
if (from) { where.push(`o.created_at >= $${params.length + 1}`); params.push(from) }
|
||||
if (to) { where.push(`o.created_at <= $${params.length + 1}`); params.push(to + 'T23:59:59') }
|
||||
|
||||
const sql = `
|
||||
SELECT o.id, o.status, o.total_amount, o.payment_method,
|
||||
o.created_at, o.updated_at, o.user_cpf,
|
||||
u.nome AS user_nome, u.telefone AS user_telefone,
|
||||
COUNT(t.id)::int AS ticket_count
|
||||
FROM orders o
|
||||
LEFT JOIN users u ON u.cpf = o.user_cpf
|
||||
LEFT JOIN tickets t ON t.order_id = o.id
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
GROUP BY o.id, u.nome, u.telefone
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT 300
|
||||
`
|
||||
const rows = await query(sql, params)
|
||||
return res.json(rows ?? [])
|
||||
} catch (err) {
|
||||
console.error('[Admin] listOrders:', err.message)
|
||||
return res.status(500).json({ error: 'Erro ao listar pedidos' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Gestão de Resgates (raffle_claims) ────────────────────────────────────
|
||||
|
||||
async listClaims(req, res) {
|
||||
try {
|
||||
const { status } = req.query
|
||||
const params = []
|
||||
const where = []
|
||||
if (req.tenant?.id) { where.push(`r.tenant_id = $${params.length + 1}`); params.push(req.tenant.id) }
|
||||
if (status) { where.push(`c.status = $${params.length + 1}`); params.push(status) }
|
||||
|
||||
let sql = `
|
||||
SELECT c.id, c.status, c.delivery_address, c.delivery_notes,
|
||||
c.claimed_at, c.updated_at,
|
||||
r.titulo AS raffle_titulo,
|
||||
t.numero AS ticket_numero,
|
||||
u.nome AS user_nome,
|
||||
u.cpf AS user_cpf,
|
||||
u.telefone AS user_telefone
|
||||
FROM raffle_claims c
|
||||
JOIN raffles r ON r.id = c.raffle_id
|
||||
JOIN tickets t ON t.id = c.ticket_id
|
||||
LEFT JOIN users u ON u.cpf = c.user_cpf
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
`
|
||||
const rows = await query(sql, params)
|
||||
return res.json(rows ?? [])
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'Erro ao listar resgates' })
|
||||
}
|
||||
},
|
||||
|
||||
async updateClaimStatus(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { status } = req.body
|
||||
const VALID = ['pending', 'approved', 'delivered']
|
||||
if (!VALID.includes(status)) {
|
||||
return res.status(400).json({ error: `Status inválido. Use: ${VALID.join(', ')}` })
|
||||
}
|
||||
|
||||
const claim = await Claim.getClaimDetails(id)
|
||||
if (!claim) return res.status(404).json({ error: 'Resgate não encontrado' })
|
||||
|
||||
await Claim.updateStatus(id, status)
|
||||
|
||||
// Notifica o ganhador sobre a mudança de status
|
||||
const user = await queryOne('SELECT nome, telefone FROM users WHERE cpf = $1', [claim.user_cpf])
|
||||
if (status === 'approved') {
|
||||
NotificationService.notifyClaimApproved(
|
||||
user?.telefone, claim.user_cpf, user?.nome, claim.raffle_titulo
|
||||
).catch(() => {})
|
||||
} else if (status === 'delivered') {
|
||||
NotificationService.notifyClaimDelivered(
|
||||
user?.telefone, claim.user_cpf, user?.nome, claim.raffle_titulo
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
return res.json({ ok: true, status })
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: 'Erro ao atualizar resgate' })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = adminController
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict'
|
||||
|
||||
const Analytics = require('../models/Analytics')
|
||||
|
||||
const analyticsController = {
|
||||
async getDashboardData(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant?.id
|
||||
const revenueStats = await Analytics.getRevenueStats(null, tenantId)
|
||||
const salesByDay = await Analytics.getSalesByDay(tenantId)
|
||||
const salesByHour = await Analytics.getSalesByHour(tenantId)
|
||||
const salesByRaffle = await Analytics.getSalesByRaffle(tenantId)
|
||||
|
||||
return res.json({ revenueStats, salesByDay, salesByHour, salesByRaffle })
|
||||
} catch (e) {
|
||||
console.error('Erro no Analytics Dashboard', e.message)
|
||||
return res.status(500).json({ error: 'Erro interno ao consultar relatórios' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = analyticsController
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict'
|
||||
|
||||
const Broadcast = require('../models/Broadcast')
|
||||
const { sseBroadcastTenant } = require('../services/sse')
|
||||
|
||||
function extractAuth(req) {
|
||||
const accountId = req.teamAccount?.accountId || req.admin?.id || null
|
||||
const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id || null
|
||||
const role = req.admin?.role || req.teamAccount?.role || null
|
||||
return { accountId, tenantId, role }
|
||||
}
|
||||
|
||||
class BroadcastController {
|
||||
/**
|
||||
* POST /api/admin/inbox/broadcast
|
||||
* Enviar broadcast para todos os colaboradores do tenant.
|
||||
* Restrito a owner e manager.
|
||||
*/
|
||||
async send(req, res) {
|
||||
try {
|
||||
const { accountId, tenantId, role } = extractAuth(req)
|
||||
|
||||
if (!accountId || !tenantId) return res.status(401).json({ error: 'Não autenticado' })
|
||||
if (role !== 'owner' && role !== 'manager') {
|
||||
return res.status(403).json({ error: 'Apenas owner ou manager podem enviar broadcasts' })
|
||||
}
|
||||
|
||||
const { content } = req.body
|
||||
if (!content?.trim()) return res.status(400).json({ error: 'content obrigatório' })
|
||||
|
||||
const bc = await Broadcast.send(tenantId, accountId, content.trim())
|
||||
res.json(bc)
|
||||
|
||||
sseBroadcastTenant(tenantId, 'broadcast.sent', {
|
||||
id: bc.id,
|
||||
from: accountId,
|
||||
content: content.trim(),
|
||||
created_at: bc.created_at,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[broadcast.send]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao enviar broadcast' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/inbox/broadcasts
|
||||
* Histórico de broadcasts + unread_count do colaborador
|
||||
*/
|
||||
async list(req, res) {
|
||||
try {
|
||||
const { accountId, tenantId } = extractAuth(req)
|
||||
if (!tenantId) return res.status(401).json({ error: 'Não autenticado' })
|
||||
|
||||
const [items, unread] = await Promise.all([
|
||||
Broadcast.list(tenantId),
|
||||
accountId ? Broadcast.unreadCount(tenantId, accountId) : Promise.resolve(0),
|
||||
])
|
||||
res.json({ items, unread_count: unread })
|
||||
} catch (err) {
|
||||
console.error('[broadcast.list]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao listar broadcasts' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/inbox/broadcasts/seen
|
||||
* Marcar todos os broadcasts como vistos
|
||||
*/
|
||||
async markSeen(req, res) {
|
||||
try {
|
||||
const { accountId, tenantId } = extractAuth(req)
|
||||
if (!accountId || !tenantId) return res.status(401).json({ error: 'Não autenticado' })
|
||||
await Broadcast.markSeen(tenantId, accountId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[broadcast.markSeen]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao marcar como visto' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new BroadcastController()
|
||||
@@ -0,0 +1,215 @@
|
||||
'use strict'
|
||||
|
||||
const Cliente = require('../models/Cliente')
|
||||
|
||||
function validarCpf(cpf) {
|
||||
cpf = cpf.replace(/\D/g, '')
|
||||
if (cpf.length !== 11 || /^(\d)\1+$/.test(cpf)) return false
|
||||
let s = 0
|
||||
for (let i = 0; i < 9; i++) s += +cpf[i] * (10 - i)
|
||||
let r = (s * 10) % 11; if (r === 10 || r === 11) r = 0
|
||||
if (r !== +cpf[9]) return false
|
||||
s = 0
|
||||
for (let i = 0; i < 10; i++) s += +cpf[i] * (11 - i)
|
||||
r = (s * 10) % 11; if (r === 10 || r === 11) r = 0
|
||||
return r === +cpf[10]
|
||||
}
|
||||
|
||||
function validarCnpj(cnpj) {
|
||||
cnpj = cnpj.replace(/\D/g, '')
|
||||
if (cnpj.length !== 14 || /^(\d)\1+$/.test(cnpj)) return false
|
||||
const calc = (n) => {
|
||||
let s = 0, p = n - 7
|
||||
for (let i = 0; i < n; i++) { p = p < 2 ? p + 9 : p; s += +cnpj[i] * p--; }
|
||||
return s % 11 < 2 ? 0 : 11 - (s % 11)
|
||||
}
|
||||
return calc(12) === +cnpj[12] && calc(13) === +cnpj[13]
|
||||
}
|
||||
|
||||
const clienteController = {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant.id
|
||||
const { search, tipo, status, limit, offset } = req.query
|
||||
const rows = await Cliente.list(tenantId, { search, tipo, status, limit, offset })
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
console.error('[cliente.list]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao listar clientes' })
|
||||
}
|
||||
},
|
||||
|
||||
async get(req, res) {
|
||||
try {
|
||||
const cliente = await Cliente.findComplete(req.params.id)
|
||||
if (!cliente) return res.status(404).json({ error: 'Cliente não encontrado' })
|
||||
res.json(cliente)
|
||||
} catch (err) {
|
||||
console.error('[cliente.get]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao buscar cliente' })
|
||||
}
|
||||
},
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant.id
|
||||
const { tipo, nome, cpf_cnpj, email, status } = req.body
|
||||
if (!tipo || !['PF', 'PJ'].includes(tipo)) return res.status(400).json({ error: 'tipo deve ser PF ou PJ' })
|
||||
if (!nome?.trim()) return res.status(400).json({ error: 'nome é obrigatório' })
|
||||
if (!cpf_cnpj?.trim()) return res.status(400).json({ error: 'cpf_cnpj é obrigatório' })
|
||||
|
||||
const doc = cpf_cnpj.replace(/\D/g, '')
|
||||
if (tipo === 'PF' && !validarCpf(doc)) return res.status(400).json({ error: 'CPF inválido' })
|
||||
if (tipo === 'PJ' && !validarCnpj(doc)) return res.status(400).json({ error: 'CNPJ inválido' })
|
||||
|
||||
const existe = await Cliente.findByCpfCnpj(doc, tenantId)
|
||||
if (existe) return res.status(409).json({ error: 'CPF/CNPJ já cadastrado' })
|
||||
|
||||
const user_id = req.user?.id || null
|
||||
const cliente = await Cliente.create({ tenant_id: tenantId, tipo, nome: nome.trim(), cpf_cnpj: doc, email, status, user_id })
|
||||
res.status(201).json(cliente)
|
||||
} catch (err) {
|
||||
console.error('[cliente.create]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao criar cliente' })
|
||||
}
|
||||
},
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const { nome, email, status } = req.body
|
||||
if (!nome?.trim()) return res.status(400).json({ error: 'nome é obrigatório' })
|
||||
await Cliente.update(req.params.id, { nome: nome.trim(), email, status })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[cliente.update]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao atualizar cliente' })
|
||||
}
|
||||
},
|
||||
|
||||
async remove(req, res) {
|
||||
try {
|
||||
await Cliente.delete(req.params.id)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[cliente.remove]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao remover cliente' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Endereços ─────────────────────────────────────────────
|
||||
|
||||
async listEnderecos(req, res) {
|
||||
try {
|
||||
res.json(await Cliente.listEnderecos(req.params.id))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar endereços' })
|
||||
}
|
||||
},
|
||||
|
||||
async addEndereco(req, res) {
|
||||
try {
|
||||
const dados = { ...req.body, cliente_id: req.params.id }
|
||||
const end = await Cliente.createEndereco(dados)
|
||||
if (dados.principal) await Cliente.setEndereçoPrincipal(req.params.id, end.id)
|
||||
res.status(201).json(end)
|
||||
} catch (err) {
|
||||
console.error('[cliente.addEndereco]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao adicionar endereço' })
|
||||
}
|
||||
},
|
||||
|
||||
async updateEndereco(req, res) {
|
||||
try {
|
||||
await Cliente.updateEndereco(req.params.endId, req.body)
|
||||
if (req.body.principal) await Cliente.setEndereçoPrincipal(req.params.id, req.params.endId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar endereço' })
|
||||
}
|
||||
},
|
||||
|
||||
async removeEndereco(req, res) {
|
||||
try {
|
||||
await Cliente.deleteEndereco(req.params.endId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao remover endereço' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Telefones ─────────────────────────────────────────────
|
||||
|
||||
async listTelefones(req, res) {
|
||||
try {
|
||||
res.json(await Cliente.listTelefones(req.params.id))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar telefones' })
|
||||
}
|
||||
},
|
||||
|
||||
async addTelefone(req, res) {
|
||||
try {
|
||||
const tel = await Cliente.createTelefone({ ...req.body, cliente_id: req.params.id })
|
||||
res.status(201).json(tel)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao adicionar telefone' })
|
||||
}
|
||||
},
|
||||
|
||||
async updateTelefone(req, res) {
|
||||
try {
|
||||
await Cliente.updateTelefone(req.params.telId, req.body)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar telefone' })
|
||||
}
|
||||
},
|
||||
|
||||
async removeTelefone(req, res) {
|
||||
try {
|
||||
await Cliente.deleteTelefone(req.params.telId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao remover telefone' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Responsáveis ─────────────────────────────────────────
|
||||
|
||||
async listResponsaveis(req, res) {
|
||||
try {
|
||||
res.json(await Cliente.listResponsaveis(req.params.id))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar responsáveis' })
|
||||
}
|
||||
},
|
||||
|
||||
async addResponsavel(req, res) {
|
||||
try {
|
||||
const r = await Cliente.createResponsavel({ ...req.body, cliente_id: req.params.id })
|
||||
res.status(201).json(r)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao adicionar responsável' })
|
||||
}
|
||||
},
|
||||
|
||||
async updateResponsavel(req, res) {
|
||||
try {
|
||||
await Cliente.updateResponsavel(req.params.respId, req.body)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar responsável' })
|
||||
}
|
||||
},
|
||||
|
||||
async removeResponsavel(req, res) {
|
||||
try {
|
||||
await Cliente.deleteResponsavel(req.params.respId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao remover responsável' })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = clienteController
|
||||
@@ -0,0 +1,525 @@
|
||||
'use strict'
|
||||
|
||||
const BenefitClub = require('../models/BenefitClub')
|
||||
const ClubMember = require('../models/ClubMember')
|
||||
const ClubPayment = require('../models/ClubPayment')
|
||||
const ClubPartner = require('../models/ClubPartner')
|
||||
const ClubAsaas = require('../services/clubAsaasService')
|
||||
const ClubNotif = require('../services/clubNotificationService')
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
function validateCPF(cpf) {
|
||||
cpf = cpf.replace(/[^\d]+/g, '')
|
||||
if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false
|
||||
let sum = 0
|
||||
for (let i = 0; i < 9; i++) sum += parseInt(cpf.charAt(i)) * (10 - i)
|
||||
let rem = 11 - (sum % 11); if (rem >= 10) rem = 0
|
||||
if (rem !== parseInt(cpf.charAt(9))) return false
|
||||
sum = 0
|
||||
for (let i = 0; i < 10; i++) sum += parseInt(cpf.charAt(i)) * (11 - i)
|
||||
rem = 11 - (sum % 11); if (rem >= 10) rem = 0
|
||||
return rem === parseInt(cpf.charAt(10))
|
||||
}
|
||||
|
||||
// Formata próxima data de cobrança (hoje + 1 mês)
|
||||
function nextBillingDate() {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() + 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// ── Público ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.listClubs = async (req, res) => {
|
||||
try {
|
||||
const clubs = await BenefitClub.findAll(req.tenant.id)
|
||||
for (const c of clubs) {
|
||||
c.plans = await BenefitClub.getPlans(c.id)
|
||||
}
|
||||
res.json(clubs)
|
||||
} catch (err) {
|
||||
console.error('[clubController] listClubs:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.getClub = async (req, res) => {
|
||||
try {
|
||||
const club = await BenefitClub.findBySlug(req.params.slug, req.tenant.id)
|
||||
if (!club) return res.status(404).json({ error: 'Clube não encontrado' })
|
||||
club.plans = await BenefitClub.getPlans(club.id)
|
||||
club.partners = await ClubPartner.findByClub(club.id)
|
||||
// conditions e rules já vêm do findBySlug (colunas da tabela benefit_clubs)
|
||||
res.json(club)
|
||||
} catch (err) {
|
||||
console.error('[clubController] getClub:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Assinatura ────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.subscribe = async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.params
|
||||
const { plan_id, user_cpf, user_nome, user_telefone, user_email } = req.body
|
||||
|
||||
if (!plan_id || !user_cpf || !user_nome)
|
||||
return res.status(400).json({ error: 'Campos obrigatórios: plan_id, user_cpf, user_nome' })
|
||||
|
||||
if (!validateCPF(user_cpf)) return res.status(400).json({ error: 'CPF inválido' })
|
||||
|
||||
const club = await BenefitClub.findBySlug(slug, req.tenant.id)
|
||||
if (!club) return res.status(404).json({ error: 'Clube não encontrado' })
|
||||
|
||||
const plan = await BenefitClub.getPlanById(plan_id)
|
||||
if (!plan || plan.club_id !== club.id)
|
||||
return res.status(404).json({ error: 'Plano não encontrado' })
|
||||
|
||||
// Bloqueia apenas se já há assinatura ativa — suspended/pending_payment podem reinscrever
|
||||
const existing = await ClubMember.findByCpfAndClub(user_cpf, club.id)
|
||||
if (existing?.status === 'active') return res.status(409).json({ error: 'CPF já possui assinatura ativa neste clube' })
|
||||
|
||||
// Cria membro com status pending_payment
|
||||
const memberResult = await ClubMember.create({
|
||||
tenant_id: req.tenant.id,
|
||||
club_id: club.id,
|
||||
plan_id,
|
||||
user_cpf,
|
||||
user_nome,
|
||||
user_telefone: user_telefone || null,
|
||||
user_email: user_email || null,
|
||||
asaas_customer_id: null,
|
||||
asaas_subscription_id: null,
|
||||
next_billing_at: null,
|
||||
})
|
||||
const memberId = memberResult.rows?.[0]?.id
|
||||
|
||||
// Cria cliente + assinatura no Asaas em background (não bloqueia resposta)
|
||||
const tenantAsaasKey = req.tenant?.asaas_key || process.env.ASAAS_API_KEY
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const customer = await ClubAsaas.createCustomer({
|
||||
cpf: user_cpf, name: user_nome, phone: user_telefone, email: user_email,
|
||||
apiKey: tenantAsaasKey,
|
||||
})
|
||||
const subscription = await ClubAsaas.createSubscription({
|
||||
customerId: customer.id,
|
||||
value: plan.price_monthly,
|
||||
description: `${club.name} — ${plan.name}`,
|
||||
nextDueDate: nextBillingDate(),
|
||||
apiKey: tenantAsaasKey,
|
||||
})
|
||||
await ClubMember.updateSubscription(memberId, {
|
||||
asaas_customer_id: customer.id,
|
||||
asaas_subscription_id: subscription.id,
|
||||
next_billing_at: subscription.nextDueDate,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('[clubController] Asaas subscribe error:', e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// Notificação de boas-vindas
|
||||
ClubNotif.notifyWelcome(user_cpf, user_nome, user_telefone, club.name, plan.name, req.tenant?.id)
|
||||
.catch(() => {})
|
||||
|
||||
res.status(201).json({ ok: true, member_id: memberId })
|
||||
} catch (err) {
|
||||
console.error('[clubController] subscribe:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validação pública (recepção de clínica) ───────────────────────────────────
|
||||
|
||||
exports.validateMember = async (req, res) => {
|
||||
try {
|
||||
const cpf = req.params.cpf.replace(/\D/g, '')
|
||||
const slug = req.params.slug // 'odonto' ou 'medico'
|
||||
|
||||
const club = await BenefitClub.findBySlug(slug, req.tenant.id)
|
||||
if (!club) return res.status(404).json({ valid: false, error: 'Clube não encontrado' })
|
||||
|
||||
const member = await ClubMember.findByCpfAndClub(cpf, club.id)
|
||||
if (!member) return res.json({ valid: false, message: 'CPF não encontrado neste clube' })
|
||||
|
||||
if (member.status !== 'active') {
|
||||
return res.json({
|
||||
valid: false,
|
||||
message: `Plano ${member.status === 'suspended' ? 'suspenso por inadimplência' : 'inativo'}`,
|
||||
status: member.status,
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
user_nome: member.user_nome,
|
||||
user_cpf: cpf.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4'),
|
||||
plan_name: member.plan_name,
|
||||
club_name: club.name,
|
||||
next_billing: member.next_billing_at,
|
||||
status: member.status,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[clubController] validateMember:', err.message)
|
||||
res.status(500).json({ valid: false, error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parceiros por slug ────────────────────────────────────────────────────────
|
||||
|
||||
exports.listPartnersBySlug = async (req, res) => {
|
||||
try {
|
||||
const club = await BenefitClub.findBySlug(req.params.slug, req.tenant.id)
|
||||
if (!club) return res.status(404).json({ error: 'Clube não encontrado' })
|
||||
const partners = await ClubPartner.findByClub(club.id)
|
||||
res.json(partners || [])
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Carteirinha ───────────────────────────────────────────────────────────────
|
||||
|
||||
exports.getCard = async (req, res) => {
|
||||
try {
|
||||
const cpf = req.params.cpf.replace(/\D/g, '')
|
||||
if (!validateCPF(cpf)) return res.status(400).json({ error: 'CPF inválido' })
|
||||
const members = await ClubMember.findAllByCpf(cpf)
|
||||
if (!members?.length) return res.json([])
|
||||
res.json(members)
|
||||
} catch (err) {
|
||||
console.error('[clubController] getCard:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cancelamento ──────────────────────────────────────────────────────────────
|
||||
|
||||
exports.cancelMembership = async (req, res) => {
|
||||
try {
|
||||
const user = req.user
|
||||
const { member_id } = req.body
|
||||
const member = await ClubMember.findById(member_id)
|
||||
if (!member) return res.status(404).json({ error: 'Assinatura não encontrada' })
|
||||
if (member.user_cpf !== user.cpf?.replace(/\D/g, ''))
|
||||
return res.status(403).json({ error: 'Acesso negado' })
|
||||
|
||||
if (member.asaas_subscription_id) {
|
||||
await ClubAsaas.cancelSubscription(member.asaas_subscription_id).catch(console.error)
|
||||
}
|
||||
await ClubMember.updateStatus(member_id, 'canceled')
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[clubController] cancelMembership:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.adminListMembers = async (req, res) => {
|
||||
try {
|
||||
const { club_id, status, limit = 100, offset = 0 } = req.query
|
||||
if (!club_id) return res.status(400).json({ error: 'club_id obrigatório' })
|
||||
const members = await ClubMember.listByClub(club_id, { status, limit: +limit, offset: +offset })
|
||||
res.json(members)
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminListMembers:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminUpdateMemberStatus = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { status } = req.body
|
||||
const allowed = ['active', 'suspended', 'canceled', 'pending_payment']
|
||||
if (!allowed.includes(status))
|
||||
return res.status(400).json({ error: 'Status inválido' })
|
||||
|
||||
const member = await ClubMember.findById(id)
|
||||
if (!member) return res.status(404).json({ error: 'Membro não encontrado' })
|
||||
|
||||
// Sincroniza no Asaas se houver assinatura
|
||||
if (member.asaas_subscription_id) {
|
||||
if (status === 'canceled')
|
||||
await ClubAsaas.cancelSubscription(member.asaas_subscription_id).catch(console.error)
|
||||
else if (status === 'suspended')
|
||||
await ClubAsaas.suspendSubscription(member.asaas_subscription_id).catch(console.error)
|
||||
else if (status === 'active' && member.status === 'suspended')
|
||||
await ClubAsaas.reactivateSubscription(member.asaas_subscription_id).catch(console.error)
|
||||
}
|
||||
|
||||
await ClubMember.updateStatus(id, status)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminUpdateMemberStatus:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminListPartners = async (req, res) => {
|
||||
try {
|
||||
const { club_id } = req.query
|
||||
if (!club_id) return res.status(400).json({ error: 'club_id obrigatório' })
|
||||
res.json(await ClubPartner.findByClub(club_id))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminCreatePartner = async (req, res) => {
|
||||
try {
|
||||
const result = await ClubPartner.create({ ...req.body, tenant_id: req.tenant.id })
|
||||
res.status(201).json({ id: result.rows?.[0]?.id })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminUpdatePartner = async (req, res) => {
|
||||
try {
|
||||
await ClubPartner.update(req.params.id, req.body)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminDeletePartner = async (req, res) => {
|
||||
try {
|
||||
await ClubPartner.delete(req.params.id)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin — Planos ────────────────────────────────────────────────────────────
|
||||
|
||||
exports.adminListPlans = async (req, res) => {
|
||||
try {
|
||||
const { club_id } = req.query
|
||||
if (!club_id) return res.status(400).json({ error: 'club_id obrigatório' })
|
||||
const plans = await query(
|
||||
`SELECT * FROM club_plans WHERE club_id=$1 ORDER BY id ASC`,
|
||||
[club_id]
|
||||
)
|
||||
res.json(plans || [])
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminListPlans:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminCreatePlan = async (req, res) => {
|
||||
try {
|
||||
const { club_id, name, description, price_monthly, max_dependents, features } = req.body
|
||||
if (!club_id || !name || price_monthly == null)
|
||||
return res.status(400).json({ error: 'Campos obrigatórios: club_id, name, price_monthly' })
|
||||
const result = await execute(
|
||||
`INSERT INTO club_plans (club_id, name, description, price_monthly, max_dependents, features)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
[club_id, name, description || null, price_monthly, max_dependents || 0, JSON.stringify(features || [])]
|
||||
)
|
||||
res.status(201).json({ id: result.rows?.[0]?.id })
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminCreatePlan:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminUpdatePlan = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, description, price_monthly, max_dependents, features, active } = req.body
|
||||
await execute(
|
||||
`UPDATE club_plans SET
|
||||
name=$1, description=$2, price_monthly=$3, max_dependents=$4, features=$5, active=$6
|
||||
WHERE id=$7`,
|
||||
[name, description || null, price_monthly, max_dependents ?? 0, JSON.stringify(features || []), active ?? true, id]
|
||||
)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminUpdatePlan:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminDeletePlan = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
// Verifica se há membros ativos neste plano
|
||||
const active = await queryOne(
|
||||
`SELECT id FROM club_members WHERE plan_id=$1 AND status IN ('active','pending_payment','suspended') LIMIT 1`,
|
||||
[id]
|
||||
)
|
||||
if (active) return res.status(409).json({ error: 'Plano possui membros ativos. Desative-o primeiro.' })
|
||||
await execute(`DELETE FROM club_plans WHERE id=$1`, [id])
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminDeletePlan:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin — Conteúdo (condições + regras) ────────────────────────────────────
|
||||
|
||||
exports.adminSaveContent = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const tid = req.tenant?.id || req.admin?.tenant_id
|
||||
const { conditions, rules } = req.body
|
||||
const result = await execute(
|
||||
`UPDATE benefit_clubs SET conditions=$1, rules=$2 WHERE id=$3 AND tenant_id=$4`,
|
||||
[conditions || null, rules || null, id, tid]
|
||||
)
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Clube não encontrado' })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminSaveContent:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public — FAQs por slug ────────────────────────────────────────────────────
|
||||
|
||||
exports.listFaqsBySlug = async (req, res) => {
|
||||
try {
|
||||
const club = await BenefitClub.findBySlug(req.params.slug, req.tenant.id)
|
||||
if (!club) return res.status(404).json({ error: 'Clube não encontrado' })
|
||||
const faqs = await query(
|
||||
`SELECT id, question, answer, sort_order FROM club_faqs
|
||||
WHERE club_id=$1 AND active=true ORDER BY sort_order ASC, id ASC`,
|
||||
[club.id]
|
||||
)
|
||||
res.json(faqs || [])
|
||||
} catch (err) {
|
||||
console.error('[clubController] listFaqsBySlug:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin — FAQs ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.adminListFaqs = async (req, res) => {
|
||||
try {
|
||||
const { club_id } = req.query
|
||||
if (!club_id) return res.status(400).json({ error: 'club_id obrigatório' })
|
||||
const faqs = await query(
|
||||
`SELECT * FROM club_faqs WHERE club_id=$1 ORDER BY sort_order ASC, id ASC`,
|
||||
[club_id]
|
||||
)
|
||||
res.json(faqs || [])
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminListFaqs:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminCreateFaq = async (req, res) => {
|
||||
try {
|
||||
const { club_id, question, answer, sort_order, active } = req.body
|
||||
if (!club_id || !question || !answer)
|
||||
return res.status(400).json({ error: 'Campos obrigatórios: club_id, question, answer' })
|
||||
const result = await execute(
|
||||
`INSERT INTO club_faqs (club_id, tenant_id, question, answer, sort_order, active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
[club_id, req.tenant.id, question, answer, sort_order ?? 0, active ?? true]
|
||||
)
|
||||
res.status(201).json({ id: result.rows?.[0]?.id })
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminCreateFaq:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminUpdateFaq = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { question, answer, sort_order, active } = req.body
|
||||
await execute(
|
||||
`UPDATE club_faqs SET question=$1, answer=$2, sort_order=$3, active=$4 WHERE id=$5`,
|
||||
[question, answer, sort_order ?? 0, active ?? true, id]
|
||||
)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminUpdateFaq:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminDeleteFaq = async (req, res) => {
|
||||
try {
|
||||
await execute(`DELETE FROM club_faqs WHERE id=$1`, [req.params.id])
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[clubController] adminDeleteFaq:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Webhook Asaas (clube) ─────────────────────────────────────────────────────
|
||||
|
||||
const ASAAS_WEBHOOK_TOKEN = process.env.ASAAS_WEBHOOK_TOKEN
|
||||
|
||||
exports.asaasClubWebhook = async (req, res) => {
|
||||
try {
|
||||
if (!ASAAS_WEBHOOK_TOKEN) {
|
||||
console.error('[clubController] ASAAS_WEBHOOK_TOKEN não configurado')
|
||||
return res.status(500).json({ error: 'Configuração incompleta' })
|
||||
}
|
||||
const incomingToken = req.headers['asaas-access-token']
|
||||
if (!incomingToken || incomingToken !== ASAAS_WEBHOOK_TOKEN) {
|
||||
console.warn('[clubController] asaasClubWebhook: token inválido rejeitado')
|
||||
return res.status(401).json({ error: 'Não autorizado' })
|
||||
}
|
||||
|
||||
const { event, payment } = req.body
|
||||
if (!payment?.subscription) return res.sendStatus(200)
|
||||
|
||||
const member = await ClubMember.findBySubscriptionId(payment.subscription)
|
||||
if (!member) return res.sendStatus(200)
|
||||
|
||||
if (event === 'PAYMENT_RECEIVED' || event === 'PAYMENT_CONFIRMED') {
|
||||
await ClubMember.updateStatus(member.id, 'active')
|
||||
await ClubPayment.create({
|
||||
member_id: member.id,
|
||||
plan_id: member.plan_id,
|
||||
amount: payment.value,
|
||||
status: 'paid',
|
||||
asaas_payment_id: payment.id,
|
||||
reference_month: payment.dueDate?.slice(0, 7) || null,
|
||||
})
|
||||
// Atualiza próxima cobrança
|
||||
if (payment.billingType) {
|
||||
await ClubMember.updateSubscription(member.id, {
|
||||
asaas_subscription_id: member.asaas_subscription_id,
|
||||
asaas_customer_id: member.asaas_customer_id,
|
||||
next_billing_at: payment.originalDueDate || null,
|
||||
})
|
||||
}
|
||||
ClubNotif.notifyPaymentConfirmed(
|
||||
member.user_cpf, member.user_nome, member.user_telefone, member.club_name, member.tenant_id
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
if (event === 'PAYMENT_OVERDUE') {
|
||||
await ClubMember.updateStatus(member.id, 'suspended')
|
||||
ClubNotif.notifyPaymentOverdue(
|
||||
member.user_cpf, member.user_nome, member.user_telefone, member.club_name, member.tenant_id
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
if (event === 'PAYMENT_DELETED' || event === 'SUBSCRIPTION_DELETED') {
|
||||
await ClubMember.updateStatus(member.id, 'canceled')
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
} catch (err) {
|
||||
console.error('[clubController] asaasClubWebhook:', err.message)
|
||||
res.sendStatus(500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
'use strict'
|
||||
|
||||
const Cotacao = require('../models/Cotacao')
|
||||
const Pedido = require('../models/Pedido')
|
||||
const Produto = require('../models/Produto')
|
||||
const Promotion = require('../models/Promotion')
|
||||
const CotacaoFlashPromo = require('../models/CotacaoFlashPromo')
|
||||
const Notification = require('../models/Notification')
|
||||
const { queryOne: dbQueryOne, query: dbQuery } = require('../database/postgres')
|
||||
|
||||
// Notifica o WA-Inbox que um agente foi atribuído a um pedido (best-effort)
|
||||
async function notifyNwAssignAgent(pedidoId, accountId) {
|
||||
if (!accountId) return
|
||||
try {
|
||||
const cfgRows = await dbQuery(`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) return
|
||||
|
||||
// Busca chat_id vinculado ao pedido e dados do account
|
||||
const pedido = await dbQueryOne('SELECT chat_id FROM pedidos WHERE id=$1', [pedidoId])
|
||||
const account = await dbQueryOne('SELECT id, name FROM accounts WHERE id=$1', [accountId])
|
||||
if (!pedido?.chat_id || !account) return
|
||||
|
||||
await fetch(`${cfg.newwhats_url}/api/ext/v1/protocol/assign-agent`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integration_key },
|
||||
body: JSON.stringify({ chatId: pedido.chat_id, agentId: String(account.id), agentName: account.name }),
|
||||
})
|
||||
} catch { /* best-effort: nunca bloqueia o fluxo principal */ }
|
||||
}
|
||||
|
||||
// Aplica preço promocional se existir (PROMO-03)
|
||||
async function resolverPreco(produtoId, tenantId) {
|
||||
const prod = await Produto.findById(produtoId)
|
||||
if (!prod) return null
|
||||
return {
|
||||
preco_unitario: Number(prod.preco),
|
||||
preco_promocional: prod.preco_promocional ? Number(prod.preco_promocional) : null,
|
||||
nome_produto: prod.nome,
|
||||
sku: prod.sku,
|
||||
}
|
||||
}
|
||||
|
||||
// Desconto por volume PJ (PROMO-05): >10 itens = 3%, >30 = 5%, >50 = 8%
|
||||
function descontoPorVolume(tipo, quantidade) {
|
||||
if (tipo !== 'PJ') return 0
|
||||
if (quantidade >= 50) return 8
|
||||
if (quantidade >= 30) return 5
|
||||
if (quantidade >= 10) return 3
|
||||
return 0
|
||||
}
|
||||
|
||||
const cotacaoController = {
|
||||
// ── Área do cliente ───────────────────────────────────────
|
||||
|
||||
async minhas(req, res) {
|
||||
try {
|
||||
const rows = await Cotacao.listByUser(req.user.id, req.tenant.id)
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar cotações' })
|
||||
}
|
||||
},
|
||||
|
||||
async getCotacao(req, res) {
|
||||
try {
|
||||
const cotacao = await Cotacao.findById(req.params.id)
|
||||
if (!cotacao) return res.status(404).json({ error: 'Cotação não encontrada' })
|
||||
const itens = await Cotacao.listItens(req.params.id)
|
||||
res.json({ ...cotacao, itens })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar cotação' })
|
||||
}
|
||||
},
|
||||
|
||||
async criarOuObterRascunho(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant.id
|
||||
const userId = req.user.id
|
||||
|
||||
// Busca rascunho aberto do usuário
|
||||
const [rascunho] = await Cotacao.listByUser(userId, tenantId)
|
||||
.then(rows => rows.filter(r => r.status === 'rascunho'))
|
||||
|
||||
if (rascunho) {
|
||||
const itens = await Cotacao.listItens(rascunho.id)
|
||||
return res.json({ ...rascunho, itens })
|
||||
}
|
||||
|
||||
const nova = await Cotacao.create({ tenant_id: tenantId, user_id: userId })
|
||||
res.status(201).json({ ...nova, itens: [] })
|
||||
} catch (err) {
|
||||
console.error('[cotacao.rascunho]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao criar carrinho' })
|
||||
}
|
||||
},
|
||||
|
||||
async addItem(req, res) {
|
||||
try {
|
||||
const { cotacao_id, produto_id, quantidade } = req.body
|
||||
if (!cotacao_id || !produto_id || !quantidade) {
|
||||
return res.status(400).json({ error: 'cotacao_id, produto_id e quantidade são obrigatórios' })
|
||||
}
|
||||
const cotacao = await Cotacao.findById(cotacao_id)
|
||||
if (!cotacao) return res.status(404).json({ error: 'Cotação não encontrada' })
|
||||
|
||||
const info = await resolverPreco(produto_id, req.tenant.id)
|
||||
if (!info) return res.status(404).json({ error: 'Produto não encontrado' })
|
||||
|
||||
const desconto_pct = descontoPorVolume(cotacao.cliente_tipo, quantidade)
|
||||
|
||||
const item = await Cotacao.addItem({
|
||||
cotacao_id,
|
||||
produto_id,
|
||||
...info,
|
||||
quantidade,
|
||||
desconto_pct,
|
||||
})
|
||||
await Cotacao.recalcTotal(cotacao_id)
|
||||
res.status(201).json(item)
|
||||
} catch (err) {
|
||||
console.error('[cotacao.addItem]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao adicionar item' })
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(req, res) {
|
||||
try {
|
||||
await Cotacao.removeItem(req.params.itemId)
|
||||
await Cotacao.recalcTotal(req.params.cotacaoId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao remover item' })
|
||||
}
|
||||
},
|
||||
|
||||
async updateItemObs(req, res) {
|
||||
try {
|
||||
const { itemId } = req.params
|
||||
const { observacao } = req.body
|
||||
await Cotacao.updateItemObs(itemId, observacao)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao salvar observação' })
|
||||
}
|
||||
},
|
||||
|
||||
async salvar(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
await Cotacao.update(id, { ...(await Cotacao.findById(id)), status: 'salva' })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao salvar cotação' })
|
||||
}
|
||||
},
|
||||
|
||||
async finalizar(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { forma_pagto, endereco_id, tipo_entrega, observacao, endereco } = req.body
|
||||
|
||||
if (!forma_pagto) return res.status(400).json({ error: 'forma_pagto é obrigatória' })
|
||||
|
||||
const cotacao = await Cotacao.findById(id)
|
||||
if (!cotacao) return res.status(404).json({ error: 'Cotação não encontrada' })
|
||||
if (cotacao.status === 'finalizada') return res.status(409).json({ error: 'Cotação já finalizada' })
|
||||
|
||||
await Cotacao.update(id, { ...cotacao, forma_pagto })
|
||||
const pedido = await Cotacao.toOrder(id, { endereco_id, tipo_entrega, observacao })
|
||||
|
||||
// Endereço inline (sem endereco_id salvo)
|
||||
if (endereco && tipo_entrega === 'entrega') {
|
||||
await Pedido.updateAddressInline(pedido.id, endereco)
|
||||
}
|
||||
|
||||
// Pagamento na entrega → aguarda aprovação financeira
|
||||
if (forma_pagto === 'na_entrega') {
|
||||
const { execute } = require('../database/postgres')
|
||||
await execute(
|
||||
`UPDATE pedidos SET financeiro_status='pendente', updated_at=NOW() WHERE id=$1`,
|
||||
[pedido.id]
|
||||
)
|
||||
}
|
||||
|
||||
res.status(201).json(pedido)
|
||||
} catch (err) {
|
||||
console.error('[cotacao.finalizar]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao finalizar cotação' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Admin — financeiro ────────────────────────────────────
|
||||
|
||||
async listFinanceiro(req, res) {
|
||||
try {
|
||||
const { limit, offset } = req.query
|
||||
const rows = await Pedido.listFinanceiro(req.tenant.id, { limit, offset })
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar pendências financeiras' })
|
||||
}
|
||||
},
|
||||
|
||||
async financeiroDecisao(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { acao, obs } = req.body
|
||||
if (!['aprovar', 'rejeitar'].includes(acao)) {
|
||||
return res.status(400).json({ error: 'acao deve ser aprovar ou rejeitar' })
|
||||
}
|
||||
const pedido = await Pedido.findById(id)
|
||||
if (!pedido) return res.status(404).json({ error: 'Pedido não encontrado' })
|
||||
if (pedido.financeiro_status !== 'pendente') {
|
||||
return res.status(409).json({ error: 'Pedido já foi avaliado pelo financeiro' })
|
||||
}
|
||||
await Pedido.financeiroDecisao(id, { acao, obs })
|
||||
res.json({ ok: true, acao })
|
||||
} catch (err) {
|
||||
console.error('[cotacao.financeiroDecisao]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao processar decisão financeira' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Admin — pedidos ───────────────────────────────────────
|
||||
|
||||
async listPedidos(req, res) {
|
||||
try {
|
||||
const { status, limit, offset } = req.query
|
||||
const rows = await Pedido.listAdmin(req.tenant.id, { status, limit, offset })
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar pedidos' })
|
||||
}
|
||||
},
|
||||
|
||||
async getPedido(req, res) {
|
||||
try {
|
||||
const pedido = await Pedido.findById(req.params.id)
|
||||
if (!pedido) return res.status(404).json({ error: 'Pedido não encontrado' })
|
||||
const [itens, historico_responsaveis] = await Promise.all([
|
||||
Pedido.listItens(req.params.id),
|
||||
Pedido.findHistorico(req.params.id),
|
||||
])
|
||||
res.json({ ...pedido, itens, historico_responsaveis })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar pedido' })
|
||||
}
|
||||
},
|
||||
|
||||
async updateStatusPedido(req, res) {
|
||||
try {
|
||||
const { status, responsavel_entrega } = req.body
|
||||
const statusValidos = ['aguardando','separacao','embalagem','auditoria','fiscal','expedicao','entregue','cancelado']
|
||||
if (!statusValidos.includes(status)) return res.status(400).json({ error: 'Status inválido' })
|
||||
|
||||
const { expedidor_id } = req.body
|
||||
const accountId = expedidor_id
|
||||
? parseInt(expedidor_id)
|
||||
: (req.admin?.id || req.teamAccount?.accountId || null)
|
||||
if (status === 'entregue') {
|
||||
await Pedido.marcarEntregue(req.params.id, responsavel_entrega || null, accountId)
|
||||
} else {
|
||||
await Pedido.updateStatus(req.params.id, status, accountId)
|
||||
}
|
||||
|
||||
// Notificação interna para o usuário (user_id) que fez o pedido
|
||||
const STATUS_NOTIF = {
|
||||
separacao: { title: '📦 Pedido em separação', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} está sendo preparado.` },
|
||||
embalagem: { title: '📦 Pedido em embalagem', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} está sendo embalado.` },
|
||||
expedicao: { title: '🚚 Pedido saiu para entrega', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} saiu para entrega. Fique de olho!` },
|
||||
entregue: { title: '✅ Pedido marcado como entregue', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} foi marcado como entregue. Se não recebeu, entre em contato.` },
|
||||
cancelado: { title: '❌ Pedido cancelado', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} foi cancelado. Fale conosco se precisar de ajuda.` },
|
||||
auditoria: { title: '🔍 Pedido em auditoria', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} está em verificação final.` },
|
||||
fiscal: { title: '🧾 Pedido em processamento fiscal', message: (p) => `Seu pedido ${p.protocolo || '#'+p.id} está na etapa fiscal.` },
|
||||
}
|
||||
if (STATUS_NOTIF[status]) {
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const pedido = await Pedido.findById(req.params.id)
|
||||
if (!pedido?.user_id) return
|
||||
const userRow = await dbQueryOne('SELECT cpf FROM users WHERE id = $1', [pedido.user_id])
|
||||
if (!userRow?.cpf) return
|
||||
const notif = STATUS_NOTIF[status]
|
||||
await Notification.create({
|
||||
user_cpf: userRow.cpf.replace(/\D/g, ''),
|
||||
title: notif.title,
|
||||
message: notif.message(pedido),
|
||||
type: status === 'cancelado' ? 'error' : status === 'entregue' ? 'success' : 'info',
|
||||
})
|
||||
} catch { /* silent */ }
|
||||
})
|
||||
}
|
||||
|
||||
// WPP-08 — notifica cliente por WhatsApp em mudanças de status relevantes
|
||||
const STATUS_MSG = {
|
||||
separacao: (p) => `📦 *Pedido #${p.id}${p.protocolo ? ` (${p.protocolo})` : ''} em separação!*\nEstamos preparando seu pedido. Em breve está pronto! 😊`,
|
||||
expedicao: (p) => `🚚 *Pedido #${p.id}${p.protocolo ? ` (${p.protocolo})` : ''} saiu para entrega!*\nEm breve chegará até você. Qualquer dúvida, fale conosco! 🛵`,
|
||||
entregue: (p) => `✅ *Pedido #${p.id} entregue com sucesso!*\nObrigado pela confiança! Ficamos felizes em servir você. ⭐`,
|
||||
cancelado: (p) => `❌ *Pedido #${p.id} cancelado.*\nPrecisa de ajuda? Fale conosco aqui pelo WhatsApp.`,
|
||||
}
|
||||
if (STATUS_MSG[status]) {
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
const pedido = await Pedido.findById(req.params.id)
|
||||
if (!pedido?.cliente_id) return
|
||||
const { query: dbQuery } = require('../database/postgres')
|
||||
const tels = await dbQuery(
|
||||
`SELECT numero FROM cliente_telefones WHERE cliente_id = $1 AND aceita_whatsapp = true ORDER BY principal DESC LIMIT 1`,
|
||||
[pedido.cliente_id]
|
||||
)
|
||||
if (!tels.length) return
|
||||
const numero = tels[0].numero.replace(/\D/g, '')
|
||||
const msg = STATUS_MSG[status](pedido)
|
||||
const apiUrl = process.env.NEWWHATS_API_URL
|
||||
if (!apiUrl) return
|
||||
await fetch(`${apiUrl}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.NEWWHATS_API_KEY || ''}` },
|
||||
body: JSON.stringify({ phone: `55${numero}`, message: msg }),
|
||||
}).catch(() => {})
|
||||
} catch { /* silent — notificação não deve travar o fluxo */ }
|
||||
})
|
||||
}
|
||||
|
||||
// Notifica WA-Inbox (best-effort)
|
||||
if (accountId) setImmediate(() => notifyNwAssignAgent(req.params.id, accountId))
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[pedido.updateStatus]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao atualizar status' })
|
||||
}
|
||||
},
|
||||
|
||||
// EMB-02 — registra embalador; aceita embalador_id (account FK) ou texto legado
|
||||
async marcarEmbalado(req, res) {
|
||||
try {
|
||||
const { embalador, embalador_id } = req.body
|
||||
if (!embalador && !embalador_id) return res.status(400).json({ error: 'embalador ou embalador_id é obrigatório' })
|
||||
// accountId: preferência para embalador_id explícito, depois token do colaborador logado
|
||||
const accountId = embalador_id
|
||||
? parseInt(embalador_id)
|
||||
: (req.admin?.id || req.teamAccount?.accountId || null)
|
||||
await Pedido.marcarEmbalado(req.params.id, embalador || null, accountId)
|
||||
if (accountId) setImmediate(() => notifyNwAssignAgent(req.params.id, accountId))
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao registrar embalagem' })
|
||||
}
|
||||
},
|
||||
|
||||
// Lista colaboradores disponíveis para uma função operacional. A regra é
|
||||
// direta: o colaborador aparece se está vinculado (team_members) a um setor
|
||||
// cujo nome bate com a função. Cada função aceita variações de nome para
|
||||
// tolerar grafias comuns ("Separação"/"Separacao"/"Separar"). Setor é a fonte
|
||||
// da verdade — gerenciado em /admin-v2/equipe; nada para configurar em Settings.
|
||||
async colaboradoresDisponiveis(req, res) {
|
||||
try {
|
||||
const SETOR_POR_FUNCAO = {
|
||||
separador: ['separação','separacao','separar','separador'],
|
||||
embalador: ['embalagem','embalador','embalar'],
|
||||
auditor: ['auditoria','auditor','auditar'],
|
||||
fiscal: ['fiscal','fiscalização','fiscalizacao'],
|
||||
expedidor: ['expedição','expedicao','entrega','expedidor','motoboy','entregador'],
|
||||
}
|
||||
const funcao = String(req.query.funcao || '').toLowerCase()
|
||||
const aliases = SETOR_POR_FUNCAO[funcao]
|
||||
if (!aliases) {
|
||||
return res.status(400).json({ error: 'Função inválida', funcoes_validas: Object.keys(SETOR_POR_FUNCAO) })
|
||||
}
|
||||
const tenantId = req.tenant.id
|
||||
|
||||
// Coluna de FK no pedido para contar pedidos ativos por colaborador
|
||||
const FK = {
|
||||
separador: 'separador_id', embalador: 'embalador_id',
|
||||
auditor: 'auditor_id', fiscal: 'fiscal_id',
|
||||
expedidor: 'expedidor_id',
|
||||
}[funcao]
|
||||
|
||||
// Match case-insensitive contra os aliases do setor.
|
||||
// DISTINCT ON evita duplicar colaborador que esteja em mais de um setor compatível.
|
||||
const colaboradores = await dbQuery(`
|
||||
SELECT DISTINCT ON (a.id)
|
||||
a.id, a.name, a.email, a.role, a.availability, a.avatar_url, a.phone_connected,
|
||||
COALESCE(BOOL_OR(tm.on_duty) OVER (PARTITION BY a.id), FALSE) AS on_duty,
|
||||
s.name AS sector_match,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM pedidos p
|
||||
WHERE p.tenant_id = $1
|
||||
AND p.${FK} = a.id
|
||||
AND p.status NOT IN ('entregue','cancelado')) AS pedidos_ativos,
|
||||
(SELECT MAX(created_at)
|
||||
FROM pedidos p
|
||||
WHERE p.tenant_id = $1
|
||||
AND p.${FK} = a.id) AS ultimo_pedido_em
|
||||
FROM accounts a
|
||||
JOIN team_members tm ON tm.account_id = a.id
|
||||
JOIN sectors s ON s.id = tm.sector_id
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.active = TRUE
|
||||
AND s.active = TRUE
|
||||
AND LOWER(s.name) = ANY($2::text[])
|
||||
ORDER BY a.id, a.name
|
||||
`, [tenantId, aliases])
|
||||
|
||||
// Reordena no Node (simples e legível) — disponível primeiro, depois ocupado, offline por último.
|
||||
colaboradores.sort((x, y) => {
|
||||
const r = (a) => a.availability === 'online' ? 0 : a.availability === 'busy' ? 1 : 2
|
||||
const d = r(x) - r(y)
|
||||
return d !== 0 ? d : x.name.localeCompare(y.name)
|
||||
})
|
||||
|
||||
res.json({
|
||||
configurado: true,
|
||||
setores_aceitos: aliases,
|
||||
colaboradores,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[colaboradoresDisponiveis]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao listar colaboradores' })
|
||||
}
|
||||
},
|
||||
|
||||
// Endpoint genérico para registrar separador/auditor/fiscal num pedido.
|
||||
// Embalagem (mudança de status) usa marcarEmbalado; expedição usa marcarEntregue.
|
||||
async setResponsavelEtapa(req, res) {
|
||||
try {
|
||||
const FUNCOES_VALIDAS = ['separador','auditor','fiscal']
|
||||
const funcao = String(req.params.funcao || '').toLowerCase()
|
||||
if (!FUNCOES_VALIDAS.includes(funcao)) {
|
||||
return res.status(400).json({ error: 'Função inválida' })
|
||||
}
|
||||
const accountId = req.body.account_id ? parseInt(req.body.account_id) : null
|
||||
if (!accountId) return res.status(400).json({ error: 'account_id é obrigatório' })
|
||||
|
||||
// Valida que o colaborador pertence ao tenant antes de gravar
|
||||
const acc = await dbQueryOne(
|
||||
`SELECT id FROM accounts WHERE id=$1 AND tenant_id=$2 AND active=TRUE`,
|
||||
[accountId, req.tenant.id]
|
||||
)
|
||||
if (!acc) return res.status(404).json({ error: 'Colaborador não encontrado' })
|
||||
|
||||
await Pedido.setResponsavel(req.params.id, funcao, accountId)
|
||||
setImmediate(() => notifyNwAssignAgent(req.params.id, accountId))
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[setResponsavelEtapa]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao atribuir responsável' })
|
||||
}
|
||||
},
|
||||
|
||||
// Registra cupom fiscal emitido externamente (PDV/ECF).
|
||||
// Aceita em qualquer fase pós-pagamento — validação leve para não bloquear
|
||||
// workflow real do operador. Pode ser sobrescrito (re-emissão por erro).
|
||||
async registrarCupomFiscal(req, res) {
|
||||
try {
|
||||
const id = req.params.id
|
||||
const { numero, chave } = req.body || {}
|
||||
|
||||
const num = String(numero || '').trim()
|
||||
const chave44 = String(chave || '').replace(/\D/g, '')
|
||||
|
||||
if (!num && !chave44) {
|
||||
return res.status(400).json({ error: 'Informe o número do cupom ou a chave de acesso (44 dígitos).' })
|
||||
}
|
||||
if (chave44 && chave44.length !== 44) {
|
||||
return res.status(400).json({ error: 'Chave de acesso NFC-e deve ter 44 dígitos.' })
|
||||
}
|
||||
|
||||
const pedido = await Pedido.findById(id)
|
||||
if (!pedido) return res.status(404).json({ error: 'Pedido não encontrado' })
|
||||
if (pedido.tenant_id !== req.tenant.id) return res.status(403).json({ error: 'Acesso negado' })
|
||||
if (['cancelado'].includes(pedido.status)) {
|
||||
return res.status(409).json({ error: 'Pedido cancelado — não é possível emitir cupom.' })
|
||||
}
|
||||
|
||||
const accountId = req.admin?.id || req.teamAccount?.accountId || null
|
||||
await Pedido.registrarCupom(id, { numero: num || null, chave: chave44 || null, accountId })
|
||||
|
||||
const atualizado = await Pedido.findById(id)
|
||||
res.json({
|
||||
ok: true,
|
||||
cupom_numero: atualizado.cupom_numero,
|
||||
cupom_chave: atualizado.cupom_chave,
|
||||
cupom_emitido_em: atualizado.cupom_emitido_em,
|
||||
cupom_emitido_por: atualizado.cupom_emitido_por,
|
||||
cupom_emitido_por_nome: atualizado.cupom_emitido_por_nome,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[registrarCupomFiscal]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao registrar cupom fiscal' })
|
||||
}
|
||||
},
|
||||
|
||||
async atribuirEquipe(req, res) {
|
||||
try {
|
||||
await Pedido.atribuirEquipe(req.params.id, req.body.equipe_id)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atribuir equipe' })
|
||||
}
|
||||
},
|
||||
|
||||
async marcarItemSeparado(req, res) {
|
||||
try {
|
||||
await Pedido.marcarItemSeparado(req.params.itemId, req.body.separado !== false)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao marcar item' })
|
||||
}
|
||||
},
|
||||
|
||||
async setAlertaItem(req, res) {
|
||||
try {
|
||||
const { tipo, obs, foto } = req.body
|
||||
const TIPOS = ['falta', 'marca_diferente', 'substituicao']
|
||||
if (!TIPOS.includes(tipo)) return res.status(400).json({ error: 'tipo inválido: falta | marca_diferente | substituicao' })
|
||||
await Pedido.setAlertaItem(req.params.itemId, { tipo, obs, foto })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao salvar alerta' })
|
||||
}
|
||||
},
|
||||
|
||||
async limparAlertaItem(req, res) {
|
||||
try {
|
||||
await Pedido.limparAlertaItem(req.params.itemId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao limpar alerta' })
|
||||
}
|
||||
},
|
||||
|
||||
async confirmarAlertaItem(req, res) {
|
||||
try {
|
||||
await Pedido.confirmarAlertaItem(req.params.itemId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao confirmar alerta' })
|
||||
}
|
||||
},
|
||||
|
||||
// DASH-04 — notificações internas de novos pedidos
|
||||
async notificacoes(req, res) {
|
||||
try {
|
||||
const minutes = Math.min(Number(req.query.minutes) || 1440, 10080) // max 7 dias
|
||||
const rows = await Pedido.notificacoes(req.tenant.id, minutes)
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar notificações' })
|
||||
}
|
||||
},
|
||||
|
||||
async contadores(req, res) {
|
||||
try {
|
||||
const dados = await Pedido.contadores(req.tenant.id)
|
||||
// Promoções ativas (PROMO-09)
|
||||
const promos = await Promotion.findAllActive(req.tenant.id)
|
||||
res.json({ ...dados, promocoes_ativas: promos.length })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar contadores' })
|
||||
}
|
||||
},
|
||||
|
||||
// PROMO-10 — Flash promo ativa para o checkout da cotação
|
||||
async getFlashPromo(req, res) {
|
||||
try {
|
||||
const promo = await CotacaoFlashPromo.getActive(req.tenant?.id ?? 1)
|
||||
res.json(promo ?? null)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar flash promo' })
|
||||
}
|
||||
},
|
||||
|
||||
async createFlashPromo(req, res) {
|
||||
try {
|
||||
const { produto_id, discount_pct, label, duration_minutes } = req.body
|
||||
if (!discount_pct || !duration_minutes) {
|
||||
return res.status(400).json({ error: 'discount_pct e duration_minutes são obrigatórios' })
|
||||
}
|
||||
const result = await CotacaoFlashPromo.create({
|
||||
tenantId: req.tenant?.id ?? 1,
|
||||
produtoId: produto_id || null,
|
||||
discount_pct,
|
||||
label,
|
||||
duration_minutes,
|
||||
})
|
||||
res.status(201).json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao criar flash promo' })
|
||||
}
|
||||
},
|
||||
|
||||
async cancelFlashPromo(req, res) {
|
||||
try {
|
||||
await CotacaoFlashPromo.cancel(req.params.id)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao cancelar flash promo' })
|
||||
}
|
||||
},
|
||||
|
||||
// FUT-04/05 — Ranking de equipes e relatório de performance
|
||||
async rankingEquipes(req, res) {
|
||||
try {
|
||||
const { query } = require('../database/postgres')
|
||||
const { from, to } = req.query
|
||||
const params = [req.tenant.id]
|
||||
let where = 'WHERE p.tenant_id = $1'
|
||||
if (from) { params.push(from); where += ` AND DATE(p.created_at) >= $${params.length}` }
|
||||
if (to) { params.push(to); where += ` AND DATE(p.created_at) <= $${params.length}` }
|
||||
|
||||
const ranking = await query(
|
||||
`SELECT
|
||||
p.equipe_id,
|
||||
COUNT(*) FILTER (WHERE p.status = 'entregue') AS pedidos_entregues,
|
||||
COUNT(*) FILTER (WHERE p.status NOT IN ('cancelado','aguardando')) AS pedidos_processados,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (p.entregue_em - p.created_at))/3600)::NUMERIC, 1) AS media_horas_entrega,
|
||||
COUNT(*) FILTER (WHERE p.embalador IS NOT NULL) AS pedidos_embalados,
|
||||
MIN(p.created_at)::DATE AS primeiro_pedido,
|
||||
MAX(p.created_at)::DATE AS ultimo_pedido
|
||||
FROM pedidos p
|
||||
${where}
|
||||
AND p.equipe_id IS NOT NULL
|
||||
GROUP BY p.equipe_id
|
||||
ORDER BY pedidos_entregues DESC`,
|
||||
params
|
||||
)
|
||||
res.json(ranking)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar ranking' })
|
||||
}
|
||||
},
|
||||
|
||||
async relatorioSetor(req, res) {
|
||||
try {
|
||||
const { query } = require('../database/postgres')
|
||||
const { from, to } = req.query
|
||||
const params = [req.tenant.id]
|
||||
let where = 'WHERE tenant_id = $1'
|
||||
if (from) { params.push(from); where += ` AND DATE(created_at) >= $${params.length}` }
|
||||
if (to) { params.push(to); where += ` AND DATE(created_at) <= $${params.length}` }
|
||||
|
||||
const [volumeStatus, volumeDia, top_total] = await Promise.all([
|
||||
query(
|
||||
`SELECT status, COUNT(*) AS total, SUM(total) AS receita
|
||||
FROM pedidos ${where} GROUP BY status ORDER BY total DESC`,
|
||||
params
|
||||
),
|
||||
query(
|
||||
`SELECT DATE(created_at) AS dia, COUNT(*) AS pedidos, SUM(total) AS receita
|
||||
FROM pedidos ${where} GROUP BY DATE(created_at) ORDER BY dia DESC LIMIT 30`,
|
||||
params
|
||||
),
|
||||
query(
|
||||
`SELECT cl.nome AS cliente, COUNT(*) AS pedidos, SUM(p.total) AS gasto
|
||||
FROM pedidos p
|
||||
LEFT JOIN clientes cl ON cl.id = p.cliente_id
|
||||
${where} AND p.status = 'entregue'
|
||||
GROUP BY cl.nome ORDER BY gasto DESC LIMIT 10`,
|
||||
params
|
||||
),
|
||||
])
|
||||
|
||||
res.json({ volume_por_status: volumeStatus, volume_por_dia: volumeDia, top_clientes: top_total })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar relatório' })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = cotacaoController
|
||||
@@ -0,0 +1,232 @@
|
||||
'use strict'
|
||||
|
||||
const DirectMessage = require('../models/DirectMessage')
|
||||
const { query: queryDb } = require('../database/postgres')
|
||||
const { sseSend, isOnline } = require('../services/sse')
|
||||
|
||||
/**
|
||||
* Extrai accountId e tenantId da request, compatível com:
|
||||
* - verifyTeamToken → req.teamAccount = { accountId, tenantId, ... }
|
||||
* - verifyAnyToken → req.admin = { id, tenant_id, ... } OU req.teamAccount
|
||||
*/
|
||||
function extractAuth(req) {
|
||||
const accountId = req.teamAccount?.accountId || req.admin?.id || null
|
||||
const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id || null
|
||||
return { accountId, tenantId }
|
||||
}
|
||||
|
||||
class DirectMessageController {
|
||||
/**
|
||||
* POST /api/admin/inbox/direct-message
|
||||
* Enviar DM para um colaborador
|
||||
*/
|
||||
async send(req, res) {
|
||||
try {
|
||||
const { to_account, content } = req.body
|
||||
const { accountId: fromAccountId, tenantId } = extractAuth(req)
|
||||
|
||||
if (!fromAccountId || !tenantId) {
|
||||
return res.status(401).json({ error: 'Não autenticado' })
|
||||
}
|
||||
if (!to_account || !content?.trim()) {
|
||||
return res.status(400).json({ error: 'to_account e content obrigatórios' })
|
||||
}
|
||||
|
||||
const msg = await DirectMessage.send(tenantId, fromAccountId, to_account, content)
|
||||
res.json(msg)
|
||||
|
||||
// Notificar destinatário via SSE (se estiver conectado)
|
||||
sseSend(to_account, 'dm.new', {
|
||||
id: msg.id,
|
||||
from: fromAccountId,
|
||||
to: to_account,
|
||||
content: content,
|
||||
created_at: msg.created_at,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[dm.send]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao enviar mensagem' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/inbox/direct/:otherAccountId
|
||||
* Histórico de DMs com um colaborador
|
||||
*/
|
||||
async getConversation(req, res) {
|
||||
try {
|
||||
const { otherAccountId } = req.params
|
||||
const { accountId, tenantId } = extractAuth(req)
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
||||
|
||||
if (!accountId || !tenantId) {
|
||||
return res.status(401).json({ error: 'Não autenticado' })
|
||||
}
|
||||
if (!otherAccountId) {
|
||||
return res.status(400).json({ error: 'otherAccountId obrigatório' })
|
||||
}
|
||||
|
||||
const msgs = await DirectMessage.getConversation(tenantId, accountId, Number(otherAccountId), limit)
|
||||
|
||||
// Marcar como lidas ao abrir a conversa
|
||||
await DirectMessage.markAsRead(tenantId, accountId, Number(otherAccountId))
|
||||
|
||||
res.json(msgs)
|
||||
} catch (err) {
|
||||
console.error('[dm.getConversation]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao buscar conversa' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/inbox/direct-messages
|
||||
* Últimas DMs com cada colaborador + contagem de não lidas
|
||||
*/
|
||||
async getLastMessages(req, res) {
|
||||
try {
|
||||
const { accountId, tenantId } = extractAuth(req)
|
||||
|
||||
if (!accountId || !tenantId) {
|
||||
return res.status(401).json({ error: 'Não autenticado' })
|
||||
}
|
||||
|
||||
const [lastMsgs, unreadByContact] = await Promise.all([
|
||||
DirectMessage.getLastMessagesByAccount(tenantId, accountId),
|
||||
DirectMessage.getUnreadCountByContact(tenantId, accountId),
|
||||
])
|
||||
|
||||
// Enriquecer com info do outro usuário + unread count
|
||||
const enriched = await Promise.all(
|
||||
lastMsgs.map(async (msg) => {
|
||||
const otherAccountId = msg.other_account
|
||||
const rows = await queryDb(
|
||||
`SELECT id, name, email, availability FROM accounts WHERE id = $1`,
|
||||
[otherAccountId]
|
||||
)
|
||||
const account = rows[0] || null
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
other_account: otherAccountId,
|
||||
other_account_name: account?.name || 'Unknown',
|
||||
other_account_availability: account?.availability || 'offline',
|
||||
last_message: msg.content,
|
||||
last_message_at: msg.created_at,
|
||||
unread_count: unreadByContact[otherAccountId] || 0,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
console.error('[dm.getLastMessages]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao buscar DMs' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/inbox/colleagues
|
||||
* Listar todos os colaboradores do tenant com status online/offline
|
||||
* Para popular a coluna de contatos
|
||||
*/
|
||||
async getColleagues(req, res) {
|
||||
try {
|
||||
const { accountId, tenantId } = extractAuth(req)
|
||||
|
||||
if (!tenantId) {
|
||||
return res.status(401).json({ error: 'Não autenticado' })
|
||||
}
|
||||
|
||||
// accountId pode ser null para admins sem account — nesse caso lista todos
|
||||
const colleagues = accountId
|
||||
? await queryDb(
|
||||
`SELECT a.id, a.name, a.email, a.availability, a.avatar_url
|
||||
FROM accounts a
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.id != $2
|
||||
AND a.active = true
|
||||
ORDER BY
|
||||
CASE WHEN a.availability = 'online' THEN 0
|
||||
WHEN a.availability = 'busy' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
a.name`,
|
||||
[tenantId, accountId]
|
||||
)
|
||||
: await queryDb(
|
||||
`SELECT a.id, a.name, a.email, a.availability, a.avatar_url
|
||||
FROM accounts a
|
||||
WHERE a.tenant_id = $1
|
||||
AND a.active = true
|
||||
ORDER BY
|
||||
CASE WHEN a.availability = 'online' THEN 0
|
||||
WHEN a.availability = 'busy' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
a.name`,
|
||||
[tenantId]
|
||||
)
|
||||
|
||||
// Adicionar unread count para cada colega (só quando temos accountId)
|
||||
const unreadByContact = accountId
|
||||
? await DirectMessage.getUnreadCountByContact(tenantId, accountId)
|
||||
: {}
|
||||
|
||||
// Presença em tempo real: SSE conectado = online (exceto se DB diz busy)
|
||||
const enriched = colleagues.map(c => {
|
||||
let availability = c.availability
|
||||
if (isOnline(c.id)) {
|
||||
// Se conectado via SSE e não está como busy no DB → online
|
||||
if (availability !== 'busy') availability = 'online'
|
||||
} else {
|
||||
// Sem conexão SSE → offline independente do DB
|
||||
availability = 'offline'
|
||||
}
|
||||
return { ...c, availability, unread_count: unreadByContact[c.id] || 0 }
|
||||
})
|
||||
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
console.error('[dm.getColleagues]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao buscar colaboradores' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/inbox/direct/:messageId/read
|
||||
* Marcar uma DM como lida
|
||||
*/
|
||||
async markAsRead(req, res) {
|
||||
try {
|
||||
const { messageId } = req.params
|
||||
const { accountId } = extractAuth(req)
|
||||
|
||||
if (!accountId) {
|
||||
return res.status(401).json({ error: 'Não autenticado' })
|
||||
}
|
||||
|
||||
// Verificar que a mensagem é para este usuário
|
||||
const rows = await queryDb(
|
||||
`SELECT id FROM direct_messages WHERE id = $1 AND to_account = $2`,
|
||||
[messageId, accountId]
|
||||
)
|
||||
const msg = rows[0] || null
|
||||
|
||||
if (!msg) {
|
||||
return res.status(404).json({ error: 'Mensagem não encontrada' })
|
||||
}
|
||||
|
||||
await queryDb(
|
||||
`UPDATE direct_messages SET read_at = NOW() WHERE id = $1`,
|
||||
[messageId]
|
||||
)
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[dm.markAsRead]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao marcar como lida' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new DirectMessageController()
|
||||
@@ -0,0 +1,528 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* equipeController.js — CRUD para Equipe Virtual (Fase 12)
|
||||
*
|
||||
* Recursos:
|
||||
* accounts — colaboradores internos
|
||||
* sectors — setores operacionais
|
||||
* team_members — vínculo conta ↔ setor
|
||||
* sector_brains — cérebro de conhecimento por setor
|
||||
* inbox_interna — mensagens internas (Human API, escalações, colaboração)
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcryptjs')
|
||||
const { query, queryOne, execute } = require('../database/postgres')
|
||||
|
||||
// ─── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function tid(req) { return req.tenantId || 1 }
|
||||
|
||||
function ok(res, data) { return res.json(data) }
|
||||
function err(res, status, msg) { return res.status(status).json({ error: msg }) }
|
||||
function serverErr(res, e, tag) {
|
||||
console.error(`[equipe] ${tag}:`, e)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
|
||||
// ─── ACCOUNTS ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function listAccounts(req, res) {
|
||||
try {
|
||||
const { role, active } = req.query
|
||||
let sql = `
|
||||
SELECT a.id, a.name, a.email, a.role, a.phone, a.phone_connected,
|
||||
a.availability, a.avatar_url, a.active, a.created_at,
|
||||
a.nw_team_id, a.nw_sector_id,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'sector_id', tm.sector_id,
|
||||
'sector_name', s.name,
|
||||
'function', tm.function,
|
||||
'is_primary', tm.is_primary,
|
||||
'on_duty', tm.on_duty
|
||||
)
|
||||
) 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.tenant_id = $1
|
||||
`
|
||||
const params = [tid(req)]
|
||||
if (role) { params.push(role); sql += ` AND a.role = $${params.length}` }
|
||||
if (active !== undefined) { params.push(active === 'true'); sql += ` AND a.active = $${params.length}` }
|
||||
sql += ` GROUP BY a.id ORDER BY a.name`
|
||||
const rows = await query(sql, params)
|
||||
ok(res, rows)
|
||||
} catch (e) { serverErr(res, e, 'listAccounts') }
|
||||
}
|
||||
|
||||
async function getAccount(req, res) {
|
||||
try {
|
||||
const row = await queryOne(
|
||||
`SELECT id, name, email, role, phone, phone_connected, availability, avatar_url,
|
||||
active, created_at, nw_team_id, nw_sector_id
|
||||
FROM accounts WHERE id = $1 AND tenant_id = $2`,
|
||||
[req.params.id, tid(req)]
|
||||
)
|
||||
if (!row) return err(res, 404, 'Conta não encontrada')
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'getAccount') }
|
||||
}
|
||||
|
||||
async function createAccount(req, res) {
|
||||
try {
|
||||
const { name, email, password, role, phone, avatar_url } = req.body
|
||||
if (!name) return err(res, 400, 'name obrigatório')
|
||||
const validRoles = ['owner','manager','supervisor','secretary','agent','viewer']
|
||||
if (role && !validRoles.includes(role)) return err(res, 400, 'role inválido')
|
||||
|
||||
let hash = null
|
||||
if (password) hash = await bcrypt.hash(password, 10)
|
||||
|
||||
const row = await queryOne(
|
||||
`INSERT INTO accounts (tenant_id, name, email, password_hash, role, phone, avatar_url)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, name, email, role, phone, availability, active`,
|
||||
[tid(req), name, email||null, hash, role||'agent', phone||null, avatar_url||null]
|
||||
)
|
||||
ok(res, row)
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return err(res, 409, 'E-mail já cadastrado neste tenant')
|
||||
serverErr(res, e, 'createAccount')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAccount(req, res) {
|
||||
try {
|
||||
const { name, email, password, role, phone, availability, avatar_url, active,
|
||||
phone_connected, nw_team_id, nw_sector_id } = req.body
|
||||
const id = req.params.id
|
||||
|
||||
const existing = await queryOne('SELECT id FROM accounts WHERE id=$1 AND tenant_id=$2', [id, tid(req)])
|
||||
if (!existing) return err(res, 404, 'Conta não encontrada')
|
||||
|
||||
const sets = []
|
||||
const params = []
|
||||
|
||||
if (name !== undefined) { params.push(name); sets.push(`name=$${params.length}`) }
|
||||
if (email !== undefined) { params.push(email); sets.push(`email=$${params.length}`) }
|
||||
if (role !== undefined) { params.push(role); sets.push(`role=$${params.length}`) }
|
||||
if (phone !== undefined) { params.push(phone); sets.push(`phone=$${params.length}`) }
|
||||
if (availability !== undefined) { params.push(availability); sets.push(`availability=$${params.length}`) }
|
||||
if (avatar_url !== undefined) { params.push(avatar_url); sets.push(`avatar_url=$${params.length}`) }
|
||||
if (active !== undefined) { params.push(active); sets.push(`active=$${params.length}`) }
|
||||
if (phone_connected !== undefined) { params.push(phone_connected); sets.push(`phone_connected=$${params.length}`) }
|
||||
if (nw_team_id !== undefined) { params.push(nw_team_id); sets.push(`nw_team_id=$${params.length}`) }
|
||||
if (nw_sector_id !== undefined) { params.push(nw_sector_id); sets.push(`nw_sector_id=$${params.length}`) }
|
||||
if (password) {
|
||||
const hash = await bcrypt.hash(password, 10)
|
||||
params.push(hash); sets.push(`password_hash=$${params.length}`)
|
||||
}
|
||||
|
||||
if (sets.length === 0) return err(res, 400, 'Nenhum campo para atualizar')
|
||||
|
||||
params.push(id, tid(req))
|
||||
const row = await queryOne(
|
||||
`UPDATE accounts SET ${sets.join(', ')} WHERE id=$${params.length-1} AND tenant_id=$${params.length}
|
||||
RETURNING id, name, email, role, phone, phone_connected, availability, active`,
|
||||
params
|
||||
)
|
||||
ok(res, row)
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return err(res, 409, 'E-mail já cadastrado neste tenant')
|
||||
serverErr(res, e, 'updateAccount')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccount(req, res) {
|
||||
try {
|
||||
const result = await execute(
|
||||
'DELETE FROM accounts WHERE id=$1 AND tenant_id=$2',
|
||||
[req.params.id, tid(req)]
|
||||
)
|
||||
if (result.rowCount === 0) return err(res, 404, 'Conta não encontrada')
|
||||
ok(res, { deleted: true })
|
||||
} catch (e) { serverErr(res, e, 'deleteAccount') }
|
||||
}
|
||||
|
||||
// ─── SECTORS ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listSectors(req, res) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT s.id, s.name, s.description, s.icon, s.color, s.sla_minutes,
|
||||
s.sort_order, s.active, s.created_at,
|
||||
COUNT(tm.id)::int AS member_count
|
||||
FROM sectors s
|
||||
LEFT JOIN team_members tm ON tm.sector_id = s.id
|
||||
WHERE s.tenant_id = $1
|
||||
GROUP BY s.id
|
||||
ORDER BY s.sort_order, s.name`,
|
||||
[tid(req)]
|
||||
)
|
||||
ok(res, rows)
|
||||
} catch (e) { serverErr(res, e, 'listSectors') }
|
||||
}
|
||||
|
||||
async function getSector(req, res) {
|
||||
try {
|
||||
const sector = await queryOne(
|
||||
'SELECT * FROM sectors WHERE id=$1 AND tenant_id=$2',
|
||||
[req.params.id, tid(req)]
|
||||
)
|
||||
if (!sector) return err(res, 404, 'Setor não encontrado')
|
||||
|
||||
const members = await query(
|
||||
`SELECT a.id, a.name, a.role, a.availability, a.phone_connected,
|
||||
tm.function, tm.is_primary, tm.on_duty, tm.notify_escalation, tm.notify_human_api
|
||||
FROM team_members tm
|
||||
JOIN accounts a ON a.id = tm.account_id
|
||||
WHERE tm.sector_id = $1`,
|
||||
[req.params.id]
|
||||
)
|
||||
ok(res, { ...sector, members })
|
||||
} catch (e) { serverErr(res, e, 'getSector') }
|
||||
}
|
||||
|
||||
async function createSector(req, res) {
|
||||
try {
|
||||
const { name, description, icon, color, sla_minutes, sort_order } = req.body
|
||||
if (!name) return err(res, 400, 'name obrigatório')
|
||||
const row = await queryOne(
|
||||
`INSERT INTO sectors (tenant_id, name, description, icon, color, sla_minutes, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[tid(req), name, description||null, icon||'Briefcase', color||'#6366f1',
|
||||
sla_minutes||60, sort_order||0]
|
||||
)
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'createSector') }
|
||||
}
|
||||
|
||||
async function updateSector(req, res) {
|
||||
try {
|
||||
const { name, description, icon, color, sla_minutes, sort_order, active } = req.body
|
||||
const id = req.params.id
|
||||
const existing = await queryOne('SELECT id FROM sectors WHERE id=$1 AND tenant_id=$2', [id, tid(req)])
|
||||
if (!existing) return err(res, 404, 'Setor não encontrado')
|
||||
|
||||
const sets = []; const params = []
|
||||
if (name !== undefined) { params.push(name); sets.push(`name=$${params.length}`) }
|
||||
if (description !== undefined) { params.push(description); sets.push(`description=$${params.length}`) }
|
||||
if (icon !== undefined) { params.push(icon); sets.push(`icon=$${params.length}`) }
|
||||
if (color !== undefined) { params.push(color); sets.push(`color=$${params.length}`) }
|
||||
if (sla_minutes !== undefined) { params.push(sla_minutes); sets.push(`sla_minutes=$${params.length}`) }
|
||||
if (sort_order !== undefined) { params.push(sort_order); sets.push(`sort_order=$${params.length}`) }
|
||||
if (active !== undefined) { params.push(active); sets.push(`active=$${params.length}`) }
|
||||
if (sets.length === 0) return err(res, 400, 'Nenhum campo para atualizar')
|
||||
|
||||
params.push(id, tid(req))
|
||||
const row = await queryOne(
|
||||
`UPDATE sectors SET ${sets.join(', ')} WHERE id=$${params.length-1} AND tenant_id=$${params.length} RETURNING *`,
|
||||
params
|
||||
)
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'updateSector') }
|
||||
}
|
||||
|
||||
async function deleteSector(req, res) {
|
||||
try {
|
||||
const result = await execute('DELETE FROM sectors WHERE id=$1 AND tenant_id=$2', [req.params.id, tid(req)])
|
||||
if (result.rowCount === 0) return err(res, 404, 'Setor não encontrado')
|
||||
ok(res, { deleted: true })
|
||||
} catch (e) { serverErr(res, e, 'deleteSector') }
|
||||
}
|
||||
|
||||
// ─── TEAM MEMBERS ────────────────────────────────────────────────────────────
|
||||
|
||||
async function listTeamMembers(req, res) {
|
||||
try {
|
||||
const { sector_id } = req.query
|
||||
let sql = `
|
||||
SELECT tm.id, tm.account_id, tm.sector_id, tm.function, tm.is_primary,
|
||||
tm.on_duty, tm.notify_escalation, tm.notify_human_api, tm.created_at,
|
||||
a.name AS account_name, a.email AS account_email, a.role AS account_role,
|
||||
a.availability, a.phone_connected,
|
||||
s.name AS sector_name, s.color AS sector_color, s.icon AS sector_icon
|
||||
FROM team_members tm
|
||||
JOIN accounts a ON a.id = tm.account_id
|
||||
JOIN sectors s ON s.id = tm.sector_id
|
||||
WHERE a.tenant_id = $1
|
||||
`
|
||||
const params = [tid(req)]
|
||||
if (sector_id) { params.push(sector_id); sql += ` AND tm.sector_id = $${params.length}` }
|
||||
sql += ' ORDER BY s.name, a.name'
|
||||
const rows = await query(sql, params)
|
||||
ok(res, rows)
|
||||
} catch (e) { serverErr(res, e, 'listTeamMembers') }
|
||||
}
|
||||
|
||||
async function addTeamMember(req, res) {
|
||||
try {
|
||||
const { account_id, sector_id, function: fn, is_primary, notify_escalation, notify_human_api } = req.body
|
||||
if (!account_id || !sector_id) return err(res, 400, 'account_id e sector_id obrigatórios')
|
||||
|
||||
// verifica que a conta pertence ao tenant
|
||||
const acc = await queryOne('SELECT id FROM accounts WHERE id=$1 AND tenant_id=$2', [account_id, tid(req)])
|
||||
if (!acc) return err(res, 404, 'Conta não encontrada')
|
||||
const sec = await queryOne('SELECT id FROM sectors WHERE id=$1 AND tenant_id=$2', [sector_id, tid(req)])
|
||||
if (!sec) return err(res, 404, 'Setor não encontrado')
|
||||
|
||||
const row = await queryOne(
|
||||
`INSERT INTO team_members (account_id, sector_id, function, is_primary, notify_escalation, notify_human_api)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[account_id, sector_id, fn||null,
|
||||
is_primary !== undefined ? is_primary : false,
|
||||
notify_escalation !== undefined ? notify_escalation : true,
|
||||
notify_human_api !== undefined ? notify_human_api : true]
|
||||
)
|
||||
ok(res, row)
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return err(res, 409, 'Membro já vinculado a este setor')
|
||||
serverErr(res, e, 'addTeamMember')
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTeamMember(req, res) {
|
||||
try {
|
||||
const { function: fn, is_primary, on_duty, notify_escalation, notify_human_api } = req.body
|
||||
const id = req.params.id
|
||||
const sets = []; const params = []
|
||||
if (fn !== undefined) { params.push(fn); sets.push(`function=$${params.length}`) }
|
||||
if (is_primary !== undefined) { params.push(is_primary); sets.push(`is_primary=$${params.length}`) }
|
||||
if (on_duty !== undefined) { params.push(on_duty); sets.push(`on_duty=$${params.length}`) }
|
||||
if (notify_escalation !== undefined) { params.push(notify_escalation); sets.push(`notify_escalation=$${params.length}`) }
|
||||
if (notify_human_api !== undefined) { params.push(notify_human_api); sets.push(`notify_human_api=$${params.length}`) }
|
||||
if (sets.length === 0) return err(res, 400, 'Nenhum campo para atualizar')
|
||||
params.push(id)
|
||||
const row = await queryOne(`UPDATE team_members SET ${sets.join(', ')} WHERE id=$${params.length} RETURNING *`, params)
|
||||
if (!row) return err(res, 404, 'Membro não encontrado')
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'updateTeamMember') }
|
||||
}
|
||||
|
||||
async function removeTeamMember(req, res) {
|
||||
try {
|
||||
const result = await execute('DELETE FROM team_members WHERE id=$1', [req.params.id])
|
||||
if (result.rowCount === 0) return err(res, 404, 'Membro não encontrado')
|
||||
ok(res, { deleted: true })
|
||||
} catch (e) { serverErr(res, e, 'removeTeamMember') }
|
||||
}
|
||||
|
||||
// ─── SECTOR BRAIN NODES ─────────────────────────────────────────────────────
|
||||
|
||||
async function listSectorBrains(req, res) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT * FROM sector_brain_nodes WHERE sector_id=$1 ORDER BY sort_order, id`,
|
||||
[req.params.sectorId]
|
||||
)
|
||||
ok(res, rows)
|
||||
} catch (e) { serverErr(res, e, 'listSectorBrains') }
|
||||
}
|
||||
|
||||
async function createSectorBrain(req, res) {
|
||||
try {
|
||||
const { type, content, sort_order } = req.body
|
||||
if (!content) return err(res, 400, 'content obrigatório')
|
||||
const row = await queryOne(
|
||||
`INSERT INTO sector_brain_nodes (sector_id, type, content, sort_order)
|
||||
VALUES ($1,$2,$3,$4) RETURNING *`,
|
||||
[req.params.sectorId, type||'knowledge', content, sort_order||0]
|
||||
)
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'createSectorBrain') }
|
||||
}
|
||||
|
||||
async function updateSectorBrain(req, res) {
|
||||
try {
|
||||
const { type, content, sort_order, active } = req.body
|
||||
const sets = []; const params = []
|
||||
if (type !== undefined) { params.push(type); sets.push(`type=$${params.length}`) }
|
||||
if (content !== undefined) { params.push(content); sets.push(`content=$${params.length}`) }
|
||||
if (sort_order !== undefined) { params.push(sort_order); sets.push(`sort_order=$${params.length}`) }
|
||||
if (active !== undefined) { params.push(active); sets.push(`active=$${params.length}`) }
|
||||
if (sets.length === 0) return err(res, 400, 'Nenhum campo para atualizar')
|
||||
params.push(req.params.id)
|
||||
const row = await queryOne(`UPDATE sector_brain_nodes SET ${sets.join(', ')} WHERE id=$${params.length} RETURNING *`, params)
|
||||
if (!row) return err(res, 404, 'Node não encontrado')
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'updateSectorBrain') }
|
||||
}
|
||||
|
||||
async function deleteSectorBrain(req, res) {
|
||||
try {
|
||||
const result = await execute('DELETE FROM sector_brain_nodes WHERE id=$1', [req.params.id])
|
||||
if (result.rowCount === 0) return err(res, 404, 'Node não encontrado')
|
||||
ok(res, { deleted: true })
|
||||
} catch (e) { serverErr(res, e, 'deleteSectorBrain') }
|
||||
}
|
||||
|
||||
// ─── PERFIL DO COLABORADOR LOGADO ────────────────────────────────────────────
|
||||
|
||||
async function me(req, res) {
|
||||
try {
|
||||
const accountId = req.teamAccount?.accountId || req.admin?.id
|
||||
const tenantId = req.teamAccount?.tenantId || req.tenantId || 1
|
||||
if (!accountId) return err(res, 401, 'Não autenticado')
|
||||
|
||||
const row = await queryOne(`
|
||||
SELECT a.id, a.name, a.email, a.role, a.phone, a.phone_connected,
|
||||
a.availability, a.avatar_url, a.active, a.nw_team_id, a.nw_sector_id,
|
||||
COALESCE(
|
||||
json_agg(json_build_object(
|
||||
'sector_id', s.id, 'sector_name', s.name,
|
||||
'function', tm.function, 'is_primary', tm.is_primary, 'on_duty', tm.on_duty
|
||||
)) 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 AND a.tenant_id = $2
|
||||
GROUP BY a.id
|
||||
`, [accountId, tenantId])
|
||||
|
||||
if (!row) return err(res, 404, 'Conta não encontrada')
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'me') }
|
||||
}
|
||||
|
||||
// ─── INBOX INTERNA ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Lista threads únicas com última mensagem + contador de não lidas */
|
||||
async function listThreads(req, res) {
|
||||
try {
|
||||
// Segurança: colaborador só pode ver sua própria inbox.
|
||||
// Admin pode passar account_id na query para inspecionar qualquer conta.
|
||||
const isAdmin = !!req.admin
|
||||
// queriedId pode ser NaN se não for passado — trata como null
|
||||
const queriedId = req.query.account_id ? parseInt(req.query.account_id) : null
|
||||
const ownId = req.teamAccount?.accountId
|
||||
|
||||
if (!isAdmin && queriedId && queriedId !== ownId) {
|
||||
return err(res, 403, 'Acesso negado: você só pode ver sua própria inbox')
|
||||
}
|
||||
|
||||
// Colaborador sem account_id na query usa o próprio id do token
|
||||
const account_id = isAdmin ? (queriedId || null) : (queriedId || ownId)
|
||||
if (!account_id) return ok(res, [])
|
||||
|
||||
const rows = await query(`
|
||||
SELECT DISTINCT ON (im.thread_id)
|
||||
im.thread_id,
|
||||
im.type,
|
||||
im.sector_id,
|
||||
s.name AS sector_name,
|
||||
im.content AS last_message,
|
||||
im.created_at AS last_at,
|
||||
im.ref_id,
|
||||
im.chat_id,
|
||||
COUNT(im2.id) FILTER (WHERE im2.read_at IS NULL AND im2.to_account = $2)::int AS unread
|
||||
FROM internal_messages im
|
||||
LEFT JOIN sectors s ON s.id = im.sector_id
|
||||
LEFT JOIN internal_messages im2 ON im2.thread_id = im.thread_id
|
||||
WHERE im.tenant_id = $1
|
||||
AND (im.to_account = $2 OR im.sector_id IN (
|
||||
SELECT sector_id FROM team_members WHERE account_id = $2
|
||||
))
|
||||
GROUP BY im.thread_id, im.type, im.sector_id, s.name, im.content, im.created_at, im.ref_id, im.chat_id
|
||||
ORDER BY im.thread_id, im.created_at DESC
|
||||
`, [tid(req), account_id])
|
||||
ok(res, rows)
|
||||
} catch (e) { serverErr(res, e, 'listThreads') }
|
||||
}
|
||||
|
||||
/** Lista mensagens de uma thread */
|
||||
async function getThread(req, res) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT im.*, a.name AS from_name, a.avatar_url AS from_avatar
|
||||
FROM internal_messages im
|
||||
LEFT JOIN accounts a ON a.id = im.from_account
|
||||
WHERE im.thread_id = $1 AND im.tenant_id = $2
|
||||
ORDER BY im.created_at`,
|
||||
[req.params.threadId, tid(req)]
|
||||
)
|
||||
ok(res, rows)
|
||||
} catch (e) { serverErr(res, e, 'getThread') }
|
||||
}
|
||||
|
||||
/** Envia mensagem em uma thread (ou cria nova thread) */
|
||||
async function postMessage(req, res) {
|
||||
try {
|
||||
const { thread_id, from_account, to_account, sector_id, type, content, ref_id } = req.body
|
||||
if (!content) return err(res, 400, 'content obrigatório')
|
||||
|
||||
const threadId = thread_id || `t-${Date.now()}-${Math.random().toString(36).slice(2,8)}`
|
||||
const row = await queryOne(
|
||||
`INSERT INTO internal_messages (tenant_id, thread_id, from_account, to_account, sector_id, type, content, ref_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[tid(req), threadId, from_account||null, to_account||null, sector_id||null,
|
||||
type||'collaboration', content, ref_id||null]
|
||||
)
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'postMessage') }
|
||||
}
|
||||
|
||||
/** Marca mensagens de uma thread como lidas */
|
||||
async function markRead(req, res) {
|
||||
try {
|
||||
// Colaborador só pode marcar suas próprias mensagens como lidas
|
||||
const isAdmin = !!req.admin
|
||||
const ownId = req.teamAccount?.accountId
|
||||
const bodyId = parseInt(req.body.account_id)
|
||||
const account_id = isAdmin ? bodyId : ownId
|
||||
|
||||
if (!isAdmin && bodyId && bodyId !== ownId) {
|
||||
return err(res, 403, 'Acesso negado')
|
||||
}
|
||||
if (!account_id) return ok(res, { ok: true })
|
||||
|
||||
await execute(
|
||||
`UPDATE internal_messages SET read_at = NOW()
|
||||
WHERE thread_id = $1 AND to_account = $2 AND read_at IS NULL`,
|
||||
[req.params.threadId, account_id]
|
||||
)
|
||||
ok(res, { ok: true })
|
||||
} catch (e) { serverErr(res, e, 'markRead') }
|
||||
}
|
||||
|
||||
/** Conta não lidas de uma conta */
|
||||
async function unreadCount(req, res) {
|
||||
try {
|
||||
const isAdmin = !!req.admin
|
||||
const queriedId = parseInt(req.query.account_id)
|
||||
const ownId = req.teamAccount?.accountId
|
||||
|
||||
if (!isAdmin && queriedId && queriedId !== ownId) {
|
||||
return err(res, 403, 'Acesso negado')
|
||||
}
|
||||
const account_id = isAdmin ? queriedId : ownId
|
||||
if (!account_id) return ok(res, { count: 0 })
|
||||
|
||||
const row = await queryOne(
|
||||
`SELECT COUNT(*)::int AS count FROM internal_messages
|
||||
WHERE tenant_id=$1 AND to_account=$2 AND read_at IS NULL`,
|
||||
[tid(req), account_id]
|
||||
)
|
||||
ok(res, row)
|
||||
} catch (e) { serverErr(res, e, 'unreadCount') }
|
||||
}
|
||||
|
||||
// ─── EXPORTS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
// perfil do colaborador logado
|
||||
me,
|
||||
// accounts
|
||||
listAccounts, getAccount, createAccount, updateAccount, deleteAccount,
|
||||
// sectors
|
||||
listSectors, getSector, createSector, updateSector, deleteSector,
|
||||
// team members
|
||||
listTeamMembers, addTeamMember, updateTeamMember, removeTeamMember,
|
||||
// sector brains
|
||||
listSectorBrains, createSectorBrain, updateSectorBrain, deleteSectorBrain,
|
||||
// inbox interna
|
||||
listThreads, getThread, postMessage, markRead, unreadCount,
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use strict'
|
||||
|
||||
const GroupChat = require('../models/GroupChat')
|
||||
const { sseSend } = require('../services/sse')
|
||||
|
||||
function extractAuth(req) {
|
||||
const accountId = req.teamAccount?.accountId || req.admin?.id || null
|
||||
const tenantId = req.tenantId || req.teamAccount?.tenantId || req.admin?.tenant_id || null
|
||||
return { accountId, tenantId }
|
||||
}
|
||||
|
||||
class GroupChatController {
|
||||
/**
|
||||
* GET /api/admin/inbox/groups
|
||||
* Lista grupos do colaborador (via team_members) ou todos (owner/manager)
|
||||
*/
|
||||
async list(req, res) {
|
||||
try {
|
||||
const { accountId, tenantId } = extractAuth(req)
|
||||
if (!tenantId) return res.status(401).json({ error: 'Não autenticado' })
|
||||
|
||||
const role = req.admin?.role || req.teamAccount?.role
|
||||
const isManager = role === 'owner' || role === 'manager'
|
||||
|
||||
const groups = isManager
|
||||
? await GroupChat.listAll(tenantId, accountId)
|
||||
: await GroupChat.listForAccount(tenantId, accountId)
|
||||
|
||||
res.json(groups)
|
||||
} catch (err) {
|
||||
console.error('[groupChat.list]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao listar grupos' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/inbox/groups/:id/messages
|
||||
* Histórico de mensagens de um grupo
|
||||
*/
|
||||
async getMessages(req, res) {
|
||||
try {
|
||||
const { tenantId } = extractAuth(req)
|
||||
if (!tenantId) return res.status(401).json({ error: 'Não autenticado' })
|
||||
|
||||
const groupChatId = Number(req.params.id)
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
||||
const before = req.query.before || null
|
||||
|
||||
const group = await GroupChat.findById(tenantId, groupChatId)
|
||||
if (!group) return res.status(404).json({ error: 'Grupo não encontrado' })
|
||||
|
||||
const msgs = await GroupChat.getMessages(tenantId, groupChatId, limit, before)
|
||||
res.json(msgs)
|
||||
} catch (err) {
|
||||
console.error('[groupChat.getMessages]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao buscar mensagens' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/inbox/groups/:id/message
|
||||
* Enviar mensagem em um grupo
|
||||
*/
|
||||
async sendMessage(req, res) {
|
||||
try {
|
||||
const { accountId, tenantId } = extractAuth(req)
|
||||
if (!accountId || !tenantId) return res.status(401).json({ error: 'Não autenticado' })
|
||||
|
||||
const groupChatId = Number(req.params.id)
|
||||
const { content } = req.body
|
||||
|
||||
if (!content?.trim()) return res.status(400).json({ error: 'content obrigatório' })
|
||||
|
||||
const group = await GroupChat.findById(tenantId, groupChatId)
|
||||
if (!group) return res.status(404).json({ error: 'Grupo não encontrado' })
|
||||
|
||||
const msg = await GroupChat.sendMessage(tenantId, groupChatId, accountId, content.trim())
|
||||
|
||||
res.json(msg)
|
||||
|
||||
// Notificar membros do grupo via SSE (exceto o remetente)
|
||||
const memberIds = await GroupChat.getMemberIds(groupChatId)
|
||||
const payload = {
|
||||
id: msg.id,
|
||||
group_chat_id: groupChatId,
|
||||
group_name: group.name,
|
||||
from: accountId,
|
||||
content: content.trim(),
|
||||
created_at: msg.created_at,
|
||||
}
|
||||
for (const memberId of memberIds) {
|
||||
if (memberId !== accountId) {
|
||||
sseSend(memberId, 'group_message.new', payload)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[groupChat.sendMessage]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao enviar mensagem' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/inbox/groups/:id/members
|
||||
* Listar membros de um grupo
|
||||
*/
|
||||
async getMembers(req, res) {
|
||||
try {
|
||||
const { tenantId } = extractAuth(req)
|
||||
if (!tenantId) return res.status(401).json({ error: 'Não autenticado' })
|
||||
|
||||
const groupChatId = Number(req.params.id)
|
||||
const group = await GroupChat.findById(tenantId, groupChatId)
|
||||
if (!group) return res.status(404).json({ error: 'Grupo não encontrado' })
|
||||
|
||||
const members = await GroupChat.getMembers(tenantId, groupChatId)
|
||||
res.json(members)
|
||||
} catch (err) {
|
||||
console.error('[groupChat.getMembers]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao buscar membros' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/inbox/groups/:id/read
|
||||
* Marcar grupo como lido
|
||||
*/
|
||||
async markRead(req, res) {
|
||||
try {
|
||||
const { accountId } = extractAuth(req)
|
||||
if (!accountId) return res.status(401).json({ error: 'Não autenticado' })
|
||||
await GroupChat.markRead(accountId, Number(req.params.id))
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[groupChat.markRead]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao marcar como lido' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new GroupChatController()
|
||||
@@ -0,0 +1,96 @@
|
||||
'use strict'
|
||||
|
||||
const MensagemInterna = require('../models/MensagemInterna')
|
||||
|
||||
const SETORES_VALIDOS = ['geral','atendimento','cotacao','separacao','embalagem','auditoria','fiscal','expedicao','financeiro','suporte']
|
||||
|
||||
const inboxController = {
|
||||
// INB-01 — Lista mensagens de um setor
|
||||
async listBySetor(req, res) {
|
||||
try {
|
||||
const { setor } = req.params
|
||||
if (!SETORES_VALIDOS.includes(setor)) return res.status(400).json({ error: 'Setor inválido' })
|
||||
const somenteNaoLidas = req.query.nao_lidas === 'true'
|
||||
const rows = await MensagemInterna.listBySetor(req.tenant.id, setor, somenteNaoLidas)
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar inbox' })
|
||||
}
|
||||
},
|
||||
|
||||
// INB-02 — Lista mensagens de um pedido
|
||||
async listByPedido(req, res) {
|
||||
try {
|
||||
const rows = await MensagemInterna.listByPedido(req.params.pedidoId)
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar mensagens do pedido' })
|
||||
}
|
||||
},
|
||||
|
||||
// INB-02 — Envia mensagem interna (setor + pedido opcional)
|
||||
async enviar(req, res) {
|
||||
try {
|
||||
const { pedido_id, setor, mensagem } = req.body
|
||||
if (!setor || !mensagem) return res.status(400).json({ error: 'setor e mensagem são obrigatórios' })
|
||||
if (!SETORES_VALIDOS.includes(setor)) return res.status(400).json({ error: 'Setor inválido' })
|
||||
|
||||
const remetente = req.user?.nome || req.user?.email || 'Sistema'
|
||||
const result = await MensagemInterna.create({
|
||||
tenantId: req.tenant.id,
|
||||
pedidoId: pedido_id || null,
|
||||
setor,
|
||||
remetente,
|
||||
mensagem,
|
||||
})
|
||||
res.status(201).json({ ok: true, id: result.id })
|
||||
} catch (err) {
|
||||
console.error('[inbox.enviar]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao enviar mensagem' })
|
||||
}
|
||||
},
|
||||
|
||||
// INB-03 — Badge: contagem de não lidas por setor
|
||||
async contadores(req, res) {
|
||||
try {
|
||||
const rows = await MensagemInterna.contadorNaoLidas(req.tenant.id)
|
||||
const total = await MensagemInterna.totalNaoLidas(req.tenant.id)
|
||||
res.json({ total: Number(total?.total ?? 0), por_setor: rows })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar contadores' })
|
||||
}
|
||||
},
|
||||
|
||||
// Marcar lida
|
||||
async marcarLida(req, res) {
|
||||
try {
|
||||
await MensagemInterna.marcarLida(req.params.id)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao marcar lida' })
|
||||
}
|
||||
},
|
||||
|
||||
// Marcar todas lidas de um pedido
|
||||
async marcarTodasPedido(req, res) {
|
||||
try {
|
||||
await MensagemInterna.marcarTodasLidasPorPedido(req.params.pedidoId)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao marcar lidas' })
|
||||
}
|
||||
},
|
||||
|
||||
// Marcar todas lidas de um setor
|
||||
async marcarTodasSetor(req, res) {
|
||||
try {
|
||||
const { setor } = req.params
|
||||
await MensagemInterna.marcarTodasLidasPorSetor(req.tenant.id, setor)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao marcar lidas' })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = inboxController
|
||||
@@ -0,0 +1,478 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function tenantId(req) { return req.tenant?.id || 1 }
|
||||
|
||||
const VALID_STATUSES = ['novo', 'em_analise', 'aprovado', 'reprovado', 'contratado']
|
||||
|
||||
// ── Público — Vagas ───────────────────────────────────────────────────────────
|
||||
|
||||
exports.listJobs = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const { category, work_mode, job_type, search, limit = 20, offset = 0 } = req.query
|
||||
|
||||
const conditions = ['j.tenant_id = $1', 'j.active = true']
|
||||
const params = [tid]
|
||||
let pi = 2
|
||||
|
||||
if (category) { conditions.push(`j.category = $${pi++}`); params.push(category) }
|
||||
if (work_mode) { conditions.push(`j.work_mode = $${pi++}`); params.push(work_mode) }
|
||||
if (job_type) { conditions.push(`j.job_type = $${pi++}`); params.push(job_type) }
|
||||
if (search) { conditions.push(`j.title ILIKE $${pi++}`); params.push(`%${search}%`) }
|
||||
conditions.push(`(j.expires_at IS NULL OR j.expires_at > NOW())`)
|
||||
|
||||
const where = conditions.join(' AND ')
|
||||
|
||||
const countRow = await queryOne(
|
||||
`SELECT COUNT(*) AS total FROM jobs j WHERE ${where}`,
|
||||
params
|
||||
)
|
||||
|
||||
const jobs = await query(
|
||||
`SELECT j.*,
|
||||
COUNT(a.id) AS applicants_count
|
||||
FROM jobs j
|
||||
LEFT JOIN job_applications a ON a.job_id = j.id
|
||||
WHERE ${where}
|
||||
GROUP BY j.id
|
||||
ORDER BY j.published_at DESC
|
||||
LIMIT $${pi} OFFSET $${pi + 1}`,
|
||||
[...params, +limit, +offset]
|
||||
)
|
||||
|
||||
res.json({ jobs: jobs || [], total: parseInt(countRow?.total || '0') })
|
||||
} catch (err) {
|
||||
console.error('[jobController] listJobs:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.getJob = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const job = await queryOne(
|
||||
`SELECT j.*, COUNT(a.id) AS applicants_count
|
||||
FROM jobs j
|
||||
LEFT JOIN job_applications a ON a.job_id = j.id
|
||||
WHERE j.id = $1 AND j.tenant_id = $2 AND j.active = true
|
||||
GROUP BY j.id`,
|
||||
[req.params.id, tid]
|
||||
)
|
||||
if (!job) return res.status(404).json({ error: 'Vaga não encontrada' })
|
||||
res.json(job)
|
||||
} catch (err) {
|
||||
console.error('[jobController] getJob:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.applyToJob = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const { id: job_id } = req.params
|
||||
const { applicant_cpf, applicant_nome, applicant_phone, applicant_email, cover_letter, resume_id } = req.body
|
||||
|
||||
if (!applicant_cpf || !applicant_nome)
|
||||
return res.status(400).json({ error: 'CPF e nome são obrigatórios' })
|
||||
|
||||
const job = await queryOne(
|
||||
`SELECT id FROM jobs WHERE id=$1 AND tenant_id=$2 AND active=true`,
|
||||
[job_id, tid]
|
||||
)
|
||||
if (!job) return res.status(404).json({ error: 'Vaga não encontrada' })
|
||||
|
||||
// Verifica duplicata
|
||||
const existing = await queryOne(
|
||||
`SELECT id FROM job_applications WHERE job_id=$1 AND applicant_cpf=$2`,
|
||||
[job_id, applicant_cpf.replace(/\D/g, '')]
|
||||
)
|
||||
if (existing) return res.status(409).json({ error: 'Você já se candidatou a esta vaga' })
|
||||
|
||||
const result = await execute(
|
||||
`INSERT INTO job_applications
|
||||
(tenant_id, job_id, resume_id, applicant_cpf, applicant_nome, applicant_phone, applicant_email, cover_letter)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
||||
[tid, job_id, resume_id || null,
|
||||
applicant_cpf.replace(/\D/g, ''), applicant_nome,
|
||||
applicant_phone || null, applicant_email || null, cover_letter || null]
|
||||
)
|
||||
res.status(201).json({ ok: true, id: result.rows?.[0]?.id })
|
||||
} catch (err) {
|
||||
console.error('[jobController] applyToJob:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Usuário — Currículo ───────────────────────────────────────────────────────
|
||||
|
||||
exports.getMyResume = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const cpf = req.user?.cpf?.replace(/\D/g, '')
|
||||
if (!cpf) return res.status(401).json({ error: 'Não autenticado' })
|
||||
const resume = await queryOne(
|
||||
`SELECT * FROM resumes WHERE tenant_id=$1 AND user_cpf=$2`,
|
||||
[tid, cpf]
|
||||
)
|
||||
res.json(resume || null)
|
||||
} catch (err) {
|
||||
console.error('[jobController] getMyResume:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.saveMyResume = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const cpf = req.user?.cpf?.replace(/\D/g, '')
|
||||
if (!cpf) return res.status(401).json({ error: 'Não autenticado' })
|
||||
|
||||
const { user_nome, email, phone, linkedin, portfolio, about, experience, education, skills } = req.body
|
||||
if (!user_nome) return res.status(400).json({ error: 'Nome é obrigatório' })
|
||||
|
||||
const existing = await queryOne(
|
||||
`SELECT id FROM resumes WHERE tenant_id=$1 AND user_cpf=$2`, [tid, cpf]
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
await execute(
|
||||
`UPDATE resumes SET user_nome=$1, email=$2, phone=$3, linkedin=$4, portfolio=$5,
|
||||
about=$6, experience=$7, education=$8, skills=$9, updated_at=NOW()
|
||||
WHERE tenant_id=$10 AND user_cpf=$11`,
|
||||
[user_nome, email||null, phone||null, linkedin||null, portfolio||null,
|
||||
about||null, JSON.stringify(experience||[]), JSON.stringify(education||[]),
|
||||
JSON.stringify(skills||[]), tid, cpf]
|
||||
)
|
||||
res.json({ ok: true, id: existing.id })
|
||||
} else {
|
||||
const result = await execute(
|
||||
`INSERT INTO resumes (tenant_id, user_cpf, user_nome, email, phone, linkedin, portfolio, about, experience, education, skills)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
|
||||
[tid, cpf, user_nome, email||null, phone||null, linkedin||null, portfolio||null,
|
||||
about||null, JSON.stringify(experience||[]), JSON.stringify(education||[]),
|
||||
JSON.stringify(skills||[])]
|
||||
)
|
||||
res.status(201).json({ ok: true, id: result.rows?.[0]?.id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[jobController] saveMyResume:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.getMyApplications = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const cpf = req.user?.cpf?.replace(/\D/g, '')
|
||||
if (!cpf) return res.status(401).json({ error: 'Não autenticado' })
|
||||
|
||||
const apps = await query(
|
||||
`SELECT a.id, a.job_id, a.status, a.applied_at, a.cover_letter,
|
||||
j.title, j.work_mode, j.job_type, j.location
|
||||
FROM job_applications a
|
||||
JOIN jobs j ON j.id = a.job_id
|
||||
WHERE a.applicant_cpf = $1 AND a.tenant_id = $2
|
||||
ORDER BY a.applied_at DESC`,
|
||||
[cpf, tid]
|
||||
)
|
||||
res.json(apps || [])
|
||||
} catch (err) {
|
||||
console.error('[jobController] getMyApplications:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.cancelMyApplication = async (req, res) => {
|
||||
try {
|
||||
const cpf = req.user?.cpf?.replace(/\D/g, '')
|
||||
const tid = tenantId(req)
|
||||
const app = await queryOne(
|
||||
`SELECT id, applicant_cpf, status FROM job_applications WHERE id=$1 AND tenant_id=$2`,
|
||||
[req.params.id, tid]
|
||||
)
|
||||
if (!app) return res.status(404).json({ error: 'Candidatura não encontrada' })
|
||||
if (app.applicant_cpf !== cpf) return res.status(403).json({ error: 'Acesso negado' })
|
||||
if (app.status !== 'novo') return res.status(409).json({ error: 'Candidatura já em análise, não pode ser cancelada' })
|
||||
|
||||
await execute(`DELETE FROM job_applications WHERE id=$1`, [req.params.id])
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[jobController] cancelMyApplication:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin — Vagas ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.adminListJobs = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const { active, category, work_mode, search, limit = 50, offset = 0 } = req.query
|
||||
|
||||
const conditions = ['j.tenant_id = $1']
|
||||
const params = [tid]
|
||||
let pi = 2
|
||||
|
||||
if (active !== undefined && active !== '') {
|
||||
conditions.push(`j.active = $${pi++}`)
|
||||
params.push(active === 'true')
|
||||
}
|
||||
if (category) { conditions.push(`j.category = $${pi++}`); params.push(category) }
|
||||
if (work_mode) { conditions.push(`j.work_mode = $${pi++}`); params.push(work_mode) }
|
||||
if (search) { conditions.push(`j.title ILIKE $${pi++}`); params.push(`%${search}%`) }
|
||||
|
||||
const where = conditions.join(' AND ')
|
||||
const countRow = await queryOne(`SELECT COUNT(*) AS total FROM jobs j WHERE ${where}`, params)
|
||||
|
||||
const jobs = await query(
|
||||
`SELECT j.*, COUNT(a.id) AS applicants_count
|
||||
FROM jobs j
|
||||
LEFT JOIN job_applications a ON a.job_id = j.id
|
||||
WHERE ${where}
|
||||
GROUP BY j.id
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT $${pi} OFFSET $${pi + 1}`,
|
||||
[...params, +limit, +offset]
|
||||
)
|
||||
|
||||
res.json({ jobs: jobs || [], total: parseInt(countRow?.total || '0') })
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminListJobs:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminCreateJob = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const {
|
||||
title, description, requirements, benefits, location,
|
||||
work_mode = 'presencial', job_type = 'clt', category,
|
||||
salary_min, salary_max, salary_hide = false, slots = 1,
|
||||
active = true, expires_at
|
||||
} = req.body
|
||||
|
||||
if (!title || !description) return res.status(400).json({ error: 'Título e descrição são obrigatórios' })
|
||||
|
||||
const result = await execute(
|
||||
`INSERT INTO jobs
|
||||
(tenant_id, title, description, requirements, benefits, location,
|
||||
work_mode, job_type, category, salary_min, salary_max, salary_hide,
|
||||
slots, active, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id`,
|
||||
[tid, title, description, requirements||null, benefits||null, location||null,
|
||||
work_mode, job_type, category||null,
|
||||
salary_min||null, salary_max||null, salary_hide, slots, active,
|
||||
expires_at||null]
|
||||
)
|
||||
res.status(201).json({ ok: true, id: result.rows?.[0]?.id })
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminCreateJob:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminUpdateJob = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const { id } = req.params
|
||||
const {
|
||||
title, description, requirements, benefits, location,
|
||||
work_mode, job_type, category, salary_min, salary_max,
|
||||
salary_hide, slots, active, expires_at
|
||||
} = req.body
|
||||
const result = await execute(
|
||||
`UPDATE jobs SET
|
||||
title=$1, description=$2, requirements=$3, benefits=$4, location=$5,
|
||||
work_mode=$6, job_type=$7, category=$8, salary_min=$9, salary_max=$10,
|
||||
salary_hide=$11, slots=$12, active=$13, expires_at=$14, updated_at=NOW()
|
||||
WHERE id=$15 AND tenant_id=$16`,
|
||||
[title, description, requirements||null, benefits||null, location||null,
|
||||
work_mode, job_type, category||null, salary_min||null, salary_max||null,
|
||||
salary_hide||false, slots||1, active??true, expires_at||null, id, tid]
|
||||
)
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Vaga não encontrada' })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminUpdateJob:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminToggleJob = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const job = await queryOne(`SELECT id, active FROM jobs WHERE id=$1 AND tenant_id=$2`, [req.params.id, tid])
|
||||
if (!job) return res.status(404).json({ error: 'Vaga não encontrada' })
|
||||
await execute(`UPDATE jobs SET active=$1, updated_at=NOW() WHERE id=$2`, [!job.active, job.id])
|
||||
res.json({ ok: true, active: !job.active })
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminToggleJob:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminDeleteJob = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const job = await queryOne(`SELECT id FROM jobs WHERE id=$1 AND tenant_id=$2`, [req.params.id, tid])
|
||||
if (!job) return res.status(404).json({ error: 'Vaga não encontrada' })
|
||||
const hasApps = await queryOne(
|
||||
`SELECT id FROM job_applications WHERE job_id=$1 LIMIT 1`, [req.params.id]
|
||||
)
|
||||
if (hasApps) {
|
||||
await execute(`UPDATE jobs SET active=false, updated_at=NOW() WHERE id=$1`, [req.params.id])
|
||||
return res.json({ ok: true, deactivated: true, message: 'Vaga desativada pois possui candidaturas' })
|
||||
}
|
||||
await execute(`DELETE FROM jobs WHERE id=$1`, [req.params.id])
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminDeleteJob:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin — Candidaturas ──────────────────────────────────────────────────────
|
||||
|
||||
exports.adminGetApplications = async (req, res) => {
|
||||
try {
|
||||
const { id: job_id } = req.params
|
||||
const { status, limit = 100, offset = 0 } = req.query
|
||||
const conditions = ['a.job_id = $1']
|
||||
const params = [job_id]
|
||||
let pi = 2
|
||||
if (status) { conditions.push(`a.status = $${pi++}`); params.push(status) }
|
||||
const where = conditions.join(' AND ')
|
||||
|
||||
const apps = await query(
|
||||
`SELECT a.*,
|
||||
r.about, r.experience, r.education, r.skills, r.file_url,
|
||||
r.linkedin, r.portfolio
|
||||
FROM job_applications a
|
||||
LEFT JOIN resumes r ON r.user_cpf = a.applicant_cpf AND r.tenant_id = a.tenant_id
|
||||
WHERE ${where}
|
||||
ORDER BY a.applied_at DESC
|
||||
LIMIT $${pi} OFFSET $${pi+1}`,
|
||||
[...params, +limit, +offset]
|
||||
)
|
||||
res.json(apps || [])
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminGetApplications:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminGetApplication = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const app = await queryOne(
|
||||
`SELECT a.*,
|
||||
r.about, r.experience, r.education, r.skills, r.file_url,
|
||||
r.linkedin, r.portfolio, r.email AS resume_email, r.phone AS resume_phone,
|
||||
j.title AS job_title
|
||||
FROM job_applications a
|
||||
LEFT JOIN resumes r ON r.user_cpf = a.applicant_cpf AND r.tenant_id = a.tenant_id
|
||||
LEFT JOIN jobs j ON j.id = a.job_id
|
||||
WHERE a.id = $1 AND a.tenant_id = $2`,
|
||||
[req.params.id, tid]
|
||||
)
|
||||
if (!app) return res.status(404).json({ error: 'Candidatura não encontrada' })
|
||||
res.json(app)
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminGetApplication:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminUpdateApplication = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const { status, admin_notes } = req.body
|
||||
if (status && !VALID_STATUSES.includes(status))
|
||||
return res.status(400).json({ error: 'Status inválido' })
|
||||
const result = await execute(
|
||||
`UPDATE job_applications SET status=COALESCE($1, status), admin_notes=$2, updated_at=NOW() WHERE id=$3 AND tenant_id=$4`,
|
||||
[status || null, admin_notes ?? null, req.params.id, tid]
|
||||
)
|
||||
if (result.rowCount === 0) return res.status(404).json({ error: 'Candidatura não encontrada' })
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminUpdateApplication:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin — Currículos ────────────────────────────────────────────────────────
|
||||
|
||||
exports.adminSearchResumes = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const { search, limit = 50, offset = 0 } = req.query
|
||||
const conditions = ['r.tenant_id = $1']
|
||||
const params = [tid]
|
||||
let pi = 2
|
||||
if (search) {
|
||||
conditions.push(`(r.user_nome ILIKE $${pi} OR r.user_cpf ILIKE $${pi})`)
|
||||
params.push(`%${search}%`)
|
||||
pi++
|
||||
}
|
||||
const where = conditions.join(' AND ')
|
||||
const resumes = await query(
|
||||
`SELECT r.id, r.user_cpf, r.user_nome, r.email, r.phone, r.updated_at,
|
||||
COUNT(a.id) AS applications_count
|
||||
FROM resumes r
|
||||
LEFT JOIN job_applications a ON a.applicant_cpf = r.user_cpf AND a.tenant_id = r.tenant_id
|
||||
WHERE ${where}
|
||||
GROUP BY r.id
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT $${pi} OFFSET $${pi+1}`,
|
||||
[...params, +limit, +offset]
|
||||
)
|
||||
res.json(resumes || [])
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminSearchResumes:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.adminGetResume = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const cpf = req.params.cpf.replace(/\D/g, '')
|
||||
const resume = await queryOne(
|
||||
`SELECT * FROM resumes WHERE tenant_id=$1 AND user_cpf=$2`, [tid, cpf]
|
||||
)
|
||||
if (!resume) return res.status(404).json({ error: 'Currículo não encontrado' })
|
||||
res.json(resume)
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminGetResume:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin — KPIs ──────────────────────────────────────────────────────────────
|
||||
|
||||
exports.adminJobsKpi = async (req, res) => {
|
||||
try {
|
||||
const tid = tenantId(req)
|
||||
const [active, inactive, total_apps, hired] = await Promise.all([
|
||||
queryOne(`SELECT COUNT(*) AS c FROM jobs WHERE tenant_id=$1 AND active=true`, [tid]),
|
||||
queryOne(`SELECT COUNT(*) AS c FROM jobs WHERE tenant_id=$1 AND active=false`, [tid]),
|
||||
queryOne(`SELECT COUNT(*) AS c FROM job_applications a JOIN jobs j ON j.id=a.job_id WHERE j.tenant_id=$1`, [tid]),
|
||||
queryOne(`SELECT COUNT(*) AS c FROM job_applications a JOIN jobs j ON j.id=a.job_id WHERE j.tenant_id=$1 AND a.status='contratado'`, [tid]),
|
||||
])
|
||||
res.json({
|
||||
active: parseInt(active?.c || '0'),
|
||||
inactive: parseInt(inactive?.c || '0'),
|
||||
total_applications: parseInt(total_apps?.c || '0'),
|
||||
hired: parseInt(hired?.c || '0'),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[jobController] adminJobsKpi:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* nwTeamsController.js — Proxy de sectors/teams do WA-Inbox
|
||||
*
|
||||
* GET /api/admin/nw-teams
|
||||
* Retorna a árvore de setores + equipes cadastrados no WA-Inbox,
|
||||
* usando a integration_key do par já configurado em plugin_configs.
|
||||
*
|
||||
* PATCH /api/admin/accounts/:id
|
||||
* Estende o updateAccount para aceitar nw_team_id e nw_sector_id.
|
||||
* (implementado diretamente em equipeController.js)
|
||||
*/
|
||||
|
||||
const { queryOne, execute } = require('../database/postgres')
|
||||
|
||||
// Cache em memória: evita hammering no WA-Inbox a cada request
|
||||
let _cache = null
|
||||
let _cacheAt = 0
|
||||
const CACHE_TTL_MS = 60_000
|
||||
|
||||
async function getNwConfig(tenantId) {
|
||||
const rows = await require('../database/postgres').query(
|
||||
`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`
|
||||
)
|
||||
const cfg = {}
|
||||
for (const r of rows) cfg[r.key] = r.value
|
||||
return cfg
|
||||
}
|
||||
|
||||
async function listNwTeams(req, res) {
|
||||
try {
|
||||
const now = Date.now()
|
||||
if (_cache && now - _cacheAt < CACHE_TTL_MS) {
|
||||
return res.json(_cache)
|
||||
}
|
||||
|
||||
const cfg = await getNwConfig()
|
||||
if (!cfg.newwhats_url || !cfg.integration_key) {
|
||||
return res.json([])
|
||||
}
|
||||
|
||||
const baseUrl = cfg.newwhats_url.replace(/\/$/, '')
|
||||
const headers = { 'x-nw-key': cfg.integration_key, 'Content-Type': 'application/json' }
|
||||
|
||||
// Busca setores
|
||||
const sectorsResp = await fetch(`${baseUrl}/api/sectors`, { headers })
|
||||
if (!sectorsResp.ok) {
|
||||
console.warn('[nwTeams] falha ao buscar sectors:', sectorsResp.status)
|
||||
return res.json([])
|
||||
}
|
||||
const sectors = await sectorsResp.json()
|
||||
|
||||
// Busca teams de cada setor em paralelo
|
||||
const tree = await Promise.all(
|
||||
sectors.map(async (sector) => {
|
||||
try {
|
||||
const teamsResp = await fetch(`${baseUrl}/api/sectors/${sector.id}/teams`, { headers })
|
||||
const teams = teamsResp.ok ? await teamsResp.json() : []
|
||||
return { ...sector, teams }
|
||||
} catch {
|
||||
return { ...sector, teams: [] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
_cache = tree
|
||||
_cacheAt = now
|
||||
res.json(tree)
|
||||
} catch (e) {
|
||||
console.error('[nwTeams] listNwTeams:', e)
|
||||
res.status(500).json({ error: 'Erro ao buscar teams do WA-Inbox' })
|
||||
}
|
||||
}
|
||||
|
||||
// Invalida o cache (chamado quando um sector/team é criado/editado no WA-Inbox)
|
||||
function invalidateCache() {
|
||||
_cache = null
|
||||
_cacheAt = 0
|
||||
}
|
||||
|
||||
module.exports = { listNwTeams, invalidateCache }
|
||||
@@ -0,0 +1,147 @@
|
||||
'use strict'
|
||||
|
||||
const Produto = require('../models/Produto')
|
||||
|
||||
const produtoController = {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant.id
|
||||
const { search, categoria, ativo, limit, offset } = req.query
|
||||
const rows = await Produto.list(tenantId, {
|
||||
search,
|
||||
categoria,
|
||||
ativo: ativo === undefined ? true : ativo === 'true',
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
console.error('[produto.list]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao listar produtos' })
|
||||
}
|
||||
},
|
||||
|
||||
async listAll(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant.id
|
||||
const { search, categoria, limit, offset } = req.query
|
||||
const rows = await Produto.list(tenantId, { search, categoria, limit, offset })
|
||||
res.json(rows)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar produtos' })
|
||||
}
|
||||
},
|
||||
|
||||
async listCategorias(req, res) {
|
||||
try {
|
||||
const rows = await Produto.listCategorias(req.tenant.id)
|
||||
res.json(rows.map(r => r.categoria))
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar categorias' })
|
||||
}
|
||||
},
|
||||
|
||||
async get(req, res) {
|
||||
try {
|
||||
const prod = await Produto.findById(req.params.id)
|
||||
if (!prod) return res.status(404).json({ error: 'Produto não encontrado' })
|
||||
res.json(prod)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar produto' })
|
||||
}
|
||||
},
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant.id
|
||||
const { sku, nome, descricao, unidade, preco, preco_promocional, estoque, categoria } = req.body
|
||||
if (!nome?.trim()) return res.status(400).json({ error: 'nome é obrigatório' })
|
||||
if (preco === undefined || preco === null) return res.status(400).json({ error: 'preco é obrigatório' })
|
||||
|
||||
if (sku) {
|
||||
const existe = await Produto.findBySku(sku, tenantId)
|
||||
if (existe) return res.status(409).json({ error: 'SKU já cadastrado' })
|
||||
}
|
||||
|
||||
const prod = await Produto.create({ tenant_id: tenantId, sku, nome: nome.trim(), descricao, unidade, preco, preco_promocional: preco_promocional || null, estoque, categoria })
|
||||
res.status(201).json(prod)
|
||||
} catch (err) {
|
||||
console.error('[produto.create]', err.message)
|
||||
res.status(500).json({ error: 'Erro ao criar produto' })
|
||||
}
|
||||
},
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const prod = await Produto.findById(req.params.id)
|
||||
if (!prod) return res.status(404).json({ error: 'Produto não encontrado' })
|
||||
await Produto.update(req.params.id, req.body)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar produto' })
|
||||
}
|
||||
},
|
||||
|
||||
async updateEstoque(req, res) {
|
||||
try {
|
||||
const { estoque } = req.body
|
||||
if (estoque === undefined) return res.status(400).json({ error: 'estoque é obrigatório' })
|
||||
await Produto.updateEstoque(req.params.id, estoque)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar estoque' })
|
||||
}
|
||||
},
|
||||
|
||||
async updatePreco(req, res) {
|
||||
try {
|
||||
const { preco } = req.body
|
||||
if (preco === undefined) return res.status(400).json({ error: 'preco é obrigatório' })
|
||||
await Produto.updatePreco(req.params.id, preco)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar preço' })
|
||||
}
|
||||
},
|
||||
|
||||
async remove(req, res) {
|
||||
try {
|
||||
await Produto.delete(req.params.id)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao remover produto' })
|
||||
}
|
||||
},
|
||||
|
||||
// Importação CSV — recebe array de produtos no body
|
||||
async importar(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant.id
|
||||
const { produtos } = req.body
|
||||
if (!Array.isArray(produtos) || produtos.length === 0) {
|
||||
return res.status(400).json({ error: 'produtos deve ser um array não vazio' })
|
||||
}
|
||||
const resultados = { inseridos: 0, atualizados: 0, erros: [] }
|
||||
for (const p of produtos) {
|
||||
if (!p.nome || !p.sku) {
|
||||
resultados.erros.push({ sku: p.sku || '?', erro: 'nome e sku são obrigatórios' })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const res2 = await Produto.upsertBySku({ tenant_id: tenantId, ...p })
|
||||
// ON CONFLICT atualiza → xmax > 0 indica UPDATE
|
||||
if (res2 && res2.xmax && res2.xmax !== '0') resultados.atualizados++
|
||||
else resultados.inseridos++
|
||||
} catch (e) {
|
||||
resultados.erros.push({ sku: p.sku, erro: e.message })
|
||||
}
|
||||
}
|
||||
res.json(resultados)
|
||||
} catch (err) {
|
||||
console.error('[produto.importar]', err.message)
|
||||
res.status(500).json({ error: 'Erro na importação' })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = produtoController
|
||||
@@ -0,0 +1,93 @@
|
||||
'use strict'
|
||||
|
||||
const Promotion = require('../models/Promotion')
|
||||
|
||||
const promotionsController = {
|
||||
async getAll(req, res) {
|
||||
try {
|
||||
const promotions = await Promotion.findAllActive(req.tenant?.id)
|
||||
return res.json(promotions)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async getAllAdmin(req, res) {
|
||||
try {
|
||||
const promotions = await Promotion.findAll(req.tenant?.id)
|
||||
return res.json(promotions)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const { nome, descricao, preco_original, preco_promocional, imagem } = req.body
|
||||
if (!nome || !preco_promocional) {
|
||||
return res.status(400).json({ error: 'Nome e preço promocional são obrigatórios' })
|
||||
}
|
||||
const result = await Promotion.create({
|
||||
nome,
|
||||
descricao,
|
||||
preco_original: parseFloat(preco_original) || null,
|
||||
preco_promocional: parseFloat(preco_promocional),
|
||||
imagem: imagem || '',
|
||||
tenant_id: req.tenant?.id || 1,
|
||||
})
|
||||
return res.json({ success: true, id: result.lastInsertRowid })
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar promoção:', error)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { nome, descricao, preco_original, preco_promocional, imagem, ativo } = req.body
|
||||
await Promotion.update(id, {
|
||||
nome,
|
||||
descricao,
|
||||
preco_original: parseFloat(preco_original) || null,
|
||||
preco_promocional: parseFloat(preco_promocional),
|
||||
imagem: imagem || '',
|
||||
ativo: ativo !== undefined ? ativo : true,
|
||||
})
|
||||
return res.json({ success: true })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async deletePromotion(req, res) {
|
||||
try {
|
||||
await Promotion.deleteById(req.params.id)
|
||||
return res.json({ success: true })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// Resumo para o Cérebro do WhatsApp (PROMO-07)
|
||||
async getActiveSummary(req, res) {
|
||||
try {
|
||||
const promos = await Promotion.findAllActive(req.tenant?.id)
|
||||
const summary = promos.map(p => ({
|
||||
id: p.id,
|
||||
nome: p.nome,
|
||||
descricao: p.descricao,
|
||||
preco_original: p.preco_original,
|
||||
preco_promocional: p.preco_promocional,
|
||||
desconto_pct: p.preco_original
|
||||
? Math.round((1 - p.preco_promocional / p.preco_original) * 100)
|
||||
: null,
|
||||
}))
|
||||
return res.json({ total: summary.length, promocoes: summary })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = promotionsController
|
||||
@@ -0,0 +1,408 @@
|
||||
const Raffle = require('../models/Raffle')
|
||||
const Ticket = require('../models/Ticket')
|
||||
const RaffleResult = require('../models/RaffleResult')
|
||||
const Order = require('../models/Order')
|
||||
const PriceRule = require('../models/PriceRule')
|
||||
const RaffleDiscount = require('../models/RaffleDiscount')
|
||||
const RaffleFlashPromo = require('../models/RaffleFlashPromo')
|
||||
const WhatsappCore = require('../services/whatsappService')
|
||||
const NotificationService = require('../services/notificationService')
|
||||
const AsaasService = require('../services/asaasService')
|
||||
const crypto = require('crypto')
|
||||
|
||||
function validateCPF(cpf) {
|
||||
cpf = cpf.replace(/[^\d]+/g, '')
|
||||
if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false
|
||||
let sum = 0
|
||||
for (let i = 0; i < 9; i++) sum += parseInt(cpf.charAt(i)) * (10 - i)
|
||||
let rem = 11 - (sum % 11)
|
||||
if (rem === 10 || rem === 11) rem = 0
|
||||
if (rem !== parseInt(cpf.charAt(9))) return false
|
||||
sum = 0
|
||||
for (let i = 0; i < 10; i++) sum += parseInt(cpf.charAt(i)) * (11 - i)
|
||||
rem = 11 - (sum % 11)
|
||||
if (rem === 10 || rem === 11) rem = 0
|
||||
return rem === parseInt(cpf.charAt(10))
|
||||
}
|
||||
|
||||
const raffleController = {
|
||||
async getRaffles(req, res) {
|
||||
try {
|
||||
const raffles = await Raffle.findActive(req.tenant?.id)
|
||||
return res.json(raffles)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async getRaffleDetail(req, res) {
|
||||
try {
|
||||
const raffle = await Raffle.findById(req.params.id)
|
||||
if (!raffle) return res.status(404).json({ error: 'Sorteio não encontrado' })
|
||||
|
||||
const soldNumbers = await Ticket.getSoldNumbers(raffle.id)
|
||||
const totalSold = soldNumbers.length
|
||||
|
||||
return res.json({
|
||||
...raffle,
|
||||
soldNumbers,
|
||||
totalSold,
|
||||
totalDisponivel: raffle.total_numeros - totalSold
|
||||
})
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async buyTicket(req, res) {
|
||||
try {
|
||||
const { raffle_id, numero, usuario_nome, usuario_telefone, usuario_cpf } = req.body
|
||||
|
||||
if (!raffle_id || !numero || !usuario_nome) {
|
||||
return res.status(400).json({ error: 'Dados incompletos' })
|
||||
}
|
||||
|
||||
if (usuario_cpf) {
|
||||
if (!validateCPF(usuario_cpf)) {
|
||||
return res.status(400).json({ error: 'CPF inválido' })
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({ error: 'CPF é obrigatório para pagamento via PIX' })
|
||||
}
|
||||
|
||||
const raffle = await Raffle.findById(raffle_id)
|
||||
if (!raffle) throw new Error('Sorteio não encontrado')
|
||||
|
||||
const localOrderId = crypto.randomUUID()
|
||||
const numeros = Array.isArray(numero) ? numero.map(n => parseInt(n)) : [parseInt(numero)]
|
||||
|
||||
// ENGINE DE PRECIFICAÇÃO DINÂMICA
|
||||
const currentPriceData = await PriceRule.getCurrentPrice(raffle_id)
|
||||
const ticketPrice = currentPriceData.price
|
||||
|
||||
// DESCONTO POR QUANTIDADE
|
||||
const bestDiscount = await RaffleDiscount.getBestForQty(raffle_id, numeros.length)
|
||||
const qtyDiscountPct = bestDiscount ? Number(bestDiscount.discount_pct) : 0
|
||||
|
||||
// PROMOÇÃO RELÂMPAGO (aplica o maior entre os dois)
|
||||
const flashPromo = await RaffleFlashPromo.getActive(raffle_id)
|
||||
const flashPct = flashPromo ? Number(flashPromo.discount_pct) : 0
|
||||
const discountPct = Math.max(qtyDiscountPct, flashPct)
|
||||
const discountType = flashPct >= qtyDiscountPct && flashPct > 0 ? 'flash' : (qtyDiscountPct > 0 ? 'quantity' : 'none')
|
||||
|
||||
const subtotal = ticketPrice * numeros.length
|
||||
const amount = discountPct > 0
|
||||
? Math.round(subtotal * (1 - discountPct / 100) * 100) / 100
|
||||
: subtotal
|
||||
|
||||
// 1. Cria Record de Order
|
||||
await Order.create({
|
||||
id: localOrderId,
|
||||
user_cpf: usuario_cpf.replace(/\D/g, ''),
|
||||
total_amount: amount,
|
||||
payment_method: 'PIX'
|
||||
})
|
||||
|
||||
// 2. Tenta fazer a reserva — se falhar, remove a Order criada acima
|
||||
try {
|
||||
await Ticket.reserve({
|
||||
raffle_id,
|
||||
numeros,
|
||||
usuario_nome,
|
||||
usuario_telefone: usuario_telefone || null,
|
||||
usuario_cpf: usuario_cpf.replace(/\D/g, ''),
|
||||
order_id: localOrderId,
|
||||
preco_pago: ticketPrice
|
||||
})
|
||||
} catch (reserveErr) {
|
||||
await Order.delete(localOrderId).catch(() => {})
|
||||
throw reserveErr
|
||||
}
|
||||
|
||||
// 2.5 Registrar audit log do preço aplicado
|
||||
await PriceRule.logPrice({
|
||||
raffle_id,
|
||||
order_id: localOrderId,
|
||||
user_cpf: usuario_cpf.replace(/\D/g, ''),
|
||||
price_applied: ticketPrice,
|
||||
rule_id: currentPriceData.rule_id
|
||||
})
|
||||
|
||||
// 3. Se chegou aqui, os tickets estão reservados. Vamos chamar a Asaas
|
||||
let asaasPayload = null
|
||||
try {
|
||||
const customerId = await AsaasService.getOrCreateCustomer(
|
||||
usuario_nome, usuario_cpf, usuario_telefone, null
|
||||
)
|
||||
asaasPayload = await AsaasService.createPixCharge(
|
||||
customerId, amount, `Compra de ${numeros.length} rifa(s) no sorteio #${raffle_id}`
|
||||
)
|
||||
} catch (asaasErr) {
|
||||
throw new Error('Falha na comunicação com gateway de pagamento: ' + asaasErr.message)
|
||||
}
|
||||
|
||||
// 4. Salva os IDs e dados Pix asaas
|
||||
await Order.updateAsaasPayment(
|
||||
localOrderId, asaasPayload.orderId, asaasPayload.qrCodeUrl, asaasPayload.pixCopyPaste
|
||||
)
|
||||
|
||||
// Fase 7: Dispara notificação PIX Pendente via fila
|
||||
if (usuario_telefone && usuario_telefone.length >= 10) {
|
||||
WhatsappCore.sendNotification({
|
||||
userId: null,
|
||||
phone: usuario_telefone,
|
||||
type: 'purchase_pending',
|
||||
variables: {
|
||||
pixCopiaECola: asaasPayload.pixCopyPaste,
|
||||
link: asaasPayload.qrCodeUrl
|
||||
}
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
console.log(`🎟️ Tickets reservado para ${usuario_nome}. Custo: R$${amount.toFixed(2)}${discountPct > 0 ? ` (${discountPct}% off via ${discountType})` : ''} / PIX gerado`)
|
||||
return res.json({
|
||||
success: true,
|
||||
orderId: localOrderId,
|
||||
number: numero,
|
||||
amount,
|
||||
discountPct,
|
||||
discountType,
|
||||
subtotal,
|
||||
qrCodeUrl: asaasPayload.qrCodeUrl,
|
||||
pixCopyPaste: asaasPayload.pixCopyPaste
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.message === 'Número já vendido') return res.status(400).json({ error: 'Número já vendido ou reservado' })
|
||||
if (error.message === 'Sorteio não encontrado') return res.status(404).json({ error: 'Sorteio não encontrado' })
|
||||
if (error.message === 'Sorteio encerrado') return res.status(400).json({ error: 'Sorteio encerrado' })
|
||||
if (error.message.includes('Número inválido')) return res.status(400).json({ error: error.message })
|
||||
if (error.code === '23505') return res.status(400).json({ error: 'Número já vendido ou reservado' })
|
||||
|
||||
console.error('Erro em buyTicket:', error)
|
||||
return res.status(500).json({ error: error.message || 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async getOrderStatus(req, res) {
|
||||
try {
|
||||
const order = await Order.findById(req.params.id)
|
||||
if (!order) return res.status(404).json({ error: 'Pedido não encontrado' })
|
||||
return res.json({ status: order.status })
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar status do pedido:', error)
|
||||
return res.status(500).json({ error: 'Erro ao buscar status' })
|
||||
}
|
||||
},
|
||||
|
||||
async getTickets(req, res) {
|
||||
try {
|
||||
const tickets = await Ticket.findByRaffle(req.params.raffle_id)
|
||||
return res.json(tickets)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async getAllTickets(req, res) {
|
||||
try {
|
||||
const tickets = await Ticket.getAll()
|
||||
return res.json(tickets)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async createRaffle(req, res) {
|
||||
try {
|
||||
const { titulo, descricao, preco_numero, total_numeros, start_at, end_at } = req.body
|
||||
if (!titulo) return res.status(400).json({ error: 'Título é obrigatório' })
|
||||
|
||||
const result = await Raffle.create({
|
||||
titulo,
|
||||
descricao,
|
||||
preco_numero: parseFloat(preco_numero) || 5.0,
|
||||
total_numeros: parseInt(total_numeros) || 500,
|
||||
start_at: start_at || null,
|
||||
end_at: end_at || null,
|
||||
tenant_id: req.tenant?.id || 1,
|
||||
})
|
||||
return res.json({ success: true, id: result.lastInsertRowid })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTicket(req, res) {
|
||||
try {
|
||||
const tenantId = req.tenant?.id || 1
|
||||
await Ticket.deleteByIdAndTenant(req.params.id, tenantId)
|
||||
return res.json({ success: true })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async getMyTickets(req, res) {
|
||||
try {
|
||||
if (!req.user || !req.user.cpf) {
|
||||
return res.status(401).json({ error: 'Usuário não autenticado' })
|
||||
}
|
||||
const tickets = await Ticket.findByUserCpf(req.user.cpf)
|
||||
return res.json(tickets)
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar meus tickets:', error)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
async drawRaffle(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const ip = req.ip || '127.0.0.1'
|
||||
|
||||
const result = await RaffleResult.draw(id, ip)
|
||||
return res.json({ success: true, result })
|
||||
} catch (error) {
|
||||
console.error('Erro no draw:', error)
|
||||
return res.status(400).json({ error: error.message || 'Erro interno ao realizar sorteio' })
|
||||
}
|
||||
},
|
||||
|
||||
async getResult(req, res) {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const result = await RaffleResult.findByRaffleId(id)
|
||||
if (!result) return res.status(404).json({ error: 'Nenhum resultado encontrado para este sorteio' })
|
||||
return res.json(result)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// PREÇO DINÂMICO
|
||||
async getCurrentPrice(req, res) {
|
||||
try {
|
||||
const data = await PriceRule.getCurrentPrice(req.params.id)
|
||||
return res.json(data)
|
||||
} catch (error) {
|
||||
if (error.message === 'Sorteio não encontrado') return res.status(404).json({ error: error.message })
|
||||
return res.status(500).json({ error: 'Erro interno ao consultar preço.' })
|
||||
}
|
||||
},
|
||||
|
||||
async addPriceRule(req, res) {
|
||||
try {
|
||||
const { rule_type, price, start_at, end_at, priority } = req.body
|
||||
if (!rule_type || !price) return res.status(400).json({ error: 'Regra e Preço obrigatórios' })
|
||||
|
||||
// Captura preço atual antes de criar a nova regra
|
||||
const before = await PriceRule.getCurrentPrice(req.params.id).catch(() => null)
|
||||
|
||||
const id = await PriceRule.create({
|
||||
raffle_id: req.params.id,
|
||||
rule_type,
|
||||
price,
|
||||
start_at,
|
||||
end_at,
|
||||
priority,
|
||||
})
|
||||
|
||||
// Dispara notificações de queda de preço se o novo preço for menor
|
||||
if (before && parseFloat(price) < parseFloat(before.price)) {
|
||||
const raffle = await Raffle.findById(req.params.id)
|
||||
if (raffle) {
|
||||
// Broadcast para todos os opt-in
|
||||
NotificationService.notifyPriceDrop(raffle.id, raffle.titulo, price).catch(() => {})
|
||||
// Notificação localizada para reservas pendentes de pagamento
|
||||
NotificationService.notifyPriceDropLocal(raffle.id, raffle.titulo, price).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true, id: id.rows?.[0]?.id || id.lastInsertRowid })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno ao criar regra de preço.' })
|
||||
}
|
||||
},
|
||||
|
||||
async listPriceRules(req, res) {
|
||||
try {
|
||||
const rules = await PriceRule.listByRaffle(req.params.id)
|
||||
return res.json(rules.rows || rules)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro interno ao listar regras.' })
|
||||
}
|
||||
},
|
||||
|
||||
async getDiscounts(req, res) {
|
||||
try {
|
||||
const discounts = await RaffleDiscount.listByRaffle(req.params.id)
|
||||
return res.json(discounts)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao listar descontos.' })
|
||||
}
|
||||
},
|
||||
|
||||
async saveDiscounts(req, res) {
|
||||
try {
|
||||
const raffleId = parseInt(req.params.id)
|
||||
const tiers = req.body.tiers
|
||||
if (!Array.isArray(tiers)) return res.status(400).json({ error: 'tiers deve ser um array' })
|
||||
|
||||
const valid = tiers.filter(t =>
|
||||
Number.isInteger(parseInt(t.min_qty)) && parseInt(t.min_qty) >= 2 &&
|
||||
parseFloat(t.discount_pct) > 0 && parseFloat(t.discount_pct) <= 100
|
||||
)
|
||||
|
||||
await RaffleDiscount.deleteByRaffle(raffleId)
|
||||
if (valid.length > 0) await RaffleDiscount.bulkInsert(raffleId, valid)
|
||||
|
||||
return res.json({ success: true, saved: valid.length })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao salvar descontos.' })
|
||||
}
|
||||
},
|
||||
|
||||
async getFlashPromo(req, res) {
|
||||
try {
|
||||
const promo = await RaffleFlashPromo.getActive(req.params.id)
|
||||
return res.json(promo || null)
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao buscar promoção.' })
|
||||
}
|
||||
},
|
||||
|
||||
async createFlashPromo(req, res) {
|
||||
try {
|
||||
const { discount_pct, duration_minutes, label } = req.body
|
||||
if (!discount_pct || !duration_minutes) {
|
||||
return res.status(400).json({ error: 'discount_pct e duration_minutes são obrigatórios' })
|
||||
}
|
||||
if (parseFloat(discount_pct) <= 0 || parseFloat(discount_pct) > 100) {
|
||||
return res.status(400).json({ error: 'Percentual inválido (1–100)' })
|
||||
}
|
||||
if (parseInt(duration_minutes) < 1 || parseInt(duration_minutes) > 1440) {
|
||||
return res.status(400).json({ error: 'Duração inválida (1–1440 minutos)' })
|
||||
}
|
||||
const promo = await RaffleFlashPromo.create({
|
||||
raffle_id: parseInt(req.params.id),
|
||||
discount_pct,
|
||||
duration_minutes,
|
||||
label
|
||||
})
|
||||
return res.json({ success: true, promo: promo.rows?.[0] || promo })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao criar promoção.' })
|
||||
}
|
||||
},
|
||||
|
||||
async cancelFlashPromo(req, res) {
|
||||
try {
|
||||
await RaffleFlashPromo.cancel(req.params.promo_id)
|
||||
return res.json({ success: true })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: 'Erro ao cancelar promoção.' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = raffleController
|
||||
@@ -0,0 +1,79 @@
|
||||
'use strict'
|
||||
|
||||
const PedidoRecorrente = require('../models/PedidoRecorrente')
|
||||
const Cotacao = require('../models/Cotacao')
|
||||
const Produto = require('../models/Produto')
|
||||
|
||||
const TENANT_ID = parseInt(process.env.TENANT_ID || '1', 10)
|
||||
|
||||
module.exports = {
|
||||
/** GET /api/recorrentes — lista do usuário logado */
|
||||
async list(req, res) {
|
||||
try {
|
||||
const rows = await PedidoRecorrente.listByUser(req.user.id, TENANT_ID)
|
||||
res.json(rows)
|
||||
} catch (e) {
|
||||
console.error('[recorrente.list]', e.message)
|
||||
res.status(500).json({ error: 'Erro ao listar pedidos recorrentes' })
|
||||
}
|
||||
},
|
||||
|
||||
/** POST /api/recorrentes */
|
||||
async create(req, res) {
|
||||
try {
|
||||
const { nome, itens, frequencia, proximo_em, cliente_id } = req.body
|
||||
if (!itens?.length) return res.status(400).json({ error: 'Informe ao menos um item' })
|
||||
if (!proximo_em) return res.status(400).json({ error: 'Informe a data do primeiro pedido' })
|
||||
|
||||
const rec = await PedidoRecorrente.create({
|
||||
tenant_id: TENANT_ID,
|
||||
user_id: req.user.id,
|
||||
cliente_id: cliente_id || null,
|
||||
nome: nome || 'Pedido Recorrente',
|
||||
itens,
|
||||
frequencia: frequencia || 'mensal',
|
||||
proximo_em,
|
||||
})
|
||||
res.status(201).json(rec)
|
||||
} catch (e) {
|
||||
console.error('[recorrente.create]', e.message)
|
||||
res.status(500).json({ error: 'Erro ao criar pedido recorrente' })
|
||||
}
|
||||
},
|
||||
|
||||
/** PUT /api/recorrentes/:id */
|
||||
async update(req, res) {
|
||||
try {
|
||||
const { nome, itens, frequencia, proximo_em, pausado } = req.body
|
||||
await PedidoRecorrente.update(req.params.id, req.user.id, { nome, itens, frequencia, proximo_em, pausado })
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
console.error('[recorrente.update]', e.message)
|
||||
res.status(500).json({ error: 'Erro ao atualizar pedido recorrente' })
|
||||
}
|
||||
},
|
||||
|
||||
/** PATCH /api/recorrentes/:id/pausa */
|
||||
async togglePausa(req, res) {
|
||||
try {
|
||||
const rec = await PedidoRecorrente.findById(req.params.id, req.user.id)
|
||||
if (!rec) return res.status(404).json({ error: 'Não encontrado' })
|
||||
await PedidoRecorrente.togglePausa(req.params.id, req.user.id, !rec.pausado)
|
||||
res.json({ pausado: !rec.pausado })
|
||||
} catch (e) {
|
||||
console.error('[recorrente.togglePausa]', e.message)
|
||||
res.status(500).json({ error: 'Erro ao pausar/retomar' })
|
||||
}
|
||||
},
|
||||
|
||||
/** DELETE /api/recorrentes/:id */
|
||||
async remove(req, res) {
|
||||
try {
|
||||
await PedidoRecorrente.remove(req.params.id, req.user.id)
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
console.error('[recorrente.remove]', e.message)
|
||||
res.status(500).json({ error: 'Erro ao remover pedido recorrente' })
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use strict'
|
||||
|
||||
const bcrypt = require('bcryptjs')
|
||||
const { queryOne, query, execute } = require('../database/postgres')
|
||||
const { signSuperToken } = require('../middleware/superAuth')
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.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 admin = await queryOne('SELECT * FROM super_admins WHERE email=$1', [email.toLowerCase()])
|
||||
if (!admin) return res.status(401).json({ error: 'Credenciais inválidas' })
|
||||
|
||||
const ok = await bcrypt.compare(password, admin.password_hash)
|
||||
if (!ok) return res.status(401).json({ error: 'Credenciais inválidas' })
|
||||
|
||||
const token = signSuperToken(admin)
|
||||
res.cookie('super_token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.COOKIE_SECURE === 'true',
|
||||
sameSite: 'lax',
|
||||
maxAge: 8 * 60 * 60 * 1000,
|
||||
})
|
||||
res.json({ ok: true, name: admin.name, email: admin.email })
|
||||
} catch (err) {
|
||||
console.error('[superAdmin] login:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.logout = (req, res) => {
|
||||
res.clearCookie('super_token')
|
||||
res.json({ ok: true })
|
||||
}
|
||||
|
||||
exports.me = (req, res) => {
|
||||
res.json({ id: req.superAdmin.id, name: req.superAdmin.name, email: req.superAdmin.email })
|
||||
}
|
||||
|
||||
// ── Tenants ───────────────────────────────────────────────────────────────────
|
||||
|
||||
exports.listTenants = async (req, res) => {
|
||||
try {
|
||||
const tenants = await query(`
|
||||
SELECT t.*,
|
||||
tc.primary_color, tc.secondary_color, tc.logo_url,
|
||||
tc.contact_email, tc.contact_phone, tc.app_url
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_config tc ON tc.tenant_id = t.id
|
||||
ORDER BY t.id
|
||||
`)
|
||||
res.json(tenants ?? [])
|
||||
} catch (err) {
|
||||
console.error('[superAdmin] listTenants:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.createTenant = async (req, res) => {
|
||||
try {
|
||||
const { slug, name, domain, primary_color, secondary_color,
|
||||
logo_url, contact_email, contact_phone, app_url } = req.body
|
||||
if (!slug || !name) return res.status(400).json({ error: 'slug e name obrigatórios' })
|
||||
|
||||
const result = await execute(
|
||||
`INSERT INTO tenants (slug, name, domain, active) VALUES ($1,$2,$3,TRUE) RETURNING id`,
|
||||
[slug.toLowerCase(), name, domain || null]
|
||||
)
|
||||
const tenantId = result.rows[0].id
|
||||
|
||||
await execute(
|
||||
`INSERT INTO tenant_config
|
||||
(tenant_id, primary_color, secondary_color, logo_url, contact_email, contact_phone, app_url)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[tenantId, primary_color || '#f59e0b', secondary_color || '#10b981',
|
||||
logo_url || null, contact_email || null, contact_phone || null, app_url || null]
|
||||
)
|
||||
res.status(201).json({ id: tenantId })
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Slug já existe' })
|
||||
console.error('[superAdmin] createTenant:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.updateTenant = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, domain, active, primary_color, secondary_color,
|
||||
logo_url, contact_email, contact_phone, app_url } = req.body
|
||||
|
||||
await execute(
|
||||
`UPDATE tenants SET name=$1, domain=$2, active=$3 WHERE id=$4`,
|
||||
[name, domain || null, active !== false, id]
|
||||
)
|
||||
await execute(
|
||||
`UPDATE tenant_config
|
||||
SET primary_color=$1, secondary_color=$2, logo_url=$3,
|
||||
contact_email=$4, contact_phone=$5, app_url=$6
|
||||
WHERE tenant_id=$7`,
|
||||
[primary_color, secondary_color, logo_url || null,
|
||||
contact_email || null, contact_phone || null, app_url || null, id]
|
||||
)
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[superAdmin] updateTenant:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.deleteTenant = async (req, res) => {
|
||||
try {
|
||||
await execute('UPDATE tenants SET active=FALSE WHERE id=$1', [req.params.id])
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
exports.getTenantStats = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const tid = parseInt(id)
|
||||
|
||||
const [users, orders, tickets, clubMembers] = await Promise.all([
|
||||
queryOne(`SELECT COUNT(*) AS cnt FROM users WHERE tenant_id=$1`, [tid]),
|
||||
queryOne(`SELECT COUNT(*) AS cnt, COALESCE(SUM(total_amount),0) AS revenue
|
||||
FROM orders WHERE tenant_id=$1 AND status='paid'`, [tid]),
|
||||
queryOne(`SELECT COUNT(*) AS cnt FROM tickets WHERE tenant_id=$1 AND status='sold'`, [tid]),
|
||||
queryOne(`SELECT COUNT(*) AS cnt FROM club_members WHERE tenant_id=$1 AND status='active'`, [tid]),
|
||||
])
|
||||
|
||||
res.json({
|
||||
users: Number(users?.cnt ?? 0),
|
||||
orders: Number(orders?.cnt ?? 0),
|
||||
revenue: Number(orders?.revenue ?? 0),
|
||||
tickets_sold: Number(tickets?.cnt ?? 0),
|
||||
club_members: Number(clubMembers?.cnt ?? 0),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[superAdmin] getTenantStats:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tenant Admins ─────────────────────────────────────────────────────────────
|
||||
|
||||
exports.createTenantAdmin = async (req, res) => {
|
||||
try {
|
||||
const { id: tenantId } = req.params
|
||||
const { username, password, name } = req.body
|
||||
if (!username || !password) return res.status(400).json({ error: 'username e password obrigatórios' })
|
||||
|
||||
const hash = await bcrypt.hash(password, 10)
|
||||
const result = await execute(
|
||||
`INSERT INTO admins (username, password_hash, name, tenant_id) VALUES ($1,$2,$3,$4) RETURNING id`,
|
||||
[username, hash, name || username, tenantId]
|
||||
)
|
||||
res.status(201).json({ id: result.rows[0].id })
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Username já existe' })
|
||||
console.error('[superAdmin] createTenantAdmin:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
'use strict'
|
||||
|
||||
const bcrypt = require('bcryptjs')
|
||||
const crypto = require('crypto')
|
||||
const User = require('../models/User')
|
||||
const Claim = require('../models/Claim')
|
||||
const Notification = require('../models/Notification')
|
||||
const NotificationService = require('../services/notificationService')
|
||||
const { queryOne, execute } = require('../database/postgres')
|
||||
const { signUserToken } = require('../middleware/auth')
|
||||
|
||||
const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true'
|
||||
|
||||
const COOKIE_OPTS = {
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: 'lax',
|
||||
}
|
||||
// Cookie hint legível pelo JS — permite o frontend evitar chamada desnecessária a /me
|
||||
const HINT_OPTS = {
|
||||
httpOnly: false,
|
||||
maxAge: 24 * 60 * 60 * 1000,
|
||||
secure: COOKIE_SECURE,
|
||||
sameSite: 'lax',
|
||||
}
|
||||
function setUserCookies(res, token) {
|
||||
res.cookie('user_token', token, COOKIE_OPTS)
|
||||
res.cookie('_usr', '1', HINT_OPTS)
|
||||
}
|
||||
|
||||
const usersController = {
|
||||
|
||||
// ── Lead (hotspot) ─────────────────────────────────────────────────────────
|
||||
async receiveLead(req, res) {
|
||||
try {
|
||||
const { nome, email, cpf, telefone, ip, source, session_id } = req.body
|
||||
if (!email && !nome) return res.status(400).json({ error: 'Nome ou e-mail é obrigatório' })
|
||||
|
||||
const tenantId = req.tenant?.id || 1
|
||||
const cleanEmail = email ? email.toLowerCase().trim() : null
|
||||
const cleanCpf = cpf ? cpf.replace(/[^\d]+/g, '') : null
|
||||
const cleanTel = telefone ? telefone.replace(/[^\d]+/g, '') : null
|
||||
const clientIp = ip || req.ip || null
|
||||
const displayNome = nome || cleanEmail
|
||||
|
||||
// Deduplicação: email primeiro (mais confiável), depois telefone
|
||||
let existing = null
|
||||
if (cleanEmail) existing = await User.findByEmail(cleanEmail, tenantId)
|
||||
if (!existing && cleanTel) existing = await User.findByPhone(cleanTel, tenantId)
|
||||
|
||||
if (existing) {
|
||||
await execute(
|
||||
`UPDATE users
|
||||
SET ip = $1, portal_session_id = $2, timestamp = NOW()
|
||||
WHERE id = $3`,
|
||||
[clientIp, session_id || null, existing.id]
|
||||
)
|
||||
console.log(`📥 Lead atualizado (${cleanEmail || cleanTel}): id=${existing.id}`)
|
||||
return res.json({ success: true, id: existing.id, updated: true })
|
||||
}
|
||||
|
||||
const result = await User.create({
|
||||
nome: displayNome,
|
||||
email: cleanEmail,
|
||||
cpf: cleanCpf,
|
||||
telefone: cleanTel,
|
||||
ip: clientIp,
|
||||
source: source || 'hotspot',
|
||||
tenant_id: tenantId,
|
||||
portal_session_id: session_id || null,
|
||||
})
|
||||
console.log('📥 Lead criado:', cleanEmail || displayNome, session_id ? `(session: ${session_id})` : '')
|
||||
return res.json({ success: true, id: result.id ?? result.lastInsertRowid })
|
||||
} catch (error) {
|
||||
console.error('Erro ao receber lead:', error)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Admin ──────────────────────────────────────────────────────────────────
|
||||
async getAll(req, res) {
|
||||
try {
|
||||
return res.json(await User.findAll(req.tenant?.id))
|
||||
} catch { return res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
async getStats(req, res) {
|
||||
try {
|
||||
return res.json(await User.getStats(req.tenant?.id))
|
||||
} catch { return res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
async deleteUser(req, res) {
|
||||
try {
|
||||
await User.deleteById(req.params.id)
|
||||
return res.json({ success: true })
|
||||
} catch { return res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
async updateUser(req, res) {
|
||||
try {
|
||||
const { nome, email, cpf, telefone, source } = req.body
|
||||
await User.update(req.params.id, { nome, email, cpf, telefone, source })
|
||||
return res.json({ success: true })
|
||||
} catch (e) {
|
||||
console.error('Erro ao atualizar usuário:', e)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Cadastro ───────────────────────────────────────────────────────────────
|
||||
async registerUser(req, res) {
|
||||
try {
|
||||
const { nome, email, cpf, telefone, password } = req.body
|
||||
if (!nome || !email || !cpf || !telefone || !password) {
|
||||
return res.status(400).json({ error: 'Nome, email, CPF, telefone e senha são obrigatórios' })
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return res.status(400).json({ error: 'E-mail inválido' })
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Senha deve ter ao menos 6 caracteres' })
|
||||
}
|
||||
|
||||
const tenantId = req.tenant?.id || 1
|
||||
const existing = await User.findByEmail(email, tenantId)
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'E-mail já cadastrado. Faça login.' })
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 10)
|
||||
const result = await User.create({ nome, email, cpf, telefone, source: 'web', password_hash, tenant_id: tenantId })
|
||||
const newId = Number(result.lastInsertRowid ?? result.rows?.[0]?.id)
|
||||
|
||||
const token = signUserToken({ id: newId, nome, cpf, email }, tenantId)
|
||||
setUserCookies(res, token)
|
||||
|
||||
NotificationService.notifyWelcome(newId, nome, telefone, cpf).catch(() => {})
|
||||
return res.status(201).json({ success: true, redirect: '/area-do-usuario' })
|
||||
} catch (error) {
|
||||
console.error('Erro no cadastro:', error)
|
||||
if (error.code === '23505') return res.status(409).json({ error: 'E-mail já cadastrado.' })
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Login ──────────────────────────────────────────────────────────────────
|
||||
async loginUser(req, res) {
|
||||
try {
|
||||
const { email, password, cpf, telefone } = req.body
|
||||
const tenantId = req.tenant?.id || 1
|
||||
let user = null
|
||||
|
||||
if (email && password) {
|
||||
// Fluxo novo: email + senha
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return res.status(400).json({ error: 'E-mail inválido' })
|
||||
}
|
||||
user = await User.findByEmailAndTenant(email, tenantId)
|
||||
if (!user) return res.status(401).json({ error: 'E-mail não encontrado.' })
|
||||
if (!user.password_hash) {
|
||||
return res.status(401).json({
|
||||
error: 'Conta legada. Use CPF e telefone ou migre sua conta.',
|
||||
legacy: true,
|
||||
})
|
||||
}
|
||||
const ok = await bcrypt.compare(password, user.password_hash)
|
||||
if (!ok) return res.status(401).json({ error: 'Senha incorreta.' })
|
||||
|
||||
} else if (cpf && telefone) {
|
||||
// Fallback legado: CPF + telefone (apenas se sem password_hash)
|
||||
user = await User.findByCpfPhone(cpf, telefone, tenantId)
|
||||
if (!user) return res.status(401).json({ error: 'Usuário não encontrado. Verifique os dados ou cadastre-se.' })
|
||||
if (user.password_hash) {
|
||||
return res.status(401).json({ error: 'Esta conta usa email e senha. Faça login com seu e-mail.' })
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Informe email+senha ou CPF+telefone.' })
|
||||
}
|
||||
|
||||
const token = signUserToken(user, tenantId)
|
||||
setUserCookies(res, token)
|
||||
|
||||
if (user.telefone) {
|
||||
NotificationService.notifySecurityLogin(user.id, user.nome, user.telefone, user.cpf).catch(() => {})
|
||||
}
|
||||
const safeUser = {
|
||||
id: user.id,
|
||||
nome: user.nome,
|
||||
email: user.email,
|
||||
cpf: user.cpf,
|
||||
telefone: user.telefone,
|
||||
whatsapp_optin: user.whatsapp_optin,
|
||||
}
|
||||
return res.json({ success: true, redirect: '/area-do-usuario', user: safeUser })
|
||||
} catch (error) {
|
||||
console.error('Erro no login:', error)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Esqueci a senha ────────────────────────────────────────────────────────
|
||||
async forgotPassword(req, res) {
|
||||
try {
|
||||
const { email } = req.body
|
||||
if (!email) return res.status(400).json({ error: 'E-mail obrigatório' })
|
||||
const tenantId = req.tenant?.id || 1
|
||||
const user = await User.findByEmailAndTenant(email, tenantId)
|
||||
// Responde sempre 200 para não revelar se o email existe
|
||||
if (!user) return res.json({ ok: true })
|
||||
|
||||
const token = crypto.randomBytes(48).toString('hex')
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1h
|
||||
await execute(
|
||||
`INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)`,
|
||||
[user.id, token, expiresAt]
|
||||
)
|
||||
|
||||
const appUrl = req.tenant?.app_url || process.env.APP_URL || 'http://localhost:4001'
|
||||
const link = `${appUrl}/redefinir-senha?token=${token}`
|
||||
|
||||
if (user.telefone) {
|
||||
NotificationService.notifyPasswordReset(user.id, user.nome, user.telefone, user.cpf, link).catch(() => {})
|
||||
}
|
||||
return res.json({ ok: true })
|
||||
} catch (e) {
|
||||
console.error('Erro forgot-password:', e)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Redefinir senha ────────────────────────────────────────────────────────
|
||||
async resetPassword(req, res) {
|
||||
try {
|
||||
const { token, password } = req.body
|
||||
if (!token || !password) return res.status(400).json({ error: 'Token e senha obrigatórios' })
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Senha deve ter ao menos 6 caracteres' })
|
||||
|
||||
const row = await queryOne(
|
||||
`SELECT * FROM password_reset_tokens
|
||||
WHERE token=$1 AND used=FALSE AND expires_at > NOW()`,
|
||||
[token]
|
||||
)
|
||||
if (!row) return res.status(400).json({ error: 'Token inválido ou expirado' })
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 10)
|
||||
await User.updatePassword(row.user_id, password_hash)
|
||||
await execute(`UPDATE password_reset_tokens SET used=TRUE WHERE id=$1`, [row.id])
|
||||
|
||||
return res.json({ ok: true })
|
||||
} catch (e) {
|
||||
console.error('Erro reset-password:', e)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Ativação de lead do hotspot (sem CPF) ─────────────────────────────────
|
||||
async activateUser(req, res) {
|
||||
try {
|
||||
const { email, telefone, nome, password } = req.body
|
||||
if (!email || !telefone || !nome || !password) {
|
||||
return res.status(400).json({ error: 'Nome, e-mail, celular e senha são obrigatórios' })
|
||||
}
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Senha deve ter ao menos 6 caracteres' })
|
||||
|
||||
const tenantId = req.tenant?.id || 1
|
||||
const cleanEmail = email.toLowerCase().trim()
|
||||
const cleanTel = telefone.replace(/\D/g, '')
|
||||
|
||||
// Busca o lead pelo email (ou pelo telefone se email não bater)
|
||||
let user = await User.findByEmailAndTenant(cleanEmail, tenantId)
|
||||
if (!user) user = await User.findByPhone(cleanTel, tenantId)
|
||||
if (!user) return res.status(404).json({ error: 'Cadastro não encontrado. Conecte-se ao WiFi primeiro.' })
|
||||
if (user.password_hash) return res.status(409).json({ error: 'Conta já ativada. Faça login normalmente.' })
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 10)
|
||||
await execute(
|
||||
`UPDATE users SET nome=$1, email=$2, password_hash=$3 WHERE id=$4`,
|
||||
[nome, cleanEmail, password_hash, user.id]
|
||||
)
|
||||
|
||||
const token = signUserToken({ ...user, nome, email: cleanEmail }, tenantId)
|
||||
setUserCookies(res, token)
|
||||
NotificationService.notifyWelcome(user.id, nome, user.telefone || cleanTel, user.cpf).catch(() => {})
|
||||
return res.status(200).json({ success: true, redirect: '/area-do-usuario' })
|
||||
} catch (e) {
|
||||
console.error('Erro activate:', e)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Migração de conta legada ───────────────────────────────────────────────
|
||||
async migrateAccount(req, res) {
|
||||
try {
|
||||
const { cpf, telefone, email, password } = req.body
|
||||
if (!cpf || !telefone || !email || !password) {
|
||||
return res.status(400).json({ error: 'CPF, telefone, novo email e senha são obrigatórios' })
|
||||
}
|
||||
if (password.length < 6) return res.status(400).json({ error: 'Senha deve ter ao menos 6 caracteres' })
|
||||
|
||||
const tenantId = req.tenant?.id || 1
|
||||
const user = await User.findByCpfPhone(cpf, telefone, tenantId)
|
||||
if (!user) return res.status(401).json({ error: 'Dados não encontrados.' })
|
||||
if (user.password_hash) return res.status(400).json({ error: 'Conta já migrada. Faça login com e-mail e senha.' })
|
||||
|
||||
const emailExists = await User.findByEmailAndTenant(email, tenantId)
|
||||
if (emailExists && emailExists.id !== user.id) {
|
||||
return res.status(409).json({ error: 'Este e-mail já está em uso por outra conta.' })
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 10)
|
||||
await execute(
|
||||
`UPDATE users SET email=$1, password_hash=$2 WHERE id=$3`,
|
||||
[email, password_hash, user.id]
|
||||
)
|
||||
|
||||
const token = signUserToken({ ...user, email }, tenantId)
|
||||
setUserCookies(res, token)
|
||||
return res.json({ success: true, redirect: '/area-do-usuario' })
|
||||
} catch (e) {
|
||||
console.error('Erro migrate:', e)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
},
|
||||
|
||||
// ── Dados do usuário logado ────────────────────────────────────────────────
|
||||
async getMe(req, res) {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ error: 'Não autorizado' })
|
||||
const user = await queryOne(
|
||||
'SELECT id, nome, email, cpf, telefone, whatsapp_optin FROM users WHERE id=$1',
|
||||
[req.user.id]
|
||||
)
|
||||
if (!user) return res.status(404).json({ error: 'Usuário não encontrado' })
|
||||
return res.json(user)
|
||||
} catch { return res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
async updateMe(req, res) {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ error: 'Não autorizado' })
|
||||
const { nome, email, telefone } = req.body
|
||||
if (!nome?.trim()) return res.status(400).json({ error: 'Nome é obrigatório' })
|
||||
const updated = await queryOne(
|
||||
`UPDATE users SET nome=$1, email=$2, telefone=$3, updated_at=NOW()
|
||||
WHERE id=$4 RETURNING id, nome, email, cpf, telefone, whatsapp_optin`,
|
||||
[nome.trim(), email?.trim() || null, telefone?.trim() || null, req.user.id]
|
||||
)
|
||||
return res.json(updated)
|
||||
} catch { return res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
async updateOptin(req, res) {
|
||||
try {
|
||||
if (!req.user) return res.status(401).json({ error: 'Não autorizado' })
|
||||
// Aceita tanto { whatsapp_optin } (front-end) quanto { optin } (legado)
|
||||
const optin = req.body.whatsapp_optin ?? req.body.optin
|
||||
if (typeof optin !== 'boolean') return res.status(400).json({ error: 'Campo "whatsapp_optin" (boolean) obrigatório' })
|
||||
await execute('UPDATE users SET whatsapp_optin=$1 WHERE id=$2', [optin, req.user.id])
|
||||
return res.json({ ok: true, whatsapp_optin: optin })
|
||||
} catch { return res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
// ── Notificações ───────────────────────────────────────────────────────────
|
||||
async getMyNotifications(req, res) {
|
||||
try {
|
||||
if (!req.user?.cpf) return res.status(401).json({ error: 'Não autorizado' })
|
||||
const notifs = await Notification.getUserNotifications(req.user.cpf)
|
||||
const unreadCount = await Notification.getUnreadCount(req.user.cpf)
|
||||
return res.json({ unread: unreadCount, notifications: notifs.rows || notifs })
|
||||
} catch { res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
async markNotificationRead(req, res) {
|
||||
try {
|
||||
if (!req.user?.cpf) return res.status(401).json({ error: 'Não autorizado' })
|
||||
await Notification.markAsRead(req.params.id, req.user.cpf)
|
||||
return res.json({ success: true })
|
||||
} catch { res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
// ── Resgates ───────────────────────────────────────────────────────────────
|
||||
async getMyClaims(req, res) {
|
||||
try {
|
||||
if (!req.user?.cpf) return res.status(401).json({ error: 'Não autorizado' })
|
||||
const claims = await Claim.getUserClaims(req.user.cpf)
|
||||
return res.json(claims.rows || claims)
|
||||
} catch { res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
async createClaim(req, res) {
|
||||
try {
|
||||
if (!req.user?.cpf) return res.status(401).json({ error: 'Não autorizado' })
|
||||
const { raffle_id, ticket_id, delivery_address, delivery_notes } = req.body
|
||||
if (!raffle_id || !ticket_id) return res.status(400).json({ error: 'Faltam dados da rifa' })
|
||||
|
||||
const claim = await Claim.create({ raffle_id, ticket_id, user_cpf: req.user.cpf, delivery_address, delivery_notes })
|
||||
const claimId = claim.rows?.[0]?.id ?? claim.lastInsertRowid
|
||||
const details = await Claim.getClaimDetails(claimId)
|
||||
|
||||
if (details) {
|
||||
const u = await queryOne('SELECT telefone, nome FROM users WHERE cpf=$1', [req.user.cpf])
|
||||
NotificationService.notifyClaimCreated(u?.telefone, req.user.cpf, u?.nome, details.raffle_titulo, details.ticket_numero).catch(() => {})
|
||||
}
|
||||
return res.json({ success: true, id: claimId })
|
||||
} catch { res.status(500).json({ error: 'Erro interno' }) }
|
||||
},
|
||||
|
||||
/**
|
||||
* Captive portal: verifica se o email/telefone já tem cadastro completo.
|
||||
* Usado pelo portal local antes de redirecionar para a landing page.
|
||||
* Protegido por X-Portal-Key — não expõe dados sensíveis.
|
||||
*/
|
||||
async checkPortalUser(req, res) {
|
||||
try {
|
||||
const { email, telefone } = req.body
|
||||
if (!email && !telefone) return res.json({ found: false })
|
||||
|
||||
const tenantId = req.tenant?.id || 1
|
||||
let user = null
|
||||
|
||||
if (email) {
|
||||
user = await User.findByEmailAndTenant(email.toLowerCase().trim(), tenantId)
|
||||
}
|
||||
|
||||
if (!user && telefone) {
|
||||
const cleanTel = telefone.replace(/\D/g, '')
|
||||
// busca só por telefone quando não tem email ou email não bateu
|
||||
user = await queryOne(
|
||||
`SELECT id, nome, email FROM users WHERE REGEXP_REPLACE(telefone,'[^0-9]','','g') = $1 AND tenant_id = $2 LIMIT 1`,
|
||||
[cleanTel, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return res.json({ found: false })
|
||||
|
||||
// needs_password: lead do hotspot ainda não criou senha — site pode oferecer ativação
|
||||
return res.json({ found: true, nome: user.nome, needs_password: !user.password_hash })
|
||||
} catch (err) {
|
||||
console.error('checkPortalUser error:', err.message)
|
||||
// Em caso de erro, retorna found:false para não bloquear o portal
|
||||
return res.json({ found: false })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = usersController
|
||||
@@ -0,0 +1,78 @@
|
||||
'use strict'
|
||||
|
||||
const Order = require('../models/Order')
|
||||
const Ticket = require('../models/Ticket')
|
||||
const WhatsappCore = require('../services/whatsappService')
|
||||
|
||||
const ASAAS_WEBHOOK_TOKEN = process.env.ASAAS_WEBHOOK_TOKEN
|
||||
|
||||
const webhookController = {
|
||||
async asaasWebhook(req, res) {
|
||||
try {
|
||||
if (!ASAAS_WEBHOOK_TOKEN) {
|
||||
console.error('[Webhook Asaas] ASAAS_WEBHOOK_TOKEN não configurado')
|
||||
return res.status(500).json({ error: 'Configuração incompleta' })
|
||||
}
|
||||
const incomingToken = req.headers['asaas-access-token']
|
||||
if (!incomingToken || incomingToken !== ASAAS_WEBHOOK_TOKEN) {
|
||||
console.warn('[Webhook Asaas] Token inválido rejeitado')
|
||||
return res.status(401).json({ error: 'Não autorizado' })
|
||||
}
|
||||
|
||||
const { event, payment } = req.body
|
||||
|
||||
if (!event || !payment) {
|
||||
return res.status(400).json({ error: 'Payload inválido' })
|
||||
}
|
||||
|
||||
console.log(`[Webhook Asaas] Recebido evento: ${event} para o pagamento ${payment.id}`)
|
||||
|
||||
if (event === 'PAYMENT_CONFIRMED' || event === 'PAYMENT_RECEIVED') {
|
||||
const order = await Order.findByAsaasId(payment.id)
|
||||
if (!order) {
|
||||
console.error(`[Webhook Asaas] Pedido não encontrado para payment.id: ${payment.id}`)
|
||||
return res.status(404).json({ error: 'Pedido não encontrado' })
|
||||
}
|
||||
|
||||
if (order.status !== 'paid') {
|
||||
await Order.updateStatus(order.id, 'paid')
|
||||
await Ticket.confirmOrder(order.id)
|
||||
|
||||
// Fase 7: Dispara Confirmação de Compra pelo WhatsApp via BullMQ Fila
|
||||
const getTickets = await Ticket.getByOrderId(order.id)
|
||||
if (getTickets && getTickets.length > 0) {
|
||||
const userPhone = getTickets[0].usuario_telefone
|
||||
const numerosComprados = getTickets.map(t => t.numero).join(', ')
|
||||
|
||||
WhatsappCore.sendNotification({
|
||||
userId: null,
|
||||
phone: userPhone,
|
||||
type: 'purchase_confirmed',
|
||||
variables: {
|
||||
numeros: numerosComprados,
|
||||
valor: parseFloat(order.total_amount).toFixed(2),
|
||||
link: `${process.env.APP_URL || 'http://alemaoconveniencias.local'}/meus-premios`
|
||||
}
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
console.log(`[Webhook Asaas] Pagamento confirmado! Pedido ${order.id} status atualizado para 'paid' e tickets garantidos.`)
|
||||
} else {
|
||||
console.log(`[Webhook Asaas] Pedido ${order.id} já estava marcado como 'paid'.`)
|
||||
}
|
||||
} else if (event === 'PAYMENT_DELETED' || event === 'PAYMENT_OVERDUE') {
|
||||
const order = await Order.findByAsaasId(payment.id)
|
||||
if (order && order.status !== 'paid') {
|
||||
await Order.updateStatus(order.id, event === 'PAYMENT_DELETED' ? 'canceled' : 'expired')
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('[Webhook Asaas] Erro no processamento:', error)
|
||||
return res.status(500).json({ error: 'Erro interno no processamento do webhook' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = webhookController
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict'
|
||||
|
||||
const { query } = require('../database/postgres')
|
||||
|
||||
const whatsappController = {
|
||||
async getLogs(req, res) {
|
||||
try {
|
||||
// Busca os logs de disparos unindo com o nome do usuário se disponível
|
||||
const logs = await query(`
|
||||
SELECT
|
||||
l.*,
|
||||
u.nome as user_name,
|
||||
u.telefone as user_phone
|
||||
FROM whatsapp_logs l
|
||||
LEFT JOIN users u ON l.user_cpf = u.cpf
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 100
|
||||
`)
|
||||
|
||||
// Mapeia para o formato que o React espera
|
||||
const formatted = logs.map(log => ({
|
||||
id: log.id,
|
||||
status: log.status, // sent, failed, pending
|
||||
type: log.message_type || 'notification',
|
||||
payload: log.payload || {},
|
||||
created_at: log.created_at,
|
||||
user: {
|
||||
name: log.user_name || 'Sistema',
|
||||
phone: log.user_phone || log.phone_target || '-'
|
||||
}
|
||||
}))
|
||||
|
||||
return res.json(formatted)
|
||||
} catch (error) {
|
||||
console.error('Erro ao buscar logs do WhatsApp:', error)
|
||||
return res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = whatsappController
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* migrate-sqlite-to-pg.js
|
||||
* Migra os dados existentes do database.sqlite para o PostgreSQL.
|
||||
* Execute UMA ÚNICA VEZ: node database/migrate-sqlite-to-pg.js
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const { Pool } = require('pg')
|
||||
|
||||
// ── Configuração PostgreSQL ──────────────────────────────────────
|
||||
const pool = new Pool({
|
||||
host: process.env.PG_HOST || 'localhost',
|
||||
port: parseInt(process.env.PG_PORT || '5432'),
|
||||
database: process.env.PG_DATABASE || 'alemao',
|
||||
user: process.env.PG_USER || 'newwhats',
|
||||
password: process.env.PG_PASSWORD || 'newwhats123',
|
||||
})
|
||||
|
||||
// ── Load SQLite via sql.js ───────────────────────────────────────
|
||||
const DB_PATH = path.resolve(__dirname, '..', 'database.sqlite')
|
||||
|
||||
async function migrate() {
|
||||
console.log('\n🔄 Iniciando migração SQLite → PostgreSQL...\n')
|
||||
|
||||
if (!fs.existsSync(DB_PATH)) {
|
||||
console.error('❌ database.sqlite não encontrado em:', DB_PATH)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const initSqlJs = require('sql.js')
|
||||
const SQL = await initSqlJs()
|
||||
const fileBuffer = fs.readFileSync(DB_PATH)
|
||||
const db = new SQL.Database(fileBuffer)
|
||||
|
||||
const client = await pool.connect()
|
||||
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
|
||||
// ── Admins ──────────────────────────────────────────────────
|
||||
console.log('📋 Migrando admins...')
|
||||
const admins = db.exec('SELECT * FROM admins')[0]
|
||||
if (admins) {
|
||||
for (const row of admins.values) {
|
||||
const [id, username, password_hash, nome, email, telefone] = row
|
||||
await client.query(
|
||||
`INSERT INTO admins (id, username, password_hash, nome, email, telefone)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (username) DO NOTHING`,
|
||||
[id, username, password_hash, nome || null, email || null, telefone || null]
|
||||
)
|
||||
}
|
||||
// Sincronizar sequence
|
||||
await client.query(`SELECT setval('admins_id_seq', (SELECT MAX(id) FROM admins))`)
|
||||
console.log(` ✅ ${admins.values.length} admins migrados`)
|
||||
}
|
||||
|
||||
// ── Users ───────────────────────────────────────────────────
|
||||
console.log('📋 Migrando users...')
|
||||
const users = db.exec('SELECT * FROM users')[0]
|
||||
if (users) {
|
||||
let count = 0
|
||||
for (const row of users.values) {
|
||||
// Colunas reais: id, nome, cpf, telefone, ip, timestamp, source, email
|
||||
const [id, nome, cpf, telefone, ip, timestamp, source, email] = row
|
||||
await client.query(
|
||||
`INSERT INTO users (id, nome, email, cpf, telefone, ip, source, timestamp)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`,
|
||||
[id, nome, email || null, cpf || null, telefone || null, ip || null, source || 'hotspot', timestamp || new Date()]
|
||||
)
|
||||
count++
|
||||
}
|
||||
await client.query(`SELECT setval('users_id_seq', (SELECT MAX(id) FROM users))`)
|
||||
console.log(` ✅ ${count} users migrados`)
|
||||
}
|
||||
|
||||
// ── Raffles ─────────────────────────────────────────────────
|
||||
console.log('📋 Migrando raffles...')
|
||||
const raffles = db.exec('SELECT * FROM raffles')[0]
|
||||
if (raffles) {
|
||||
for (const row of raffles.values) {
|
||||
const [id, titulo, descricao, preco_numero, total_numeros, ativo, data_criacao] = row
|
||||
await client.query(
|
||||
`INSERT INTO raffles (id, titulo, descricao, preco_numero, total_numeros, ativo, data_criacao)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
|
||||
[id, titulo, descricao || null, preco_numero || 5.0, total_numeros || 500, ativo, data_criacao || new Date()]
|
||||
)
|
||||
}
|
||||
await client.query(`SELECT setval('raffles_id_seq', (SELECT MAX(id) FROM raffles))`)
|
||||
console.log(` ✅ ${raffles.values.length} sorteios migrados`)
|
||||
}
|
||||
|
||||
// ── Tickets ─────────────────────────────────────────────────
|
||||
console.log('📋 Migrando tickets...')
|
||||
const tickets = db.exec('SELECT * FROM tickets')[0]
|
||||
if (tickets) {
|
||||
for (const row of tickets.values) {
|
||||
const [id, raffle_id, numero, usuario_nome, usuario_telefone, usuario_cpf, data_compra] = row
|
||||
await client.query(
|
||||
`INSERT INTO tickets (id, raffle_id, numero, usuario_nome, usuario_telefone, usuario_cpf, data_compra)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`,
|
||||
[id, raffle_id, numero, usuario_nome, usuario_telefone || null, usuario_cpf || null, data_compra || new Date()]
|
||||
)
|
||||
}
|
||||
await client.query(`SELECT setval('tickets_id_seq', (SELECT MAX(id) FROM tickets))`)
|
||||
console.log(` ✅ ${tickets.values.length} tickets migrados`)
|
||||
}
|
||||
|
||||
// ── Promotions ──────────────────────────────────────────────
|
||||
console.log('📋 Migrando promotions...')
|
||||
const promos = db.exec('SELECT * FROM promotions')[0]
|
||||
if (promos) {
|
||||
for (const row of promos.values) {
|
||||
const [id, nome, descricao, preco_original, preco_promocional, imagem, ativo, data_criacao] = row
|
||||
await client.query(
|
||||
`INSERT INTO promotions (id, nome, descricao, preco_original, preco_promocional, imagem, ativo, data_criacao)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT DO NOTHING`,
|
||||
[id, nome, descricao || null, preco_original || null, preco_promocional, imagem || '', ativo, data_criacao || new Date()]
|
||||
)
|
||||
}
|
||||
await client.query(`SELECT setval('promotions_id_seq', (SELECT MAX(id) FROM promotions))`)
|
||||
console.log(` ✅ ${promos.values.length} promoções migradas`)
|
||||
}
|
||||
|
||||
await client.query('COMMIT')
|
||||
console.log('\n🎉 Migração concluída com sucesso!\n')
|
||||
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
console.error('\n❌ Migração falhou — rollback executado:', err.message)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
client.release()
|
||||
await pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
migrate()
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict'
|
||||
|
||||
const { Pool } = require('pg')
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.PG_HOST || 'localhost',
|
||||
port: parseInt(process.env.PG_PORT || '5432'),
|
||||
database: process.env.PG_DATABASE || 'alemao',
|
||||
user: process.env.PG_USER || 'newwhats',
|
||||
password: process.env.PG_PASSWORD || 'newwhats123',
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
})
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('❌ Erro inesperado no pool PostgreSQL:', err)
|
||||
})
|
||||
|
||||
/**
|
||||
* Executa uma query SELECT e retorna todas as linhas.
|
||||
*/
|
||||
async function query(sql, params = []) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const result = await client.query(sql, params)
|
||||
return result.rows
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa uma query SELECT e retorna apenas a primeira linha.
|
||||
*/
|
||||
async function queryOne(sql, params = []) {
|
||||
const rows = await query(sql, params)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa INSERT/UPDATE/DELETE e retorna o resultado.
|
||||
*/
|
||||
async function execute(sql, params = []) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
const result = await client.query(sql, params)
|
||||
return {
|
||||
rowCount: result.rowCount,
|
||||
rows: result.rows,
|
||||
lastInsertRowid: result.rows[0]?.id || null,
|
||||
}
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executa múltiplas queries em uma única transação atômica.
|
||||
* Uso: await transaction(async (client) => { await client.query(...) })
|
||||
*/
|
||||
async function transaction(fn) {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
const result = await fn(client)
|
||||
await client.query('COMMIT')
|
||||
return result
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { pool, query, queryOne, execute, transaction }
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Schema PostgreSQL — Alemão Conveniências
|
||||
-- Fase 1: migração do sql.js para PostgreSQL
|
||||
|
||||
-- Extensões
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- ADMINS
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
nome TEXT,
|
||||
email TEXT,
|
||||
telefone TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- USERS (compradores / leads)
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nome TEXT NOT NULL,
|
||||
email TEXT UNIQUE,
|
||||
cpf TEXT,
|
||||
telefone TEXT,
|
||||
ip TEXT,
|
||||
source TEXT DEFAULT 'hotspot',
|
||||
password_hash TEXT,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- RAFFLES (sorteios)
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS raffles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
titulo TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
preco_numero NUMERIC(10,2) DEFAULT 5.00,
|
||||
total_numeros INTEGER DEFAULT 500,
|
||||
status TEXT DEFAULT 'active' CHECK (status IN ('draft','active','finished')),
|
||||
ativo INTEGER DEFAULT 1, -- compatibilidade com código existente
|
||||
data_criacao TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- TICKETS (números comprados)
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
raffle_id INTEGER NOT NULL REFERENCES raffles(id) ON DELETE CASCADE,
|
||||
numero INTEGER NOT NULL,
|
||||
usuario_nome TEXT NOT NULL,
|
||||
usuario_telefone TEXT,
|
||||
usuario_cpf TEXT,
|
||||
data_compra TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (raffle_id, numero)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_raffle ON tickets(raffle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_cpf ON tickets(usuario_cpf);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- PROMOTIONS
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS promotions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nome TEXT NOT NULL,
|
||||
descricao TEXT,
|
||||
preco_original NUMERIC(10,2),
|
||||
preco_promocional NUMERIC(10,2) NOT NULL,
|
||||
imagem TEXT DEFAULT '',
|
||||
ativo INTEGER DEFAULT 1,
|
||||
data_criacao TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- PLUGINS
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
active INTEGER DEFAULT 0,
|
||||
installed_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS plugin_configs (
|
||||
plugin_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
PRIMARY KEY (plugin_id, key)
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- RAFFLE RESULTS & LOGS (Fase 2)
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE raffles ADD COLUMN IF NOT EXISTS start_at TIMESTAMPTZ;
|
||||
ALTER TABLE raffles ADD COLUMN IF NOT EXISTS end_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raffle_results (
|
||||
id SERIAL PRIMARY KEY,
|
||||
raffle_id INTEGER NOT NULL REFERENCES raffles(id) ON DELETE CASCADE UNIQUE,
|
||||
winner_ticket_id INTEGER NOT NULL REFERENCES tickets(id),
|
||||
seed TEXT NOT NULL,
|
||||
algorithm_version TEXT DEFAULT '1.0',
|
||||
drawn_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raffle_draw_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
raffle_id INTEGER NOT NULL REFERENCES raffles(id) ON DELETE CASCADE,
|
||||
seed TEXT,
|
||||
result_number INTEGER,
|
||||
executed_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
ip TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- FASE 3: Integração Asaas + PIX e Reservas
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Tabela Orders (Pedidos/Cobranças Asaas)
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id TEXT PRIMARY KEY, -- Asaas Payment / Charge ID (ex: pay_123456)
|
||||
customer_id TEXT, -- Asaas Customer ID (ex: cus_00000)
|
||||
user_cpf TEXT NOT NULL, -- Usado para vincular pagamentos a usuários
|
||||
total_amount NUMERIC(10,2) NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'canceled', 'expired')),
|
||||
payment_method TEXT DEFAULT 'PIX',
|
||||
qr_code TEXT,
|
||||
qr_code_url TEXT,
|
||||
pix_copy_paste TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Ajustes na tabela tickets para suportar reservas
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'sold' CHECK (status IN ('reserved', 'sold'));
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS reserved_until TIMESTAMPTZ;
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS order_id TEXT REFERENCES orders(id) ON DELETE SET NULL;
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS price_paid NUMERIC(10,2);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- FASE 4: Preço Dinâmico
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raffle_price_rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
raffle_id INTEGER NOT NULL REFERENCES raffles(id) ON DELETE CASCADE,
|
||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('fixed', 'time_range')),
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
start_at TIMESTAMPTZ, -- Usado apenas se rule_type = 'time_range'
|
||||
end_at TIMESTAMPTZ, -- Usado apenas se rule_type = 'time_range'
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS price_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
raffle_id INTEGER NOT NULL REFERENCES raffles(id) ON DELETE CASCADE,
|
||||
order_id TEXT REFERENCES orders(id) ON DELETE SET NULL,
|
||||
user_cpf TEXT,
|
||||
price_applied NUMERIC(10,2) NOT NULL,
|
||||
rule_id INTEGER REFERENCES raffle_price_rules(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- FASE 5: Notificações e Área do Ganhador
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_cpf TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'info', -- 'info', 'win', 'alert'
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS raffle_claims (
|
||||
id SERIAL PRIMARY KEY,
|
||||
raffle_id INTEGER NOT NULL REFERENCES raffles(id) ON DELETE CASCADE,
|
||||
ticket_id INTEGER NOT NULL REFERENCES tickets(id),
|
||||
user_cpf TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'delivered')),
|
||||
delivery_address TEXT,
|
||||
delivery_notes TEXT,
|
||||
claimed_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
-- FASE 7: Logs do Motor de WhatsApp (NewWhats)
|
||||
-- ─────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS whatsapp_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
phone TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- 'purchase_confirmed', 'winner', 'last_hours', etc
|
||||
payload TEXT, -- O JSON da request final
|
||||
status TEXT DEFAULT 'queued' CHECK (status IN ('queued', 'processing', 'sent', 'failed', 'retrying')),
|
||||
response TEXT, -- Resposta de erro ou sucesso do NewWhats HTTP
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Monitor de mensagens sem resposta no WhatsApp
|
||||
*
|
||||
* Detecta chats com mensagens recebidas há mais de LIMIAR_MINUTOS
|
||||
* que não receberam resposta da IA (status='sent') nem estão em handoff humano.
|
||||
*
|
||||
* A cada ciclo:
|
||||
* - Busca chats pendentes
|
||||
* - Cria mensagem interna no setor 'atendimento' (uma por chat, sem duplicar em 30min)
|
||||
* - Emite evento SSE 'wpp.sem_resposta' para o admin
|
||||
*/
|
||||
|
||||
const { query, queryOne, execute } = require('../database/postgres')
|
||||
|
||||
const LIMIAR_MINUTOS = 5 // tempo sem resposta para considerar pendente
|
||||
const INTERVALO_MS = 5 * 60 * 1000 // roda a cada 5 minutos
|
||||
const DEDUP_MINUTOS = 30 // não gera alerta duplicado no mesmo chat em 30min
|
||||
|
||||
let timer = null
|
||||
|
||||
async function checarMsgsSemResposta() {
|
||||
try {
|
||||
// Chats com mensagem recebida há mais de LIMIAR_MINUTOS sem reply enviado
|
||||
// e que não estão em handoff humano ativo
|
||||
const pendentes = await query(`
|
||||
SELECT DISTINCT ON (el.chat_id)
|
||||
el.chat_id,
|
||||
el.created_at AS ultima_msg_em,
|
||||
el.payload->>'body' AS ultima_msg,
|
||||
el.payload->>'pushName' AS push_name,
|
||||
el.payload->>'author' AS author,
|
||||
EXTRACT(EPOCH FROM (NOW() - el.created_at)) / 60 AS minutos_aguardando
|
||||
FROM nw_event_logs el
|
||||
WHERE el.direction = 'in'
|
||||
AND el.event = 'message.new'
|
||||
AND el.created_at > NOW() - INTERVAL '2 hours'
|
||||
AND el.created_at < NOW() - ($1 || ' minutes')::INTERVAL
|
||||
-- sem resposta da IA depois desta mensagem
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM nw_auto_replies ar
|
||||
WHERE ar.chat_id = el.chat_id
|
||||
AND ar.status = 'sent'
|
||||
AND ar.created_at >= el.created_at
|
||||
)
|
||||
-- não está em handoff humano ativo
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM nw_handoff h
|
||||
WHERE h.chat_id = el.chat_id
|
||||
AND h.human_until > NOW()
|
||||
)
|
||||
-- sem Human API pendente para este chat (operador já está sendo acionado)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM human_api_requests har
|
||||
WHERE har.chat_id = el.chat_id
|
||||
AND har.status = 'pending'
|
||||
)
|
||||
ORDER BY el.chat_id, el.created_at DESC
|
||||
`, [LIMIAR_MINUTOS])
|
||||
|
||||
if (!pendentes.length) return
|
||||
|
||||
for (const p of pendentes) {
|
||||
// Dedup por chat_id — não repete alerta para o mesmo chat em DEDUP_MINUTOS
|
||||
const jaAlertado = await queryOne(`
|
||||
SELECT id FROM mensagens_internas
|
||||
WHERE setor = 'atendimento'
|
||||
AND remetente = 'Monitor IA'
|
||||
AND chat_id = $1
|
||||
AND created_at > NOW() - ($2 || ' minutes')::INTERVAL
|
||||
LIMIT 1
|
||||
`, [p.chat_id, DEDUP_MINUTOS])
|
||||
|
||||
if (jaAlertado) continue
|
||||
|
||||
const mins = Math.round(Number(p.minutos_aguardando))
|
||||
const preview = (p.ultima_msg || '').slice(0, 80)
|
||||
|
||||
// Monta identificador legível: nome > número formatado > UUID curto
|
||||
let clienteId = p.push_name?.trim() || ''
|
||||
if (!clienteId && p.author) {
|
||||
const num = p.author.replace('@s.whatsapp.net', '').replace('@c.us', '')
|
||||
if (/^\d+$/.test(num)) {
|
||||
clienteId = `+${num.replace(/^55(\d{2})(\d{4,5})(\d{4})$/, '55 ($1) $2-$3')}`
|
||||
}
|
||||
}
|
||||
if (!clienteId) clienteId = p.chat_id.slice(0, 8) + '…'
|
||||
|
||||
await execute(`
|
||||
INSERT INTO mensagens_internas (tenant_id, setor, remetente, chat_id, mensagem)
|
||||
VALUES (1, 'atendimento', 'Monitor IA', $1, $2)
|
||||
`, [
|
||||
p.chat_id,
|
||||
`⚠️ Sem resposta há ${mins} min — ${clienteId}\n"${preview}"`,
|
||||
])
|
||||
|
||||
console.log(`[monitor-wpp] ⚠️ ${clienteId} (${p.chat_id}) sem resposta há ${mins}min`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[monitor-wpp] Erro:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna os chats atualmente pendentes (usado pelo endpoint de admin).
|
||||
*/
|
||||
async function getPendentes(limitMinutos = LIMIAR_MINUTOS) {
|
||||
return query(`
|
||||
SELECT DISTINCT ON (el.chat_id)
|
||||
el.chat_id,
|
||||
el.created_at AS ultima_msg_em,
|
||||
el.payload->>'body' AS ultima_msg,
|
||||
el.payload->>'pushName' AS push_name,
|
||||
el.payload->>'author' AS author,
|
||||
ROUND(EXTRACT(EPOCH FROM (NOW() - el.created_at)) / 60) AS minutos_aguardando,
|
||||
(SELECT ar2.reply
|
||||
FROM nw_auto_replies ar2
|
||||
WHERE ar2.chat_id = el.chat_id
|
||||
ORDER BY ar2.created_at DESC LIMIT 1
|
||||
) AS ultima_reply_ia,
|
||||
(SELECT ar2.status
|
||||
FROM nw_auto_replies ar2
|
||||
WHERE ar2.chat_id = el.chat_id
|
||||
ORDER BY ar2.created_at DESC LIMIT 1
|
||||
) AS ultima_reply_status
|
||||
FROM nw_event_logs el
|
||||
WHERE el.direction = 'in'
|
||||
AND el.event = 'message.new'
|
||||
AND el.created_at > NOW() - INTERVAL '2 hours'
|
||||
AND el.created_at < NOW() - ($1 || ' minutes')::INTERVAL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM nw_auto_replies ar
|
||||
WHERE ar.chat_id = el.chat_id
|
||||
AND ar.status = 'sent'
|
||||
AND ar.created_at >= el.created_at
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM nw_handoff h
|
||||
WHERE h.chat_id = el.chat_id
|
||||
AND h.human_until > NOW()
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM human_api_requests har
|
||||
WHERE har.chat_id = el.chat_id
|
||||
AND har.status = 'pending'
|
||||
)
|
||||
ORDER BY el.chat_id, el.created_at DESC
|
||||
`, [limitMinutos])
|
||||
}
|
||||
|
||||
function startMonitorJob() {
|
||||
console.log(`[monitor-wpp] Job iniciado — verifica a cada ${INTERVALO_MS / 60000}min`)
|
||||
checarMsgsSemResposta()
|
||||
timer = setInterval(checarMsgsSemResposta, INTERVALO_MS)
|
||||
}
|
||||
|
||||
function stopMonitorJob() {
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
module.exports = { startMonitorJob, stopMonitorJob, getPendentes }
|
||||
@@ -0,0 +1,114 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* FUT-02 — Job de Recompra Automática PJ
|
||||
* Roda diariamente às 07:00.
|
||||
* Para cada pedido_recorrente com proximo_em <= hoje, cria uma nova cotação
|
||||
* e converte para pedido automaticamente.
|
||||
*/
|
||||
|
||||
const PedidoRecorrente = require('../models/PedidoRecorrente')
|
||||
const Cotacao = require('../models/Cotacao')
|
||||
const Produto = require('../models/Produto')
|
||||
|
||||
const TENANT_ID = parseInt(process.env.TENANT_ID || '1', 10)
|
||||
|
||||
async function runRecorrentes() {
|
||||
console.log('[recorrentes] Verificando pedidos recorrentes vencidos…')
|
||||
let processados = 0, erros = 0
|
||||
|
||||
try {
|
||||
const lista = await PedidoRecorrente.listDue(TENANT_ID)
|
||||
if (!lista.length) {
|
||||
console.log('[recorrentes] Nenhum pedido vencido hoje.')
|
||||
return
|
||||
}
|
||||
|
||||
for (const rec of lista) {
|
||||
try {
|
||||
// Resolver preços atuais dos produtos
|
||||
const itens = Array.isArray(rec.itens) ? rec.itens : JSON.parse(rec.itens || '[]')
|
||||
let total = 0
|
||||
const itensResolvidos = []
|
||||
|
||||
for (const item of itens) {
|
||||
const prod = item.produto_id
|
||||
? await Produto.findById(item.produto_id, TENANT_ID).catch(() => null)
|
||||
: await Produto.findBySku(item.sku, TENANT_ID).catch(() => null)
|
||||
|
||||
if (!prod) {
|
||||
console.warn(`[recorrentes] Produto não encontrado: ${item.produto_id || item.sku}`)
|
||||
continue
|
||||
}
|
||||
const preco = parseFloat(prod.preco || 0)
|
||||
const subtotal = preco * item.quantidade
|
||||
total += subtotal
|
||||
itensResolvidos.push({ ...item, preco_unitario: preco, subtotal })
|
||||
}
|
||||
|
||||
if (!itensResolvidos.length) {
|
||||
console.warn(`[recorrentes] Recorrente #${rec.id} sem itens válidos — pulando.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Criar cotação
|
||||
const cotacao = await Cotacao.create({
|
||||
tenant_id: TENANT_ID,
|
||||
cliente_id: rec.cliente_id || null,
|
||||
user_id: rec.user_id,
|
||||
status: 'rascunho',
|
||||
total,
|
||||
desconto: 0,
|
||||
observacao: `Recompra automática: ${rec.nome}`,
|
||||
})
|
||||
|
||||
// Adicionar itens
|
||||
for (const item of itensResolvidos) {
|
||||
await Cotacao.addItem({
|
||||
cotacao_id: cotacao.id,
|
||||
produto_id: item.produto_id || null,
|
||||
nome_produto: item.nome_produto || item.nome || '—',
|
||||
sku: item.sku || null,
|
||||
quantidade: item.quantidade,
|
||||
preco_unitario: item.preco_unitario,
|
||||
preco_promocional: null,
|
||||
desconto_pct: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Converter para pedido (status aguardando)
|
||||
await Cotacao.toOrder(cotacao.id, {
|
||||
forma_pagto: 'faturado',
|
||||
tipo_entrega: 'entrega',
|
||||
observacao: `Recompra automática: ${rec.nome}`,
|
||||
total,
|
||||
})
|
||||
|
||||
// Avançar proximo_em
|
||||
await PedidoRecorrente.avancaProximo(rec.id, rec.frequencia)
|
||||
processados++
|
||||
console.log(`[recorrentes] Recorrente #${rec.id} (${rec.nome}) → pedido criado.`)
|
||||
|
||||
} catch (err) {
|
||||
erros++
|
||||
console.error(`[recorrentes] Erro no recorrente #${rec.id}:`, err.message)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[recorrentes] Erro geral:', err.message)
|
||||
}
|
||||
|
||||
console.log(`[recorrentes] Concluído — ${processados} processados, ${erros} erros.`)
|
||||
}
|
||||
|
||||
function startRecorrentesJob() {
|
||||
// Roda uma vez após 5min do boot, depois a cada 24h
|
||||
setTimeout(() => {
|
||||
runRecorrentes()
|
||||
setInterval(runRecorrentes, 24 * 60 * 60 * 1000)
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
console.log('[recorrentes] Job agendado (diário, 24h).')
|
||||
}
|
||||
|
||||
module.exports = { startRecorrentesJob, runRecorrentes }
|
||||
@@ -0,0 +1,123 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* reativacaoClientes.js — WPP-09
|
||||
*
|
||||
* Job que roda periodicamente (padrão: 1× por dia às 10h)
|
||||
* para re-engajar clientes que não fazem pedidos há N dias.
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Lista clientes inativos (sem pedido nos últimos 60 dias)
|
||||
* 2. Para cada um, envia mensagem via NewWhats
|
||||
* 3. Registra envio na tabela wpp_reativacao_log
|
||||
*
|
||||
* Ativação: chamado em server.js via setInterval ou node-cron
|
||||
*/
|
||||
|
||||
const { query, execute, queryOne } = require('../database/postgres')
|
||||
|
||||
const DIAS_INATIVO = 60 // dias sem pedido para considerar inativo
|
||||
const BATCH_SIZE = 20 // máx clientes por execução
|
||||
const INTERVALO_MS = 24 * 60 * 60 * 1000 // 24h
|
||||
const JANELA_REATIVACAO = 30 // dias entre reativações do mesmo cliente
|
||||
|
||||
async function getConfig() {
|
||||
try {
|
||||
const rows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`)
|
||||
const cfg = {}
|
||||
for (const r of rows) cfg[r.key] = r.value
|
||||
return cfg
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
async function enviarWhatsApp(motorUrl, integKey, phone, mensagem) {
|
||||
if (!motorUrl || !integKey) return false
|
||||
try {
|
||||
const res = await fetch(`${motorUrl}/api/ext/v1/inbox/55${phone}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-nw-key': integKey },
|
||||
body: JSON.stringify({ text: mensagem }),
|
||||
})
|
||||
return res.ok
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
async function runReativacao() {
|
||||
console.log('[reativacao] Iniciando job de reativação de clientes...')
|
||||
|
||||
const cfg = await getConfig()
|
||||
if (!cfg.newwhats_url || !cfg.integration_key) {
|
||||
console.warn('[reativacao] Motor não configurado — job pulado')
|
||||
return
|
||||
}
|
||||
|
||||
if (cfg.auto_reply_mode === 'off') {
|
||||
console.log('[reativacao] auto_reply_mode=off — job pulado')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Lista clientes inativos que ainda não foram reativados recentemente
|
||||
const clientes = await query(
|
||||
`SELECT c.id AS cliente_id, c.nome, ct.numero AS telefone,
|
||||
MAX(p.created_at) AS ultimo_pedido
|
||||
FROM clientes c
|
||||
JOIN cliente_telefones ct ON ct.cliente_id = c.id AND ct.aceita_whatsapp = true
|
||||
LEFT JOIN pedidos p ON p.cliente_id = c.id
|
||||
WHERE c.status = 'ativo'
|
||||
AND c.id NOT IN (
|
||||
SELECT cliente_id FROM wpp_reativacao_log
|
||||
WHERE enviada_em > NOW() - ($1 || ' days')::INTERVAL
|
||||
AND cliente_id IS NOT NULL
|
||||
)
|
||||
GROUP BY c.id, c.nome, ct.numero
|
||||
HAVING MAX(p.created_at) < NOW() - ($2 || ' days')::INTERVAL
|
||||
OR MAX(p.created_at) IS NULL
|
||||
ORDER BY ultimo_pedido ASC NULLS FIRST
|
||||
LIMIT $3`,
|
||||
[JANELA_REATIVACAO, DIAS_INATIVO, BATCH_SIZE]
|
||||
)
|
||||
|
||||
console.log(`[reativacao] ${clientes.length} clientes inativos encontrados`)
|
||||
|
||||
for (const cl of clientes) {
|
||||
const nome = cl.nome?.split(' ')[0] || 'Cliente'
|
||||
const telefone = cl.telefone.replace(/\D/g, '')
|
||||
const mensagem = `Olá, *${nome}*! 👋\nFaz um tempo que não vemos você por aqui no *Alemão Conveniências*.\n\nTemos novidades e promoções esperando por você! Bora fazer um pedido? 😊\n\nDigite *OI* para ver nossas ofertas do dia.`
|
||||
|
||||
const enviado = await enviarWhatsApp(cfg.newwhats_url, cfg.integration_key, telefone, mensagem)
|
||||
if (enviado) {
|
||||
await execute(
|
||||
`INSERT INTO wpp_reativacao_log (cliente_id, chat_id, mensagem) VALUES ($1,$2,$3)`,
|
||||
[cl.cliente_id, `55${telefone}@s.whatsapp.net`, mensagem]
|
||||
)
|
||||
console.log(`[reativacao] ✅ ${nome} (${telefone})`)
|
||||
} else {
|
||||
console.warn(`[reativacao] ⚠ Falha ao enviar para ${telefone}`)
|
||||
}
|
||||
|
||||
// Pausa entre envios para não sobrecarregar
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
}
|
||||
|
||||
console.log('[reativacao] Job concluído')
|
||||
} catch (err) {
|
||||
console.error('[reativacao] Erro inesperado:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia o job com agendamento automático.
|
||||
* @param {number} intervalMs — intervalo em ms (padrão 24h)
|
||||
* @param {number} initialDelayMs — atraso inicial antes do primeiro disparo (padrão 2min)
|
||||
*/
|
||||
function startReativacaoJob(intervalMs = INTERVALO_MS, initialDelayMs = 2 * 60 * 1000) {
|
||||
console.log(`[reativacao] Job agendado — intervalo: ${Math.round(intervalMs / 3600000)}h, delay inicial: ${initialDelayMs / 1000}s`)
|
||||
|
||||
setTimeout(() => {
|
||||
runReativacao()
|
||||
setInterval(runReativacao, intervalMs)
|
||||
}, initialDelayMs)
|
||||
}
|
||||
|
||||
module.exports = { startReativacaoJob, runReativacao }
|
||||
@@ -0,0 +1,78 @@
|
||||
'use strict'
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET
|
||||
const USER_SECRET = process.env.USER_SECRET
|
||||
if (!JWT_SECRET || !USER_SECRET) throw new Error('JWT_SECRET e USER_SECRET devem estar definidos no .env')
|
||||
|
||||
function verifyToken(req, res, next) {
|
||||
const token = req.cookies.cloud_token || req.headers['authorization']?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
if (DEBUG_AUTH) console.log('[auth] cloud_token ausente — cookies recebidos:', Object.keys(req.cookies), 'url:', req.url)
|
||||
if (req.headers['accept']?.includes('application/json')) return res.status(401).json({ error: 'Não autorizado' });
|
||||
return res.redirect('/admin');
|
||||
}
|
||||
try {
|
||||
req.admin = jwt.verify(token, JWT_SECRET);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (DEBUG_AUTH) console.log('[auth] cloud_token inválido:', err.message, 'url:', req.url)
|
||||
res.clearCookie('cloud_token');
|
||||
if (req.headers['accept']?.includes('application/json')) return res.status(401).json({ error: 'Token inválido' });
|
||||
return res.redirect('/admin');
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_AUTH = process.env.DEBUG_AUTH === 'true'
|
||||
|
||||
function verifyUserToken(req, res, next) {
|
||||
const token = req.cookies.user_token || req.headers['authorization']?.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
if (DEBUG_AUTH) console.log('[auth] user_token ausente — cookies recebidos:', Object.keys(req.cookies))
|
||||
if (req.headers['accept']?.includes('application/json')) return res.status(401).json({ error: 'Usuário não autenticado' });
|
||||
return res.redirect('/login');
|
||||
}
|
||||
try {
|
||||
const payload = jwt.verify(token, USER_SECRET);
|
||||
// Valida que o token pertence ao tenant da request (compara como número)
|
||||
const tokenTenant = Number(payload.tenant_id)
|
||||
const requestTenant = Number(req.tenant?.id)
|
||||
if (req.tenant?.id && payload.tenant_id && tokenTenant !== requestTenant) {
|
||||
if (DEBUG_AUTH) console.log('[auth] tenant mismatch — token:', tokenTenant, 'request:', requestTenant)
|
||||
return res.status(401).json({ error: 'Token inválido para este tenant' });
|
||||
}
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (err) {
|
||||
if (DEBUG_AUTH) console.log('[auth] jwt.verify falhou:', err.message)
|
||||
res.clearCookie('user_token');
|
||||
res.clearCookie('_usr'); // limpa hint cookie — frontend parará de chamar /me
|
||||
if (req.headers['accept']?.includes('application/json')) return res.status(401).json({ error: 'Sessão expirada' });
|
||||
return res.redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
// Gera token de usuário incluindo tenant_id
|
||||
function signUserToken(user, tenantId) {
|
||||
return jwt.sign(
|
||||
{ id: user.id, nome: user.nome, cpf: user.cpf, email: user.email, tenant_id: tenantId },
|
||||
USER_SECRET,
|
||||
{ expiresIn: '1d' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: verifica o header X-Portal-Key enviado pelo sistema-local (captive portal).
|
||||
* Protege endpoints que só devem ser acessíveis pelo gateway, não por usuários comuns.
|
||||
*/
|
||||
function verifyPortalKey(req, res, next) {
|
||||
const key = req.headers['x-portal-key'];
|
||||
if (!key || key !== process.env.PORTAL_API_KEY) {
|
||||
console.warn(`[auth] X-Portal-Key inválida — IP: ${req.ip}, URL: ${req.url}`);
|
||||
return res.status(401).json({ error: 'Não autorizado' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { verifyToken, verifyUserToken, signUserToken, verifyPortalKey, JWT_SECRET, USER_SECRET };
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict'
|
||||
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
const SUPER_SECRET = process.env.SUPER_ADMIN_JWT_SECRET || 'super-secret-change-in-production'
|
||||
|
||||
function signSuperToken(admin) {
|
||||
return jwt.sign(
|
||||
{ id: admin.id, email: admin.email, name: admin.name, role: 'superadmin' },
|
||||
SUPER_SECRET,
|
||||
{ expiresIn: '8h' }
|
||||
)
|
||||
}
|
||||
|
||||
function verifySuperToken(req, res, next) {
|
||||
const token = req.cookies?.super_token || req.headers['x-super-token']
|
||||
if (!token) return res.status(401).json({ error: 'Não autenticado' })
|
||||
try {
|
||||
req.superAdmin = jwt.verify(token, SUPER_SECRET)
|
||||
next()
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token inválido ou expirado' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { signSuperToken, verifySuperToken }
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* teamAuth.js — Autenticação para contas de equipe (tabela accounts)
|
||||
* Separado do auth.js que cuida dos admins (tabela admins)
|
||||
*
|
||||
* Cookie: team_token (JWT)
|
||||
* Payload: { accountId, tenantId, role, name }
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken')
|
||||
|
||||
// Secret compartilhado (mesmo process.env.JWT_SECRET) — fallbacks distintos por tipo
|
||||
const TEAM_SECRET = process.env.JWT_SECRET || 'alemao-secret-2024'
|
||||
// Admin usa o mesmo JWT_SECRET mas com fallback diferente se não configurado — importa de auth.js
|
||||
const { JWT_SECRET: ADMIN_SECRET } = require('./auth')
|
||||
|
||||
/**
|
||||
* verifyTeamToken — middleware que exige cookie team_token válido
|
||||
* Injeta req.teamAccount = { accountId, tenantId, role, name }
|
||||
*/
|
||||
function verifyTeamToken(req, res, next) {
|
||||
const token = req.cookies?.team_token
|
||||
if (!token) return res.status(401).json({ error: 'Não autenticado' })
|
||||
try {
|
||||
const payload = jwt.verify(token, TEAM_SECRET)
|
||||
req.teamAccount = payload
|
||||
if (!req.tenantId) req.tenantId = payload.tenantId
|
||||
next()
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Sessão expirada' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* verifyAnyToken — aceita admin (cloud_token) OU equipe (team_token)
|
||||
* SEGURANÇA:
|
||||
* - Admin: verifica com ADMIN_SECRET e popula req.admin
|
||||
* - Equipe: verifica com TEAM_SECRET e popula req.teamAccount
|
||||
* - Nunca aceita team_token quando cloud_token está presente e válido
|
||||
*/
|
||||
function verifyAnyToken(req, res, next) {
|
||||
const adminToken = req.cookies?.cloud_token
|
||||
const teamToken = req.cookies?.team_token
|
||||
|
||||
// Tenta admin primeiro — usa o secret correto e popula req.admin
|
||||
if (adminToken) {
|
||||
try {
|
||||
const payload = jwt.verify(adminToken, ADMIN_SECRET)
|
||||
req.admin = payload
|
||||
if (!req.tenantId && payload.tenant_id) req.tenantId = payload.tenant_id
|
||||
return next()
|
||||
} catch {
|
||||
// cloud_token presente mas inválido — rejeita imediatamente (evita escalada de privilégio)
|
||||
return res.status(401).json({ error: 'Token inválido ou expirado' })
|
||||
}
|
||||
}
|
||||
|
||||
// Tenta equipe apenas se não havia cloud_token
|
||||
if (teamToken) {
|
||||
try {
|
||||
const payload = jwt.verify(teamToken, TEAM_SECRET)
|
||||
req.teamAccount = payload
|
||||
if (!req.tenantId) req.tenantId = payload.tenantId
|
||||
return next()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'Não autenticado' })
|
||||
}
|
||||
|
||||
function signTeamToken(account) {
|
||||
return jwt.sign(
|
||||
{ accountId: account.id, tenantId: account.tenant_id, role: account.role, name: account.name },
|
||||
TEAM_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { verifyTeamToken, verifyAnyToken, signTeamToken }
|
||||
@@ -0,0 +1,88 @@
|
||||
'use strict'
|
||||
|
||||
const { queryOne } = require('../database/postgres')
|
||||
|
||||
// Cache simples em memória — evita query por request (TTL 60s)
|
||||
const cache = new Map()
|
||||
const CACHE_TTL = 60_000
|
||||
|
||||
async function resolveTenant(identifier) {
|
||||
const now = Date.now()
|
||||
if (cache.has(identifier)) {
|
||||
const hit = cache.get(identifier)
|
||||
if (now - hit.ts < CACHE_TTL) return hit.tenant
|
||||
cache.delete(identifier)
|
||||
}
|
||||
|
||||
const tenant = await queryOne(
|
||||
`SELECT t.*, tc.primary_color, tc.secondary_color, tc.logo_url,
|
||||
tc.favicon_url, tc.contact_email, tc.contact_phone, tc.app_url, tc.asaas_key
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_config tc ON tc.tenant_id = t.id
|
||||
WHERE (t.domain = $1 OR t.slug = $1) AND t.active = true
|
||||
LIMIT 1`,
|
||||
[identifier]
|
||||
).catch(() => null)
|
||||
|
||||
if (tenant) cache.set(identifier, { tenant, ts: now })
|
||||
return tenant
|
||||
}
|
||||
|
||||
// Middleware principal — anexa req.tenant
|
||||
async function tenantMiddleware(req, res, next) {
|
||||
try {
|
||||
// 1. Tenta pelo subdomínio (hostname sem porta)
|
||||
const hostname = (req.hostname || '').split(':')[0]
|
||||
const subdomain = hostname.split('.')[0]
|
||||
|
||||
let tenant = null
|
||||
|
||||
// Tenta pelo hostname completo primeiro (para domínios customizados)
|
||||
if (hostname && hostname !== 'localhost') {
|
||||
tenant = await resolveTenant(hostname)
|
||||
}
|
||||
|
||||
// Tenta pelo subdomínio
|
||||
if (!tenant && subdomain && subdomain !== 'localhost' && subdomain !== 'www') {
|
||||
tenant = await resolveTenant(subdomain)
|
||||
}
|
||||
|
||||
// Tenta pelo header X-Tenant-Slug (útil para dev e APIs internas)
|
||||
if (!tenant && req.headers['x-tenant-slug']) {
|
||||
tenant = await resolveTenant(req.headers['x-tenant-slug'])
|
||||
}
|
||||
|
||||
// Fallback: tenant padrão (id=1, slug=alemao) — garante funcionamento local
|
||||
if (!tenant) {
|
||||
tenant = await resolveTenant('alemao')
|
||||
}
|
||||
|
||||
if (!tenant) {
|
||||
return res.status(503).json({ error: 'Tenant não encontrado ou inativo.' })
|
||||
}
|
||||
|
||||
req.tenant = tenant
|
||||
next()
|
||||
} catch (err) {
|
||||
console.error('[tenant middleware]', err.message)
|
||||
return res.status(503).json({ error: 'Serviço temporariamente indisponível' })
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware para rotas de admin — verifica que admin pertence ao tenant
|
||||
function tenantAdminMiddleware(req, res, next) {
|
||||
if (!req.admin || !req.tenant) return next()
|
||||
// Se admin tem tenant_id definido, deve bater com o tenant da request
|
||||
if (req.admin.tenant_id && req.admin.tenant_id !== req.tenant.id) {
|
||||
return res.status(403).json({ error: 'Acesso negado a este tenant.' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// Limpar cache (útil após update de tenant_config)
|
||||
function invalidateTenantCache(identifier) {
|
||||
if (identifier) cache.delete(identifier)
|
||||
else cache.clear()
|
||||
}
|
||||
|
||||
module.exports = { tenantMiddleware, tenantAdminMiddleware, invalidateTenantCache }
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Admin {
|
||||
static findAll() {
|
||||
return query('SELECT id, username, nome, email, telefone, created_at FROM admins ORDER BY id ASC')
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return queryOne('SELECT * FROM admins WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static findByUsername(username) {
|
||||
return queryOne('SELECT * FROM admins WHERE username = $1', [username])
|
||||
}
|
||||
|
||||
static create(username, password_hash) {
|
||||
return execute(
|
||||
'INSERT INTO admins (username, password_hash) VALUES ($1, $2) RETURNING id',
|
||||
[username, password_hash]
|
||||
)
|
||||
}
|
||||
|
||||
static update(id, data) {
|
||||
const fields = []
|
||||
const values = []
|
||||
let i = 1
|
||||
|
||||
if (data.username) { fields.push(`username = $${i++}`); values.push(data.username) }
|
||||
if (data.password_hash) { fields.push(`password_hash = $${i++}`); values.push(data.password_hash) }
|
||||
if ('nome' in data) { fields.push(`nome = $${i++}`); values.push(data.nome) }
|
||||
if ('email' in data) { fields.push(`email = $${i++}`); values.push(data.email) }
|
||||
if ('telefone' in data) { fields.push(`telefone = $${i++}`); values.push(data.telefone) }
|
||||
|
||||
if (!fields.length) return Promise.resolve({ rowCount: 0 })
|
||||
values.push(id)
|
||||
return execute(`UPDATE admins SET ${fields.join(', ')} WHERE id = $${i}`, values)
|
||||
}
|
||||
|
||||
static delete(id) {
|
||||
return execute('DELETE FROM admins WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static async count() {
|
||||
const row = await queryOne('SELECT COUNT(*) as total FROM admins')
|
||||
return parseInt(row?.total || 0)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Admin
|
||||
@@ -0,0 +1,89 @@
|
||||
'use strict'
|
||||
|
||||
const { query } = require('../database/postgres')
|
||||
|
||||
function tFilter(tenantId, alias = 't') {
|
||||
if (!tenantId) return ''
|
||||
const id = parseInt(tenantId, 10)
|
||||
if (!Number.isInteger(id) || id <= 0) throw new Error('tenantId inválido')
|
||||
return `AND ${alias}.tenant_id = ${id}`
|
||||
}
|
||||
|
||||
class Analytics {
|
||||
static async getRevenueStats(raffle_id = null, tenantId = null) {
|
||||
const raffleFilter = raffle_id ? `AND r.id = $1` : ''
|
||||
const tenantFilter = tenantId ? `AND t.tenant_id = ${parseInt(tenantId)}` : ''
|
||||
const params = raffle_id ? [raffle_id] : []
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
COALESCE(SUM(t.price_paid), 0) AS total_revenue,
|
||||
COALESCE(AVG(t.price_paid), 0) AS avg_price,
|
||||
COUNT(t.id) AS total_tickets_sold
|
||||
FROM tickets t
|
||||
JOIN raffles r ON r.id = t.raffle_id
|
||||
WHERE t.status = 'sold' ${raffleFilter} ${tenantFilter}
|
||||
`
|
||||
const res = await query(sql, params)
|
||||
const row = res[0] || { total_revenue: 0, avg_price: 0, total_tickets_sold: 0 }
|
||||
|
||||
const tWhere = tenantId ? `WHERE tenant_id = ${parseInt(tenantId, 10)}` : ''
|
||||
const [usersRow] = await query(`SELECT COUNT(*) AS cnt FROM users ${tWhere}`)
|
||||
const [activeRow] = await query(
|
||||
tenantId
|
||||
? `SELECT COUNT(t.id) AS cnt FROM tickets t JOIN raffles r ON r.id = t.raffle_id WHERE r.tenant_id = ${parseInt(tenantId, 10)} AND t.status = 'sold'`
|
||||
: `SELECT COUNT(*) AS cnt FROM tickets WHERE status = 'sold'`
|
||||
)
|
||||
|
||||
row.total_users = usersRow?.cnt ?? 0
|
||||
row.active_tickets = activeRow?.cnt ?? 0
|
||||
return row
|
||||
}
|
||||
|
||||
static getSalesByDay(tenantId = null) {
|
||||
const f = tFilter(tenantId)
|
||||
return query(`
|
||||
SELECT
|
||||
DATE(t.data_compra) AS date,
|
||||
COUNT(t.id) AS tickets_sold,
|
||||
SUM(t.price_paid) AS revenue
|
||||
FROM tickets t
|
||||
WHERE t.data_compra >= NOW() - INTERVAL '30 days' AND t.status = 'sold' ${f}
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`)
|
||||
}
|
||||
|
||||
static getSalesByHour(tenantId = null) {
|
||||
const f = tFilter(tenantId)
|
||||
return query(`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM t.data_compra) AS hour_of_day,
|
||||
COUNT(t.id) AS tickets_sold,
|
||||
COALESCE(SUM(t.price_paid), 0) AS revenue
|
||||
FROM tickets t
|
||||
WHERE t.data_compra >= NOW() - INTERVAL '7 days' AND t.status = 'sold' ${f}
|
||||
GROUP BY hour_of_day
|
||||
ORDER BY hour_of_day ASC
|
||||
`)
|
||||
}
|
||||
|
||||
static getSalesByRaffle(tenantId = null) {
|
||||
const f = tenantId ? `AND r.tenant_id = ${parseInt(tenantId)}` : ''
|
||||
return query(`
|
||||
SELECT
|
||||
r.id AS raffle_id,
|
||||
r.titulo AS raffle_name,
|
||||
COUNT(t.id) AS tickets_sold,
|
||||
r.total_numeros,
|
||||
COALESCE(SUM(t.price_paid), 0) AS revenue
|
||||
FROM raffles r
|
||||
LEFT JOIN tickets t ON t.raffle_id = r.id AND t.status = 'sold'
|
||||
WHERE r.ativo = 1 ${f}
|
||||
GROUP BY r.id, r.titulo, r.total_numeros
|
||||
ORDER BY tickets_sold DESC
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Analytics
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class BenefitClub {
|
||||
static findAll(tenantId) {
|
||||
return query(
|
||||
`SELECT bc.*, COUNT(cp.id) AS plan_count
|
||||
FROM benefit_clubs bc
|
||||
LEFT JOIN club_plans cp ON cp.club_id = bc.id AND cp.active = TRUE
|
||||
WHERE bc.tenant_id = $1 AND bc.active = TRUE
|
||||
GROUP BY bc.id ORDER BY bc.id`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static findBySlug(slug, tenantId) {
|
||||
return queryOne(
|
||||
'SELECT * FROM benefit_clubs WHERE slug=$1 AND tenant_id=$2 AND active=TRUE',
|
||||
[slug, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return queryOne('SELECT * FROM benefit_clubs WHERE id=$1', [id])
|
||||
}
|
||||
|
||||
// Planos de um clube
|
||||
static getPlans(clubId) {
|
||||
return query(
|
||||
'SELECT * FROM club_plans WHERE club_id=$1 AND active=TRUE ORDER BY price_monthly',
|
||||
[clubId]
|
||||
)
|
||||
}
|
||||
|
||||
static getPlanById(planId) {
|
||||
return queryOne('SELECT * FROM club_plans WHERE id=$1', [planId])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BenefitClub
|
||||
@@ -0,0 +1,59 @@
|
||||
'use strict'
|
||||
|
||||
const { query, queryOne } = require('../database/postgres')
|
||||
|
||||
class Broadcast {
|
||||
static async send(tenantId, fromAccount, content) {
|
||||
return queryOne(
|
||||
`INSERT INTO broadcasts (tenant_id, from_account, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, from_account, content, created_at`,
|
||||
[tenantId, fromAccount, content]
|
||||
)
|
||||
}
|
||||
|
||||
static async list(tenantId, limit = 50) {
|
||||
return query(
|
||||
`SELECT b.id, b.from_account, b.content, b.created_at,
|
||||
a.name AS sender_name, a.avatar_url AS sender_avatar
|
||||
FROM broadcasts b
|
||||
LEFT JOIN accounts a ON a.id = b.from_account
|
||||
WHERE b.tenant_id = $1
|
||||
ORDER BY b.created_at DESC
|
||||
LIMIT $2`,
|
||||
[tenantId, limit]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantidade de broadcasts não vistos pelo colaborador
|
||||
*/
|
||||
static async unreadCount(tenantId, accountId) {
|
||||
const row = await queryOne(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM broadcasts b
|
||||
LEFT JOIN broadcast_last_seen bls
|
||||
ON bls.tenant_id = $1 AND bls.account_id = $2
|
||||
WHERE b.tenant_id = $1
|
||||
AND b.from_account != $2
|
||||
AND (bls.last_seen_at IS NULL OR b.created_at > bls.last_seen_at)`,
|
||||
[tenantId, accountId]
|
||||
)
|
||||
return row?.count ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar broadcasts como vistos (upsert last_seen_at)
|
||||
*/
|
||||
static async markSeen(tenantId, accountId) {
|
||||
await queryOne(
|
||||
`INSERT INTO broadcast_last_seen (account_id, tenant_id, last_seen_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (account_id, tenant_id)
|
||||
DO UPDATE SET last_seen_at = NOW()`,
|
||||
[accountId, tenantId]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Broadcast
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Claim {
|
||||
static async create({ raffle_id, ticket_id, user_cpf, delivery_address, delivery_notes }) {
|
||||
return execute(
|
||||
`INSERT INTO raffle_claims (raffle_id, ticket_id, user_cpf, delivery_address, delivery_notes)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[raffle_id, ticket_id, user_cpf, delivery_address || null, delivery_notes || null]
|
||||
)
|
||||
}
|
||||
|
||||
static async getUserClaims(user_cpf) {
|
||||
return query(
|
||||
`SELECT c.*, r.titulo as raffle_titulo, t.numero as ticket_numero
|
||||
FROM raffle_claims c
|
||||
JOIN raffles r ON r.id = c.raffle_id
|
||||
JOIN tickets t ON t.id = c.ticket_id
|
||||
WHERE c.user_cpf = $1
|
||||
ORDER BY c.claimed_at DESC`,
|
||||
[user_cpf]
|
||||
)
|
||||
}
|
||||
|
||||
static async getClaimDetails(id) {
|
||||
return queryOne(
|
||||
`SELECT c.*, r.titulo as raffle_titulo, t.numero as ticket_numero
|
||||
FROM raffle_claims c
|
||||
JOIN raffles r ON r.id = c.raffle_id
|
||||
JOIN tickets t ON t.id = c.ticket_id
|
||||
WHERE c.id = $1`,
|
||||
[id]
|
||||
)
|
||||
}
|
||||
|
||||
static async updateStatus(id, status) {
|
||||
return execute(
|
||||
`UPDATE raffle_claims SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[status, id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Claim
|
||||
@@ -0,0 +1,163 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Cliente {
|
||||
static findById(id) {
|
||||
return queryOne('SELECT * FROM clientes WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static findByCpfCnpj(cpfCnpj, tenantId) {
|
||||
return queryOne('SELECT * FROM clientes WHERE cpf_cnpj = $1 AND tenant_id = $2', [cpfCnpj, tenantId])
|
||||
}
|
||||
|
||||
static findByUserId(userId, tenantId) {
|
||||
return queryOne('SELECT * FROM clientes WHERE user_id = $1 AND tenant_id = $2', [userId, tenantId])
|
||||
}
|
||||
|
||||
static list(tenantId, { search, tipo, status, limit = 50, offset = 0 } = {}) {
|
||||
const params = [tenantId]
|
||||
let where = 'WHERE tenant_id = $1'
|
||||
if (tipo) { params.push(tipo); where += ` AND tipo = $${params.length}` }
|
||||
if (status) { params.push(status); where += ` AND status = $${params.length}` }
|
||||
if (search) {
|
||||
params.push(`%${search.replace(/[%_]/g, '\\$&')}%`)
|
||||
where += ` AND (nome ILIKE $${params.length} OR cpf_cnpj ILIKE $${params.length} OR email ILIKE $${params.length})`
|
||||
}
|
||||
params.push(Math.min(Number(limit) || 50, 200))
|
||||
params.push(Math.max(Number(offset) || 0, 0))
|
||||
return query(
|
||||
`SELECT * FROM clientes ${where} ORDER BY nome ASC LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static create({ tenant_id, tipo, nome, cpf_cnpj, email, status = 'ativo', user_id = null }) {
|
||||
return execute(
|
||||
`INSERT INTO clientes (tenant_id, tipo, nome, cpf_cnpj, email, status, user_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[tenant_id, tipo, nome, cpf_cnpj, email || null, status, user_id]
|
||||
)
|
||||
}
|
||||
|
||||
static update(id, { nome, email, status }) {
|
||||
return execute(
|
||||
`UPDATE clientes SET nome=$1, email=$2, status=$3, updated_at=NOW() WHERE id=$4`,
|
||||
[nome, email || null, status, id]
|
||||
)
|
||||
}
|
||||
|
||||
static delete(id) {
|
||||
return execute('DELETE FROM clientes WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
// ── Endereços ─────────────────────────────────────────────
|
||||
|
||||
static listEnderecos(clienteId) {
|
||||
return query('SELECT * FROM cliente_enderecos WHERE cliente_id = $1 ORDER BY principal DESC, id ASC', [clienteId])
|
||||
}
|
||||
|
||||
static createEndereco({ cliente_id, tipo, cep, rua, numero, complemento, bairro, cidade, estado, referencia, principal }) {
|
||||
return execute(
|
||||
`INSERT INTO cliente_enderecos (cliente_id,tipo,cep,rua,numero,complemento,bairro,cidade,estado,referencia,principal)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
|
||||
[cliente_id, tipo || 'entrega', cep || null, rua || null, numero || null,
|
||||
complemento || null, bairro || null, cidade || null, estado || null,
|
||||
referencia || null, principal || false]
|
||||
)
|
||||
}
|
||||
|
||||
static updateEndereco(id, { tipo, cep, rua, numero, complemento, bairro, cidade, estado, referencia, principal }) {
|
||||
return execute(
|
||||
`UPDATE cliente_enderecos SET tipo=$1,cep=$2,rua=$3,numero=$4,complemento=$5,
|
||||
bairro=$6,cidade=$7,estado=$8,referencia=$9,principal=$10 WHERE id=$11`,
|
||||
[tipo, cep || null, rua || null, numero || null, complemento || null,
|
||||
bairro || null, cidade || null, estado || null, referencia || null,
|
||||
principal || false, id]
|
||||
)
|
||||
}
|
||||
|
||||
static setEndereçoPrincipal(clienteId, enderecoId) {
|
||||
return execute(
|
||||
`UPDATE cliente_enderecos SET principal = (id = $2) WHERE cliente_id = $1`,
|
||||
[clienteId, enderecoId]
|
||||
)
|
||||
}
|
||||
|
||||
static deleteEndereco(id) {
|
||||
return execute('DELETE FROM cliente_enderecos WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
// ── Telefones ─────────────────────────────────────────────
|
||||
|
||||
static listTelefones(clienteId) {
|
||||
return query('SELECT * FROM cliente_telefones WHERE cliente_id = $1 ORDER BY principal DESC, id ASC', [clienteId])
|
||||
}
|
||||
|
||||
static createTelefone({ cliente_id, tipo, numero, ramal, aceita_whatsapp, principal }) {
|
||||
return execute(
|
||||
`INSERT INTO cliente_telefones (cliente_id,tipo,numero,ramal,aceita_whatsapp,principal)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[cliente_id, tipo || 'celular', numero, ramal || null,
|
||||
aceita_whatsapp || false, principal || false]
|
||||
)
|
||||
}
|
||||
|
||||
static updateTelefone(id, { tipo, numero, ramal, aceita_whatsapp, principal }) {
|
||||
return execute(
|
||||
`UPDATE cliente_telefones SET tipo=$1,numero=$2,ramal=$3,aceita_whatsapp=$4,principal=$5 WHERE id=$6`,
|
||||
[tipo, numero, ramal || null, aceita_whatsapp || false, principal || false, id]
|
||||
)
|
||||
}
|
||||
|
||||
static deleteTelefone(id) {
|
||||
return execute('DELETE FROM cliente_telefones WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
// ── Responsáveis ─────────────────────────────────────────
|
||||
|
||||
static listResponsaveis(clienteId) {
|
||||
return query(
|
||||
`SELECT r.*, t.numero AS telefone_numero FROM cliente_responsaveis r
|
||||
LEFT JOIN cliente_telefones t ON t.id = r.telefone_id
|
||||
WHERE r.cliente_id = $1 ORDER BY r.id ASC`,
|
||||
[clienteId]
|
||||
)
|
||||
}
|
||||
|
||||
static createResponsavel({ cliente_id, nome, cargo, setor, telefone_id, email, permissao }) {
|
||||
return execute(
|
||||
`INSERT INTO cliente_responsaveis (cliente_id,nome,cargo,setor,telefone_id,email,permissao)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[cliente_id, nome, cargo || null, setor || null,
|
||||
telefone_id || null, email || null, permissao || null]
|
||||
)
|
||||
}
|
||||
|
||||
static updateResponsavel(id, { nome, cargo, setor, telefone_id, email, permissao }) {
|
||||
return execute(
|
||||
`UPDATE cliente_responsaveis SET nome=$1,cargo=$2,setor=$3,telefone_id=$4,email=$5,permissao=$6 WHERE id=$7`,
|
||||
[nome, cargo || null, setor || null, telefone_id || null,
|
||||
email || null, permissao || null, id]
|
||||
)
|
||||
}
|
||||
|
||||
static deleteResponsavel(id) {
|
||||
return execute('DELETE FROM cliente_responsaveis WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
// ── Dados completos (cliente + endereços + telefones + responsáveis) ──────
|
||||
|
||||
static async findComplete(id) {
|
||||
const [cliente, enderecos, telefones, responsaveis] = await Promise.all([
|
||||
Cliente.findById(id),
|
||||
Cliente.listEnderecos(id),
|
||||
Cliente.listTelefones(id),
|
||||
Cliente.listResponsaveis(id),
|
||||
])
|
||||
if (!cliente) return null
|
||||
return { ...cliente, enderecos, telefones, responsaveis }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Cliente
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class ClubMember {
|
||||
static create({ tenant_id, club_id, plan_id, user_cpf, user_nome, user_telefone, user_email,
|
||||
asaas_customer_id, asaas_subscription_id, next_billing_at }) {
|
||||
return execute(
|
||||
`INSERT INTO club_members
|
||||
(tenant_id, club_id, plan_id, user_cpf, user_nome, user_telefone, user_email,
|
||||
status, asaas_customer_id, asaas_subscription_id, next_billing_at, started_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,'pending_payment',$8,$9,$10, NOW()) RETURNING id`,
|
||||
[tenant_id, club_id, plan_id,
|
||||
user_cpf.replace(/\D/g, ''), user_nome,
|
||||
user_telefone || null, user_email || null,
|
||||
asaas_customer_id || null, asaas_subscription_id || null,
|
||||
next_billing_at || null]
|
||||
)
|
||||
}
|
||||
|
||||
static findByCpfAndClub(cpf, clubId) {
|
||||
return queryOne(
|
||||
`SELECT * FROM club_members
|
||||
WHERE user_cpf=$1 AND club_id=$2 AND status NOT IN ('canceled')
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
[cpf.replace(/\D/g, ''), clubId]
|
||||
)
|
||||
}
|
||||
|
||||
static findAllByCpf(cpf) {
|
||||
return query(
|
||||
`SELECT cm.*, bc.name AS club_name, bc.slug AS club_slug,
|
||||
cp.name AS plan_name, cp.price_monthly, cp.features
|
||||
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.user_cpf = $1 AND cm.status NOT IN ('canceled')
|
||||
ORDER BY cm.id`,
|
||||
[cpf.replace(/\D/g, '')]
|
||||
)
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return queryOne(
|
||||
`SELECT cm.*, bc.name AS club_name, bc.slug AS club_slug,
|
||||
cp.name AS plan_name, cp.price_monthly
|
||||
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.id = $1`,
|
||||
[id]
|
||||
)
|
||||
}
|
||||
|
||||
static listByClub(clubId, { status, limit = 100, offset = 0 } = {}) {
|
||||
if (status) {
|
||||
return query(
|
||||
`SELECT cm.*, cp.name AS plan_name, cp.price_monthly
|
||||
FROM club_members cm
|
||||
JOIN club_plans cp ON cp.id = cm.plan_id
|
||||
WHERE cm.club_id=$1 AND cm.status=$2
|
||||
ORDER BY cm.created_at DESC LIMIT $3 OFFSET $4`,
|
||||
[clubId, status, limit, offset]
|
||||
)
|
||||
}
|
||||
return query(
|
||||
`SELECT cm.*, cp.name AS plan_name, cp.price_monthly
|
||||
FROM club_members cm
|
||||
JOIN club_plans cp ON cp.id = cm.plan_id
|
||||
WHERE cm.club_id=$1
|
||||
ORDER BY cm.created_at DESC LIMIT $2 OFFSET $3`,
|
||||
[clubId, limit, offset]
|
||||
)
|
||||
}
|
||||
|
||||
static updateStatus(id, status) {
|
||||
const extra = status === 'canceled' ? `, canceled_at=NOW()` : ''
|
||||
return execute(
|
||||
`UPDATE club_members SET status=$1${extra} WHERE id=$2`,
|
||||
[status, id]
|
||||
)
|
||||
}
|
||||
|
||||
static updateSubscription(id, { asaas_subscription_id, asaas_customer_id, next_billing_at }) {
|
||||
return execute(
|
||||
`UPDATE club_members
|
||||
SET asaas_subscription_id=$1, asaas_customer_id=$2, next_billing_at=$3
|
||||
WHERE id=$4`,
|
||||
[asaas_subscription_id, asaas_customer_id, next_billing_at || null, id]
|
||||
)
|
||||
}
|
||||
|
||||
static findBySubscriptionId(subscriptionId) {
|
||||
return queryOne(
|
||||
'SELECT * FROM club_members WHERE asaas_subscription_id=$1',
|
||||
[subscriptionId]
|
||||
)
|
||||
}
|
||||
|
||||
// Membros com pagamento em atraso há mais de N dias
|
||||
static findOverdue(daysOverdue = 5) {
|
||||
return query(
|
||||
`SELECT cm.*, cp.price_monthly
|
||||
FROM club_members cm
|
||||
JOIN club_plans cp ON cp.id = cm.plan_id
|
||||
WHERE cm.status = 'active'
|
||||
AND cm.next_billing_at < NOW() - ($1::int * INTERVAL '1 day')`,
|
||||
[parseInt(daysOverdue, 10)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClubMember
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class ClubPartner {
|
||||
static findByClub(clubId) {
|
||||
return query(
|
||||
`SELECT * FROM club_partners WHERE club_id=$1 AND active=TRUE ORDER BY name`,
|
||||
[clubId]
|
||||
)
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return queryOne('SELECT * FROM club_partners WHERE id=$1', [id])
|
||||
}
|
||||
|
||||
static create({ club_id, tenant_id, name, specialty, phone, address, discount_percent, notes }) {
|
||||
return execute(
|
||||
`INSERT INTO club_partners
|
||||
(club_id, tenant_id, name, specialty, phone, address, discount_percent, notes)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
||||
[club_id, tenant_id, name, specialty || null, phone || null,
|
||||
address || null, discount_percent || 0, notes || null]
|
||||
)
|
||||
}
|
||||
|
||||
static update(id, { name, specialty, phone, address, discount_percent, notes, active }) {
|
||||
return execute(
|
||||
`UPDATE club_partners
|
||||
SET name=$1, specialty=$2, phone=$3, address=$4,
|
||||
discount_percent=$5, notes=$6, active=$7
|
||||
WHERE id=$8`,
|
||||
[name, specialty || null, phone || null, address || null,
|
||||
discount_percent ?? 0, notes || null, active !== false, id]
|
||||
)
|
||||
}
|
||||
|
||||
static delete(id) {
|
||||
return execute('UPDATE club_partners SET active=FALSE WHERE id=$1', [id])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClubPartner
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class ClubPayment {
|
||||
static create({ member_id, plan_id, amount, status = 'pending', asaas_payment_id, reference_month }) {
|
||||
return execute(
|
||||
`INSERT INTO club_payments
|
||||
(member_id, plan_id, amount, status, asaas_payment_id, reference_month)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
[member_id, plan_id, amount, status, asaas_payment_id || null, reference_month || null]
|
||||
)
|
||||
}
|
||||
|
||||
static findByMember(memberId) {
|
||||
return query(
|
||||
`SELECT * FROM club_payments WHERE member_id=$1 ORDER BY created_at DESC`,
|
||||
[memberId]
|
||||
)
|
||||
}
|
||||
|
||||
static findByAsaasId(asaasPaymentId) {
|
||||
return queryOne(
|
||||
'SELECT * FROM club_payments WHERE asaas_payment_id=$1',
|
||||
[asaasPaymentId]
|
||||
)
|
||||
}
|
||||
|
||||
static updateStatus(id, status, paidAt = null) {
|
||||
return execute(
|
||||
`UPDATE club_payments SET status=$1, paid_at=$2 WHERE id=$3`,
|
||||
[status, paidAt, id]
|
||||
)
|
||||
}
|
||||
|
||||
static listByClub(clubId, { limit = 100, offset = 0 } = {}) {
|
||||
return query(
|
||||
`SELECT cp.*, cm.user_cpf, cm.user_nome, pl.name AS plan_name
|
||||
FROM club_payments cp
|
||||
JOIN club_members cm ON cm.id = cp.member_id
|
||||
JOIN club_plans pl ON pl.id = cp.plan_id
|
||||
WHERE cm.club_id = $1
|
||||
ORDER BY cp.created_at DESC LIMIT $2 OFFSET $3`,
|
||||
[clubId, limit, offset]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ClubPayment
|
||||
@@ -0,0 +1,218 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Cotacao {
|
||||
static findById(id) {
|
||||
return queryOne(`
|
||||
SELECT c.*,
|
||||
COALESCE(cl.nome, u.nome) AS cliente_nome,
|
||||
COALESCE(cl.tipo, 'PF') AS cliente_tipo
|
||||
FROM cotacoes c
|
||||
LEFT JOIN clientes cl ON cl.id = c.cliente_id
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.id = $1
|
||||
`, [id])
|
||||
}
|
||||
|
||||
static listByCliente(clienteId, tenantId) {
|
||||
return query(
|
||||
`SELECT * FROM cotacoes WHERE cliente_id = $1 AND tenant_id = $2 ORDER BY created_at DESC`,
|
||||
[clienteId, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static listByUser(userId, tenantId) {
|
||||
return query(
|
||||
`SELECT DISTINCT ON (c.id) c.*,
|
||||
p.id AS pedido_id,
|
||||
p.protocolo AS pedido_protocolo,
|
||||
p.status AS pedido_status,
|
||||
p.forma_pagto AS pedido_forma_pagto,
|
||||
p.tipo_entrega AS pedido_tipo_entrega,
|
||||
p.financeiro_status AS pedido_financeiro_status,
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'status', sl.status,
|
||||
'created_at', sl.created_at
|
||||
) ORDER BY sl.created_at ASC)
|
||||
FROM pedido_status_log sl
|
||||
WHERE sl.pedido_id = p.id
|
||||
) AS pedido_status_log,
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'id', pi.id,
|
||||
'nome_produto', pi.nome_produto,
|
||||
'sku', pi.sku,
|
||||
'quantidade', pi.quantidade,
|
||||
'subtotal', pi.subtotal,
|
||||
'observacao', pi.observacao,
|
||||
'alerta_tipo', pi.alerta_tipo,
|
||||
'alerta_obs', pi.alerta_obs,
|
||||
'alerta_foto', pi.alerta_foto,
|
||||
'alerta_confirmado',pi.alerta_confirmado,
|
||||
'alerta_em', pi.alerta_em
|
||||
) ORDER BY pi.id ASC)
|
||||
FROM pedido_itens pi
|
||||
WHERE pi.pedido_id = p.id
|
||||
) AS itens
|
||||
FROM cotacoes c
|
||||
LEFT JOIN pedidos p ON p.cotacao_id = c.id AND p.tenant_id = c.tenant_id
|
||||
WHERE c.user_id = $1 AND c.tenant_id = $2
|
||||
ORDER BY c.id DESC, p.id DESC
|
||||
LIMIT 50`,
|
||||
[userId, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static listAdmin(tenantId, { status, limit = 50, offset = 0 } = {}) {
|
||||
const params = [tenantId]
|
||||
let where = 'WHERE c.tenant_id = $1'
|
||||
if (status) { params.push(status); where += ` AND c.status = $${params.length}` }
|
||||
params.push(Math.min(Number(limit) || 50, 200))
|
||||
params.push(Math.max(Number(offset) || 0, 0))
|
||||
return query(
|
||||
`SELECT c.*,
|
||||
COALESCE(cl.nome, u.nome) AS cliente_nome,
|
||||
COALESCE(cl.tipo, 'PF') AS cliente_tipo
|
||||
FROM cotacoes c
|
||||
LEFT JOIN clientes cl ON cl.id = c.cliente_id
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
${where} ORDER BY c.created_at DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static create({ tenant_id, cliente_id, user_id, status = 'rascunho', forma_pagto, total, desconto, observacao }) {
|
||||
return execute(
|
||||
`INSERT INTO cotacoes (tenant_id,cliente_id,user_id,status,forma_pagto,total,desconto,observacao)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[tenant_id, cliente_id || null, user_id || null, status,
|
||||
forma_pagto || null, total || 0, desconto || 0, observacao || null]
|
||||
)
|
||||
}
|
||||
|
||||
static update(id, { status, forma_pagto, total, desconto, observacao }) {
|
||||
return execute(
|
||||
`UPDATE cotacoes SET status=$1,forma_pagto=$2,total=$3,desconto=$4,observacao=$5,updated_at=NOW()
|
||||
WHERE id=$6`,
|
||||
[status, forma_pagto || null, total, desconto || 0, observacao || null, id]
|
||||
)
|
||||
}
|
||||
|
||||
// ── Itens ─────────────────────────────────────────────────
|
||||
|
||||
static listItens(cotacaoId) {
|
||||
return query(
|
||||
`SELECT ci.*, p.imagem AS produto_imagem
|
||||
FROM cotacao_itens ci
|
||||
LEFT JOIN produtos p ON p.id = ci.produto_id
|
||||
WHERE ci.cotacao_id = $1 ORDER BY ci.id ASC`,
|
||||
[cotacaoId]
|
||||
)
|
||||
}
|
||||
|
||||
static addItem({ cotacao_id, produto_id, nome_produto, sku, quantidade, preco_unitario, preco_promocional, desconto_pct, observacao }) {
|
||||
const precoFinal = preco_promocional || preco_unitario
|
||||
const subtotal = precoFinal * quantidade * (1 - (desconto_pct || 0) / 100)
|
||||
return execute(
|
||||
`INSERT INTO cotacao_itens
|
||||
(cotacao_id,produto_id,nome_produto,sku,quantidade,preco_unitario,preco_promocional,desconto_pct,subtotal,observacao)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
|
||||
[cotacao_id, produto_id || null, nome_produto, sku || null,
|
||||
quantidade, preco_unitario, preco_promocional || null, desconto_pct || 0, subtotal, observacao || null]
|
||||
)
|
||||
}
|
||||
|
||||
static updateItemObs(itemId, observacao) {
|
||||
return execute(
|
||||
`UPDATE cotacao_itens SET observacao=$1 WHERE id=$2`,
|
||||
[observacao || null, itemId]
|
||||
)
|
||||
}
|
||||
|
||||
static removeItem(itemId) {
|
||||
return execute('DELETE FROM cotacao_itens WHERE id = $1', [itemId])
|
||||
}
|
||||
|
||||
static clearItens(cotacaoId) {
|
||||
return execute('DELETE FROM cotacao_itens WHERE cotacao_id = $1', [cotacaoId])
|
||||
}
|
||||
|
||||
static async recalcTotal(cotacaoId) {
|
||||
await execute(
|
||||
`UPDATE cotacoes SET total = (
|
||||
SELECT COALESCE(SUM(subtotal),0) FROM cotacao_itens WHERE cotacao_id = $1
|
||||
), updated_at = NOW() WHERE id = $1`,
|
||||
[cotacaoId]
|
||||
)
|
||||
}
|
||||
|
||||
// ── Converter cotação em pedido ───────────────────────────
|
||||
|
||||
static async toOrder(cotacaoId, { endereco_id, tipo_entrega, observacao } = {}) {
|
||||
const cotacao = await Cotacao.findById(cotacaoId)
|
||||
if (!cotacao) throw new Error('Cotação não encontrada')
|
||||
const itens = await Cotacao.listItens(cotacaoId)
|
||||
|
||||
// Strategy 2 — se veio do portal (user_id preenchido mas cliente_id nulo),
|
||||
// garante um registro em clientes para manter rastreabilidade no admin
|
||||
let clienteId = cotacao.cliente_id
|
||||
if (!clienteId && cotacao.user_id) {
|
||||
// verifica se já existe um clientes vinculado a este user_id
|
||||
const existing = await queryOne(
|
||||
`SELECT id FROM clientes WHERE user_id = $1 AND tenant_id = $2 LIMIT 1`,
|
||||
[cotacao.user_id, cotacao.tenant_id]
|
||||
)
|
||||
if (existing) {
|
||||
clienteId = existing.id
|
||||
} else {
|
||||
// cria o registro usando dados do users
|
||||
const user = await queryOne(`SELECT * FROM users WHERE id = $1`, [cotacao.user_id])
|
||||
if (user) {
|
||||
const novo = await execute(
|
||||
`INSERT INTO clientes (tenant_id, user_id, nome, email, cpf_cnpj, tipo, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,'PF',NOW(),NOW()) RETURNING id`,
|
||||
[cotacao.tenant_id, user.id, user.nome, user.email || null, user.cpf || null]
|
||||
)
|
||||
clienteId = novo.rows[0]?.id || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pedidoResult = await execute(
|
||||
`INSERT INTO pedidos
|
||||
(tenant_id,cotacao_id,cliente_id,user_id,endereco_id,status,forma_pagto,tipo_entrega,total,desconto,observacao)
|
||||
VALUES ($1,$2,$3,$4,$5,'aguardando',$6,$7,$8,$9,$10) RETURNING *`,
|
||||
[cotacao.tenant_id, cotacaoId, clienteId, cotacao.user_id,
|
||||
endereco_id || null, cotacao.forma_pagto, tipo_entrega || 'retirada',
|
||||
cotacao.total, cotacao.desconto, observacao || cotacao.observacao]
|
||||
)
|
||||
const pedido = pedidoResult.rows[0]
|
||||
if (!pedido) throw new Error('Falha ao criar pedido')
|
||||
|
||||
for (const item of itens) {
|
||||
await execute(
|
||||
`INSERT INTO pedido_itens (pedido_id,produto_id,nome_produto,sku,quantidade,preco_unitario,desconto_pct,subtotal,observacao)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
||||
[pedido.id, item.produto_id, item.nome_produto, item.sku,
|
||||
item.quantidade, item.preco_unitario, item.desconto_pct, item.subtotal, item.observacao || null]
|
||||
)
|
||||
}
|
||||
|
||||
// WPP-10 — gera protocolo único por pedido
|
||||
const dt = new Date()
|
||||
const yyyymmdd = `${dt.getFullYear()}${String(dt.getMonth()+1).padStart(2,'0')}${String(dt.getDate()).padStart(2,'0')}`
|
||||
const protocolo = `PED-${yyyymmdd}-${pedido.id}`
|
||||
await execute(`UPDATE pedidos SET protocolo=$1 WHERE id=$2`, [protocolo, pedido.id])
|
||||
|
||||
await execute(
|
||||
`UPDATE cotacoes SET status='finalizada', updated_at=NOW() WHERE id=$1`,
|
||||
[cotacaoId]
|
||||
)
|
||||
return { ...pedido, protocolo }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Cotacao
|
||||
@@ -0,0 +1,48 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class CotacaoFlashPromo {
|
||||
// Retorna promoção relâmpago ativa agora (com dados do produto se vinculado)
|
||||
static getActive(tenantId = 1) {
|
||||
return queryOne(
|
||||
`SELECT cfp.*, p.nome AS produto_nome, p.sku AS produto_sku, p.preco AS produto_preco
|
||||
FROM cotacao_flash_promos cfp
|
||||
LEFT JOIN produtos p ON p.id = cfp.produto_id
|
||||
WHERE cfp.tenant_id = $1 AND cfp.ativo = TRUE
|
||||
AND cfp.starts_at <= NOW() AND cfp.ends_at > NOW()
|
||||
ORDER BY cfp.ends_at ASC
|
||||
LIMIT 1`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
// Lista todas as flash promos (admin)
|
||||
static list(tenantId = 1) {
|
||||
return query(
|
||||
`SELECT cfp.*, p.nome AS produto_nome
|
||||
FROM cotacao_flash_promos cfp
|
||||
LEFT JOIN produtos p ON p.id = cfp.produto_id
|
||||
WHERE cfp.tenant_id = $1
|
||||
ORDER BY cfp.created_at DESC LIMIT 50`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
// Cria nova flash promo
|
||||
static create({ tenantId = 1, produtoId, discount_pct, label, duration_minutes }) {
|
||||
return execute(
|
||||
`INSERT INTO cotacao_flash_promos (tenant_id, produto_id, discount_pct, label, ends_at)
|
||||
VALUES ($1, $2, $3, $4, NOW() + ($5 * INTERVAL '1 minute'))
|
||||
RETURNING *`,
|
||||
[tenantId, produtoId || null, parseFloat(discount_pct), label || 'Promoção Relâmpago', parseInt(duration_minutes)]
|
||||
)
|
||||
}
|
||||
|
||||
// Encerra uma flash promo
|
||||
static cancel(id) {
|
||||
return execute(`UPDATE cotacao_flash_promos SET ativo=FALSE, ends_at=NOW() WHERE id=$1`, [id])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CotacaoFlashPromo
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict'
|
||||
|
||||
const { query, queryOne } = require('../database/postgres')
|
||||
|
||||
class DirectMessage {
|
||||
/**
|
||||
* Enviar DM para um colaborador
|
||||
*/
|
||||
static async send(tenantId, fromAccountId, toAccountId, content) {
|
||||
if (!fromAccountId || !toAccountId || !content?.trim()) {
|
||||
throw new Error('fromAccountId, toAccountId e content obrigatórios')
|
||||
}
|
||||
const row = await queryOne(
|
||||
`INSERT INTO direct_messages (tenant_id, from_account, to_account, content, created_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
RETURNING id, from_account, to_account, content, created_at`,
|
||||
[tenantId, fromAccountId, toAccountId, content]
|
||||
)
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar histórico de DMs entre dois colaboradores (conversação)
|
||||
*/
|
||||
static async getConversation(tenantId, accountId1, accountId2, limit = 50) {
|
||||
const msgs = await query(
|
||||
`SELECT
|
||||
id, from_account, to_account, content, read_at, created_at
|
||||
FROM direct_messages
|
||||
WHERE tenant_id = $1
|
||||
AND ((from_account = $2 AND to_account = $3)
|
||||
OR (from_account = $3 AND to_account = $2))
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $4`,
|
||||
[tenantId, accountId1, accountId2, limit]
|
||||
)
|
||||
return msgs.reverse() // mais antigos primeiro
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar últimas DMs por colaborador (preview de conversas)
|
||||
* Retorna a última mensagem em cada conversação
|
||||
*/
|
||||
static async getLastMessagesByAccount(tenantId, accountId) {
|
||||
const msgs = await query(
|
||||
`SELECT DISTINCT ON (other_account)
|
||||
id, from_account, to_account, content, read_at, created_at,
|
||||
CASE
|
||||
WHEN from_account = $2 THEN to_account
|
||||
ELSE from_account
|
||||
END AS other_account
|
||||
FROM direct_messages
|
||||
WHERE tenant_id = $1
|
||||
AND (from_account = $2 OR to_account = $2)
|
||||
ORDER BY other_account, created_at DESC`,
|
||||
[tenantId, accountId]
|
||||
)
|
||||
return msgs
|
||||
}
|
||||
|
||||
/**
|
||||
* Contar DMs não lidas para um colaborador
|
||||
*/
|
||||
static async getUnreadCount(tenantId, accountId) {
|
||||
const row = await queryOne(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM direct_messages
|
||||
WHERE tenant_id = $1
|
||||
AND to_account = $2
|
||||
AND read_at IS NULL`,
|
||||
[tenantId, accountId]
|
||||
)
|
||||
return Number(row?.count ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar DMs como lidas
|
||||
*/
|
||||
static async markAsRead(tenantId, accountId, fromAccountId) {
|
||||
await query(
|
||||
`UPDATE direct_messages
|
||||
SET read_at = NOW()
|
||||
WHERE tenant_id = $1
|
||||
AND to_account = $2
|
||||
AND from_account = $3
|
||||
AND read_at IS NULL`,
|
||||
[tenantId, accountId, fromAccountId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Contar DMs não lidas por remetente
|
||||
* Útil para badges de quantas DMs cada pessoa tem
|
||||
*/
|
||||
static async getUnreadCountByContact(tenantId, accountId) {
|
||||
const rows = await query(
|
||||
`SELECT from_account, COUNT(*) as unread_count
|
||||
FROM direct_messages
|
||||
WHERE tenant_id = $1
|
||||
AND to_account = $2
|
||||
AND read_at IS NULL
|
||||
GROUP BY from_account`,
|
||||
[tenantId, accountId]
|
||||
)
|
||||
const map = {}
|
||||
rows.forEach(r => {
|
||||
map[r.from_account] = Number(r.unread_count)
|
||||
})
|
||||
return map
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DirectMessage
|
||||
@@ -0,0 +1,161 @@
|
||||
'use strict'
|
||||
|
||||
const { query, queryOne } = require('../database/postgres')
|
||||
|
||||
class GroupChat {
|
||||
/**
|
||||
* Listar grupos em que o colaborador participa (via team_members) com unread_count
|
||||
*/
|
||||
static async listForAccount(tenantId, accountId) {
|
||||
return query(
|
||||
`SELECT gc.id, gc.sector_id, gc.name,
|
||||
s.icon, s.color,
|
||||
(SELECT gm.content
|
||||
FROM group_messages gm
|
||||
WHERE gm.group_chat_id = gc.id
|
||||
ORDER BY gm.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT gm.created_at
|
||||
FROM group_messages gm
|
||||
WHERE gm.group_chat_id = gc.id
|
||||
ORDER BY gm.created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM group_messages gm2
|
||||
LEFT JOIN group_last_read glr
|
||||
ON glr.group_chat_id = gm2.group_chat_id AND glr.account_id = $2
|
||||
WHERE gm2.group_chat_id = gc.id
|
||||
AND gm2.from_account != $2
|
||||
AND (glr.last_read_at IS NULL OR gm2.created_at > glr.last_read_at)
|
||||
) AS unread_count
|
||||
FROM group_chats gc
|
||||
JOIN sectors s ON s.id = gc.sector_id
|
||||
JOIN team_members tm ON tm.sector_id = gc.sector_id AND tm.account_id = $2
|
||||
WHERE gc.tenant_id = $1
|
||||
ORDER BY s.sort_order, gc.name`,
|
||||
[tenantId, accountId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar todos os grupos do tenant (para owners/managers) com unread_count
|
||||
*/
|
||||
static async listAll(tenantId, accountId) {
|
||||
return query(
|
||||
`SELECT gc.id, gc.sector_id, gc.name,
|
||||
s.icon, s.color,
|
||||
(SELECT gm.content
|
||||
FROM group_messages gm
|
||||
WHERE gm.group_chat_id = gc.id
|
||||
ORDER BY gm.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT gm.created_at
|
||||
FROM group_messages gm
|
||||
WHERE gm.group_chat_id = gc.id
|
||||
ORDER BY gm.created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT COUNT(*)::int
|
||||
FROM group_messages gm2
|
||||
LEFT JOIN group_last_read glr
|
||||
ON glr.group_chat_id = gm2.group_chat_id AND glr.account_id = $2
|
||||
WHERE gm2.group_chat_id = gc.id
|
||||
AND gm2.from_account != $2
|
||||
AND (glr.last_read_at IS NULL OR gm2.created_at > glr.last_read_at)
|
||||
) AS unread_count
|
||||
FROM group_chats gc
|
||||
JOIN sectors s ON s.id = gc.sector_id
|
||||
WHERE gc.tenant_id = $1
|
||||
ORDER BY s.sort_order, gc.name`,
|
||||
[tenantId, accountId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marcar grupo como lido (upsert last_read_at)
|
||||
*/
|
||||
static async markRead(accountId, groupChatId) {
|
||||
await queryOne(
|
||||
`INSERT INTO group_last_read (account_id, group_chat_id, last_read_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (account_id, group_chat_id)
|
||||
DO UPDATE SET last_read_at = NOW()`,
|
||||
[accountId, groupChatId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar grupo por id, verificando pertença ao tenant
|
||||
*/
|
||||
static async findById(tenantId, groupChatId) {
|
||||
return queryOne(
|
||||
`SELECT gc.*, s.name AS sector_name, s.icon, s.color,
|
||||
(SELECT COUNT(*)::int FROM team_members tm WHERE tm.sector_id = s.id) AS member_count
|
||||
FROM group_chats gc
|
||||
JOIN sectors s ON s.id = gc.sector_id
|
||||
WHERE gc.id = $1 AND gc.tenant_id = $2`,
|
||||
[groupChatId, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Buscar mensagens de um grupo (paginação por cursor)
|
||||
*/
|
||||
static async getMessages(tenantId, groupChatId, limit = 50, before = null) {
|
||||
const params = [tenantId, groupChatId, limit]
|
||||
const beforeClause = before ? `AND gm.created_at < $4` : ''
|
||||
if (before) params.push(before)
|
||||
|
||||
const msgs = await query(
|
||||
`SELECT gm.id, gm.from_account, gm.content, gm.created_at,
|
||||
a.name AS sender_name, a.avatar_url AS sender_avatar
|
||||
FROM group_messages gm
|
||||
LEFT JOIN accounts a ON a.id = gm.from_account
|
||||
WHERE gm.tenant_id = $1
|
||||
AND gm.group_chat_id = $2
|
||||
${beforeClause}
|
||||
ORDER BY gm.created_at DESC
|
||||
LIMIT $3`,
|
||||
params
|
||||
)
|
||||
return msgs.reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enviar mensagem em um grupo
|
||||
*/
|
||||
static async sendMessage(tenantId, groupChatId, fromAccount, content) {
|
||||
return queryOne(
|
||||
`INSERT INTO group_messages (tenant_id, group_chat_id, from_account, content)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, group_chat_id, from_account, content, created_at`,
|
||||
[tenantId, groupChatId, fromAccount, content]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Listar membros de um grupo (via team_members do setor)
|
||||
*/
|
||||
static async getMembers(tenantId, groupChatId) {
|
||||
return query(
|
||||
`SELECT a.id, a.name, a.avatar_url, a.availability
|
||||
FROM group_chats gc
|
||||
JOIN team_members tm ON tm.sector_id = gc.sector_id
|
||||
JOIN accounts a ON a.id = tm.account_id
|
||||
WHERE gc.id = $1 AND gc.tenant_id = $2 AND a.active = TRUE
|
||||
ORDER BY a.name`,
|
||||
[groupChatId, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs dos membros de um grupo (para SSE direcionado)
|
||||
*/
|
||||
static async getMemberIds(groupChatId) {
|
||||
const rows = await query(
|
||||
`SELECT tm.account_id
|
||||
FROM group_chats gc
|
||||
JOIN team_members tm ON tm.sector_id = gc.sector_id
|
||||
WHERE gc.id = $1`,
|
||||
[groupChatId]
|
||||
)
|
||||
return rows.map(r => r.account_id)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GroupChat
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class MensagemInterna {
|
||||
// Lista mensagens de um pedido (ou de um setor, sem pedido)
|
||||
static listByPedido(pedidoId) {
|
||||
return query(
|
||||
`SELECT * FROM mensagens_internas WHERE pedido_id = $1 ORDER BY created_at ASC`,
|
||||
[pedidoId]
|
||||
)
|
||||
}
|
||||
|
||||
// Lista mensagens de um setor (inbox do setor) — apenas não lidas, ou todas
|
||||
static listBySetor(tenantId, setor, somenteNaoLidas = false) {
|
||||
const cond = somenteNaoLidas ? 'AND lida = FALSE' : ''
|
||||
return query(
|
||||
`SELECT mi.*, p.id AS pid, p.status AS pedido_status, p.total AS pedido_total
|
||||
FROM mensagens_internas mi
|
||||
LEFT JOIN pedidos p ON p.id = mi.pedido_id
|
||||
WHERE mi.tenant_id = $1 AND mi.setor = $2 ${cond}
|
||||
ORDER BY mi.created_at DESC
|
||||
LIMIT 100`,
|
||||
[tenantId, setor]
|
||||
)
|
||||
}
|
||||
|
||||
// Contagem de não lidas por setor
|
||||
static contadorNaoLidas(tenantId) {
|
||||
return query(
|
||||
`SELECT setor, COUNT(*) AS total
|
||||
FROM mensagens_internas
|
||||
WHERE tenant_id = $1 AND lida = FALSE
|
||||
GROUP BY setor
|
||||
ORDER BY setor`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
// Total de não lidas (para badge geral)
|
||||
static totalNaoLidas(tenantId) {
|
||||
return queryOne(
|
||||
`SELECT COUNT(*) AS total FROM mensagens_internas WHERE tenant_id = $1 AND lida = FALSE`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
// Envia mensagem
|
||||
static create({ tenantId, pedidoId, setor, remetente, mensagem }) {
|
||||
return execute(
|
||||
`INSERT INTO mensagens_internas (tenant_id, pedido_id, setor, remetente, mensagem)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[tenantId, pedidoId || null, setor || 'geral', remetente, mensagem]
|
||||
)
|
||||
}
|
||||
|
||||
// Marcar como lida (uma ou todas de um pedido)
|
||||
static marcarLida(id) {
|
||||
return execute(`UPDATE mensagens_internas SET lida = TRUE WHERE id = $1`, [id])
|
||||
}
|
||||
|
||||
static marcarTodasLidasPorPedido(pedidoId) {
|
||||
return execute(`UPDATE mensagens_internas SET lida = TRUE WHERE pedido_id = $1`, [pedidoId])
|
||||
}
|
||||
|
||||
static marcarTodasLidasPorSetor(tenantId, setor) {
|
||||
return execute(
|
||||
`UPDATE mensagens_internas SET lida = TRUE WHERE tenant_id = $1 AND setor = $2 AND lida = FALSE`,
|
||||
[tenantId, setor]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MensagemInterna
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Notification {
|
||||
static async create({ user_cpf, title, message, type }) {
|
||||
return execute(
|
||||
`INSERT INTO notifications (user_cpf, title, message, type)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[user_cpf, title, message, type || 'info']
|
||||
)
|
||||
}
|
||||
|
||||
static async getUserNotifications(user_cpf) {
|
||||
return query(
|
||||
`SELECT * FROM notifications
|
||||
WHERE user_cpf = $1
|
||||
ORDER BY created_at DESC LIMIT 50`,
|
||||
[user_cpf]
|
||||
)
|
||||
}
|
||||
|
||||
static async getUnreadCount(user_cpf) {
|
||||
const row = await queryOne(
|
||||
`SELECT COUNT(*) as total FROM notifications
|
||||
WHERE user_cpf = $1 AND read_at IS NULL`,
|
||||
[user_cpf]
|
||||
)
|
||||
return row ? parseInt(row.total) : 0
|
||||
}
|
||||
|
||||
static async markAsRead(id, user_cpf) {
|
||||
return execute(
|
||||
`UPDATE notifications SET read_at = NOW()
|
||||
WHERE id = $1 AND user_cpf = $2 AND read_at IS NULL`,
|
||||
[id, user_cpf]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Notification
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Order {
|
||||
static async create({ id, customer_id, user_cpf, total_amount, payment_method, asaas_payment_id, qr_code_url, pix_copy_paste }) {
|
||||
return execute(
|
||||
`INSERT INTO orders (id, customer_id, user_cpf, total_amount, status, payment_method, asaas_payment_id, qr_code_url, pix_copy_paste)
|
||||
VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8) RETURNING id`,
|
||||
[id, customer_id || null, user_cpf, total_amount, payment_method || 'PIX', asaas_payment_id || null, qr_code_url || null, pix_copy_paste || null]
|
||||
)
|
||||
}
|
||||
|
||||
static async updateAsaasPayment(id, asaas_payment_id, qr_code_url, pix_copy_paste) {
|
||||
return execute(
|
||||
`UPDATE orders SET asaas_payment_id=$1, qr_code_url=$2, pix_copy_paste=$3 WHERE id=$4`,
|
||||
[asaas_payment_id, qr_code_url, pix_copy_paste, id]
|
||||
)
|
||||
}
|
||||
|
||||
static findByAsaasId(asaas_payment_id) {
|
||||
return queryOne('SELECT * FROM orders WHERE asaas_payment_id = $1', [asaas_payment_id])
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return queryOne('SELECT * FROM orders WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static async updateStatus(id, newStatus) {
|
||||
return execute(
|
||||
`UPDATE orders SET status = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[newStatus, id]
|
||||
)
|
||||
}
|
||||
|
||||
static async delete(id) {
|
||||
return execute(`DELETE FROM orders WHERE id = $1`, [id])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Order
|
||||
@@ -0,0 +1,273 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Pedido {
|
||||
static findById(id) {
|
||||
return queryOne(`
|
||||
SELECT p.*,
|
||||
COALESCE(cl.nome, u.nome) AS cliente_nome,
|
||||
COALESCE(cl.tipo, 'PF') AS cliente_tipo,
|
||||
COALESCE(cl.cpf_cnpj, u.cpf) AS cpf_cnpj,
|
||||
COALESCE(cl.email, u.email) AS cliente_email,
|
||||
COALESCE(e.rua, p.end_rua) AS rua,
|
||||
COALESCE(e.numero, p.end_numero) AS end_numero,
|
||||
COALESCE(e.bairro, p.end_bairro) AS bairro,
|
||||
COALESCE(e.cidade, p.end_cidade) AS cidade,
|
||||
COALESCE(e.estado, p.end_estado) AS estado,
|
||||
COALESCE(e.cep, p.end_cep) AS cep,
|
||||
ase.name AS separador_nome,
|
||||
aem.name AS embalador_nome,
|
||||
aau.name AS auditor_nome,
|
||||
afi.name AS fiscal_nome,
|
||||
aex.name AS expedidor_nome,
|
||||
acu.name AS cupom_emitido_por_nome
|
||||
FROM pedidos p
|
||||
LEFT JOIN clientes cl ON cl.id = p.cliente_id
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
LEFT JOIN cliente_enderecos e ON e.id = p.endereco_id
|
||||
LEFT JOIN accounts ase ON ase.id = p.separador_id
|
||||
LEFT JOIN accounts aem ON aem.id = p.embalador_id
|
||||
LEFT JOIN accounts aau ON aau.id = p.auditor_id
|
||||
LEFT JOIN accounts afi ON afi.id = p.fiscal_id
|
||||
LEFT JOIN accounts aex ON aex.id = p.expedidor_id
|
||||
LEFT JOIN accounts acu ON acu.id = p.cupom_emitido_por
|
||||
WHERE p.id = $1
|
||||
`, [id])
|
||||
}
|
||||
|
||||
static listFinanceiro(tenantId, { limit = 50, offset = 0 } = {}) {
|
||||
return query(
|
||||
`SELECT p.*,
|
||||
COALESCE(cl.nome, u.nome) AS cliente_nome,
|
||||
COALESCE(cl.tipo, 'PF') AS cliente_tipo,
|
||||
COALESCE(cl.cpf_cnpj, u.cpf) AS cpf_cnpj
|
||||
FROM pedidos p
|
||||
LEFT JOIN clientes cl ON cl.id = p.cliente_id
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.tenant_id = $1 AND p.financeiro_status = 'pendente'
|
||||
ORDER BY p.created_at ASC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[tenantId, Math.min(Number(limit) || 50, 200), Math.max(Number(offset) || 0, 0)]
|
||||
)
|
||||
}
|
||||
|
||||
static financeiroDecisao(id, { acao, obs }) {
|
||||
if (acao === 'aprovar') {
|
||||
return execute(
|
||||
`UPDATE pedidos SET financeiro_status='aprovado', financeiro_obs=$1, financeiro_em=NOW(), updated_at=NOW() WHERE id=$2`,
|
||||
[obs || null, id]
|
||||
)
|
||||
}
|
||||
// rejeitar → cancela o pedido
|
||||
return execute(
|
||||
`UPDATE pedidos SET financeiro_status='rejeitado', financeiro_obs=$1, financeiro_em=NOW(),
|
||||
status='cancelado', updated_at=NOW() WHERE id=$2`,
|
||||
[obs || null, id]
|
||||
)
|
||||
}
|
||||
|
||||
static updateAddressInline(id, { cep, rua, numero, complemento, bairro, cidade, estado }) {
|
||||
return execute(
|
||||
`UPDATE pedidos SET end_cep=$1, end_rua=$2, end_numero=$3, end_complemento=$4,
|
||||
end_bairro=$5, end_cidade=$6, end_estado=$7, updated_at=NOW() WHERE id=$8`,
|
||||
[cep, rua, numero, complemento, bairro, cidade, estado, id]
|
||||
)
|
||||
}
|
||||
|
||||
static listAdmin(tenantId, { status, from, to, limit = 50, offset = 0 } = {}) {
|
||||
const params = [tenantId]
|
||||
let where = 'WHERE p.tenant_id = $1'
|
||||
if (status) { params.push(status); where += ` AND p.status = $${params.length}` }
|
||||
if (from) { params.push(from); where += ` AND DATE(p.created_at) >= $${params.length}` }
|
||||
if (to) { params.push(to); where += ` AND DATE(p.created_at) <= $${params.length}` }
|
||||
params.push(Math.min(Number(limit) || 50, 200))
|
||||
params.push(Math.max(Number(offset) || 0, 0))
|
||||
return query(
|
||||
`SELECT p.*,
|
||||
COALESCE(cl.nome, u.nome) AS cliente_nome,
|
||||
COALESCE(cl.tipo, 'PF') AS cliente_tipo
|
||||
FROM pedidos p
|
||||
LEFT JOIN clientes cl ON cl.id = p.cliente_id
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
${where} ORDER BY p.created_at DESC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static listByCliente(clienteId, tenantId) {
|
||||
return query(
|
||||
`SELECT * FROM pedidos WHERE cliente_id = $1 AND tenant_id = $2 ORDER BY created_at DESC`,
|
||||
[clienteId, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static listItens(pedidoId) {
|
||||
return query(
|
||||
`SELECT pi.*, p.imagem AS produto_imagem
|
||||
FROM pedido_itens pi
|
||||
LEFT JOIN produtos p ON p.id = pi.produto_id
|
||||
WHERE pi.pedido_id = $1 ORDER BY pi.id ASC`,
|
||||
[pedidoId]
|
||||
)
|
||||
}
|
||||
|
||||
// Retorna o log de quem executou cada etapa, com dados do colaborador
|
||||
static findHistorico(pedidoId) {
|
||||
return query(`
|
||||
SELECT psl.id, psl.status, psl.created_at AS feito_em,
|
||||
a.id AS account_id, a.name AS account_name,
|
||||
a.avatar_url AS account_avatar, a.role AS account_role
|
||||
FROM pedido_status_log psl
|
||||
LEFT JOIN accounts a ON a.id = psl.account_id
|
||||
WHERE psl.pedido_id = $1
|
||||
ORDER BY psl.created_at ASC
|
||||
`, [pedidoId])
|
||||
}
|
||||
|
||||
static async logStatus(pedidoId, status, accountId = null) {
|
||||
try {
|
||||
await execute(
|
||||
'INSERT INTO pedido_status_log (pedido_id, status, account_id) VALUES ($1,$2,$3)',
|
||||
[pedidoId, status, accountId || null]
|
||||
)
|
||||
} catch { /* nunca bloqueia o fluxo principal */ }
|
||||
}
|
||||
|
||||
static async updateStatus(id, status, accountId = null) {
|
||||
// Quando entra em expedição, registra o horário de saída do entregador.
|
||||
// Só seta uma vez (COALESCE preserva valor já existente em re-transições).
|
||||
if (status === 'expedicao') {
|
||||
await execute(
|
||||
`UPDATE pedidos
|
||||
SET status=$1,
|
||||
expedidor_saida_em = COALESCE(expedidor_saida_em, NOW()),
|
||||
expedidor_id = COALESCE($2, expedidor_id),
|
||||
updated_at = NOW()
|
||||
WHERE id=$3`,
|
||||
[status, accountId || null, id]
|
||||
)
|
||||
} else {
|
||||
await execute('UPDATE pedidos SET status=$1, updated_at=NOW() WHERE id=$2', [status, id])
|
||||
}
|
||||
await Pedido.logStatus(id, status, accountId)
|
||||
}
|
||||
|
||||
// Registra cupom fiscal emitido externamente (PDV/ECF).
|
||||
// numero: COO ou número do cupom. chave: chave de acesso NFC-e (44 dígitos).
|
||||
// accountId: quem clicou no botão (auditoria).
|
||||
static async registrarCupom(id, { numero, chave, accountId }) {
|
||||
await execute(
|
||||
`UPDATE pedidos
|
||||
SET cupom_numero = $1,
|
||||
cupom_chave = $2,
|
||||
cupom_emitido_em = NOW(),
|
||||
cupom_emitido_por = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4`,
|
||||
[numero || null, chave || null, accountId || null, id]
|
||||
)
|
||||
}
|
||||
|
||||
static atribuirEquipe(id, equipeId) {
|
||||
return execute('UPDATE pedidos SET equipe_id=$1, updated_at=NOW() WHERE id=$2', [equipeId, id])
|
||||
}
|
||||
|
||||
static async marcarEntregue(id, responsavel, accountId = null) {
|
||||
const expedidorId = accountId || null
|
||||
await execute(
|
||||
`UPDATE pedidos
|
||||
SET status=$1, responsavel_entrega=$2, expedidor_id=$3, entregue_em=NOW(), updated_at=NOW()
|
||||
WHERE id=$4`,
|
||||
['entregue', responsavel || null, expedidorId, id]
|
||||
)
|
||||
await Pedido.logStatus(id, 'entregue', accountId)
|
||||
}
|
||||
|
||||
static marcarItemSeparado(itemId, separado) {
|
||||
return execute('UPDATE pedido_itens SET separado=$1 WHERE id=$2', [separado, itemId])
|
||||
}
|
||||
|
||||
static setAlertaItem(itemId, { tipo, obs, foto }) {
|
||||
return execute(
|
||||
`UPDATE pedido_itens
|
||||
SET alerta_tipo=$1, alerta_obs=$2, alerta_foto=$3,
|
||||
alerta_confirmado=FALSE, alerta_em=NOW()
|
||||
WHERE id=$4`,
|
||||
[tipo || null, obs || null, foto || null, itemId]
|
||||
)
|
||||
}
|
||||
|
||||
static confirmarAlertaItem(itemId) {
|
||||
return execute(
|
||||
`UPDATE pedido_itens SET alerta_confirmado=TRUE WHERE id=$1`, [itemId]
|
||||
)
|
||||
}
|
||||
|
||||
static limparAlertaItem(itemId) {
|
||||
return execute(
|
||||
`UPDATE pedido_itens
|
||||
SET alerta_tipo=NULL, alerta_obs=NULL, alerta_foto=NULL,
|
||||
alerta_confirmado=FALSE, alerta_em=NULL
|
||||
WHERE id=$1`,
|
||||
[itemId]
|
||||
)
|
||||
}
|
||||
|
||||
// EMB-02 — registra embalador (texto legado) + embalador_id (FK) e timestamp
|
||||
static async marcarEmbalado(id, embalador, accountId = null) {
|
||||
// embalador_id prevalece sobre texto livre; accountId é o id do colaborador logado
|
||||
const embaladorId = accountId || null
|
||||
await execute(
|
||||
`UPDATE pedidos
|
||||
SET status=$1, embalador=$2, embalador_id=$3, embalado_em=NOW(), updated_at=NOW()
|
||||
WHERE id=$4`,
|
||||
['embalagem', embalador || null, embaladorId, id]
|
||||
)
|
||||
await Pedido.logStatus(id, 'embalagem', accountId)
|
||||
}
|
||||
|
||||
// Atribui um colaborador como responsável por uma etapa (separador/auditor/fiscal).
|
||||
// Para embalador/expedidor existem métodos dedicados (marcarEmbalado/marcarEntregue)
|
||||
// por terem efeitos colaterais — mudança de status, timestamps e notificações.
|
||||
// Mapeia funcao → coluna de FK; rejeita funções desconhecidas (segurança contra SQLi).
|
||||
static async setResponsavel(id, funcao, accountId) {
|
||||
const COL = { separador: 'separador_id', auditor: 'auditor_id', fiscal: 'fiscal_id' }
|
||||
const col = COL[funcao]
|
||||
if (!col) throw new Error(`Função inválida: ${funcao}`)
|
||||
await execute(`UPDATE pedidos SET ${col}=$1, updated_at=NOW() WHERE id=$2`, [accountId || null, id])
|
||||
}
|
||||
|
||||
// DASH-04 — notificações internas (novos pedidos/cotações recentes)
|
||||
static notificacoes(tenantId, limitMinutes = 1440) {
|
||||
return query(`
|
||||
SELECT id, status, total, cliente_id, created_at,
|
||||
'pedido' AS tipo
|
||||
FROM pedidos
|
||||
WHERE tenant_id = $1
|
||||
AND created_at >= NOW() - ($2 || ' minutes')::INTERVAL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 30
|
||||
`, [tenantId, limitMinutes])
|
||||
}
|
||||
|
||||
// Contadores para dashboard
|
||||
static contadores(tenantId) {
|
||||
return queryOne(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE DATE(created_at) = CURRENT_DATE) AS hoje,
|
||||
COUNT(*) FILTER (WHERE status = 'aguardando') AS aguardando,
|
||||
COUNT(*) FILTER (WHERE status = 'separacao') AS em_separacao,
|
||||
COUNT(*) FILTER (WHERE status = 'embalagem') AS em_embalagem,
|
||||
COUNT(*) FILTER (WHERE status = 'auditoria') AS em_auditoria,
|
||||
COUNT(*) FILTER (WHERE status IN ('fiscal','expedicao')) AS prontos,
|
||||
COUNT(*) FILTER (WHERE status = 'entregue') AS entregues,
|
||||
COUNT(*) FILTER (WHERE status NOT IN ('entregue','cancelado')) AS em_aberto,
|
||||
COUNT(*) FILTER (WHERE financeiro_status = 'pendente') AS financeiro_pendente
|
||||
FROM pedidos WHERE tenant_id = $1
|
||||
`, [tenantId])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Pedido
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
/**
|
||||
* Pedidos Recorrentes — FUT-02
|
||||
* Permite que clientes PJ (e PF) agendem recompras automáticas.
|
||||
*/
|
||||
class PedidoRecorrente {
|
||||
static listByUser(userId, tenantId) {
|
||||
return query(
|
||||
`SELECT pr.*, cl.nome AS cliente_nome
|
||||
FROM pedidos_recorrentes pr
|
||||
LEFT JOIN clientes cl ON cl.id = pr.cliente_id
|
||||
WHERE pr.user_id = $1 AND pr.tenant_id = $2
|
||||
ORDER BY pr.created_at DESC`,
|
||||
[userId, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static findById(id, userId) {
|
||||
return queryOne(
|
||||
`SELECT * FROM pedidos_recorrentes WHERE id = $1 AND user_id = $2`,
|
||||
[id, userId]
|
||||
)
|
||||
}
|
||||
|
||||
static create({ tenant_id, user_id, cliente_id, nome, itens, frequencia, proximo_em }) {
|
||||
return queryOne(
|
||||
`INSERT INTO pedidos_recorrentes
|
||||
(tenant_id,user_id,cliente_id,nome,itens,frequencia,proximo_em)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING *`,
|
||||
[tenant_id || 1, user_id, cliente_id || null, nome, JSON.stringify(itens || []), frequencia || 'mensal', proximo_em]
|
||||
)
|
||||
}
|
||||
|
||||
static update(id, userId, { nome, itens, frequencia, proximo_em, pausado }) {
|
||||
return execute(
|
||||
`UPDATE pedidos_recorrentes
|
||||
SET nome=$1, itens=$2, frequencia=$3, proximo_em=$4, pausado=$5, updated_at=NOW()
|
||||
WHERE id=$6 AND user_id=$7`,
|
||||
[nome, JSON.stringify(itens || []), frequencia, proximo_em, pausado ?? false, id, userId]
|
||||
)
|
||||
}
|
||||
|
||||
static togglePausa(id, userId, pausado) {
|
||||
return execute(
|
||||
`UPDATE pedidos_recorrentes SET pausado=$1, updated_at=NOW() WHERE id=$2 AND user_id=$3`,
|
||||
[pausado, id, userId]
|
||||
)
|
||||
}
|
||||
|
||||
static remove(id, userId) {
|
||||
return execute(
|
||||
`DELETE FROM pedidos_recorrentes WHERE id=$1 AND user_id=$2`,
|
||||
[id, userId]
|
||||
)
|
||||
}
|
||||
|
||||
/** Retorna todos os recorrentes vencidos hoje (para o job diário) */
|
||||
static listDue(tenantId) {
|
||||
return query(
|
||||
`SELECT pr.*, u.telefone AS user_telefone
|
||||
FROM pedidos_recorrentes pr
|
||||
LEFT JOIN users u ON u.id = pr.user_id
|
||||
WHERE pr.tenant_id = $1
|
||||
AND pr.ativo = TRUE
|
||||
AND pr.pausado = FALSE
|
||||
AND pr.proximo_em <= CURRENT_DATE`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
/** Atualiza proximo_em após execução */
|
||||
static avancaProximo(id, frequencia) {
|
||||
const intervalMap = {
|
||||
semanal: '7 days',
|
||||
quinzenal: '14 days',
|
||||
mensal: '1 month',
|
||||
bimestral: '2 months',
|
||||
trimestral: '3 months',
|
||||
}
|
||||
const interval = intervalMap[frequencia] || '1 month'
|
||||
return execute(
|
||||
`UPDATE pedidos_recorrentes
|
||||
SET proximo_em = proximo_em + $1::INTERVAL, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[interval, id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PedidoRecorrente
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
const Raffle = require('./Raffle')
|
||||
|
||||
class PriceRule {
|
||||
static async create({ raffle_id, rule_type, price, start_at, end_at, priority }) {
|
||||
return execute(
|
||||
`INSERT INTO raffle_price_rules (raffle_id, rule_type, price, start_at, end_at, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[raffle_id, rule_type, price, start_at || null, end_at || null, priority || 0]
|
||||
)
|
||||
}
|
||||
|
||||
static async listByRaffle(raffle_id) {
|
||||
return query(
|
||||
`SELECT * FROM raffle_price_rules WHERE raffle_id = $1 AND active = 1 ORDER BY priority DESC`,
|
||||
[raffle_id]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine de Preço Dinâmico: Verifica qual regra está ativa agora.
|
||||
* Retorna o preço apropriado e o ID da regra aplicada (se houver).
|
||||
*/
|
||||
static async getCurrentPrice(raffle_id) {
|
||||
const rules = await this.listByRaffle(raffle_id)
|
||||
const now = new Date()
|
||||
|
||||
let appliedRuleId = null
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.rule_type === 'time_range') {
|
||||
const start = rule.start_at ? new Date(rule.start_at) : new Date(0)
|
||||
const end = rule.end_at ? new Date(rule.end_at) : new Date(8640000000000000) // max date
|
||||
if (now >= start && now <= end) {
|
||||
return { price: parseFloat(rule.price), rule_id: rule.id }
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.rule_type === 'fixed') {
|
||||
return { price: parseFloat(rule.price), rule_id: rule.id }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Busca o preço padrão do sorteio
|
||||
const raffle = await Raffle.findById(raffle_id)
|
||||
if (!raffle) throw new Error('Sorteio não encontrado')
|
||||
|
||||
return { price: parseFloat(raffle.preco_numero), rule_id: null }
|
||||
}
|
||||
|
||||
static async logPrice({ raffle_id, order_id, user_cpf, price_applied, rule_id }) {
|
||||
return execute(
|
||||
`INSERT INTO price_logs (raffle_id, order_id, user_cpf, price_applied, rule_id)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[raffle_id, order_id || null, user_cpf || null, price_applied, rule_id || null]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PriceRule
|
||||
@@ -0,0 +1,85 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Produto {
|
||||
static findById(id) {
|
||||
return queryOne(`SELECT * FROM produtos WHERE id = $1`, [id])
|
||||
}
|
||||
|
||||
static findBySku(sku, tenantId) {
|
||||
return queryOne('SELECT * FROM produtos WHERE sku = $1 AND tenant_id = $2', [sku, tenantId])
|
||||
}
|
||||
|
||||
static list(tenantId, { search, categoria, ativo, limit = 50, offset = 0 } = {}) {
|
||||
const params = [tenantId]
|
||||
let where = 'WHERE p.tenant_id = $1'
|
||||
if (ativo !== undefined) { params.push(ativo); where += ` AND p.ativo = $${params.length}` }
|
||||
if (categoria) { params.push(categoria); where += ` AND p.categoria = $${params.length}` }
|
||||
if (search) {
|
||||
params.push(`%${search.replace(/[%_]/g, '\\$&')}%`)
|
||||
where += ` AND (p.nome ILIKE $${params.length} OR p.sku ILIKE $${params.length} OR p.descricao ILIKE $${params.length})`
|
||||
}
|
||||
params.push(Math.min(Number(limit) || 50, 500))
|
||||
params.push(Math.max(Number(offset) || 0, 0))
|
||||
return query(
|
||||
`SELECT * FROM produtos p
|
||||
${where}
|
||||
ORDER BY p.nome ASC
|
||||
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
static listCategorias(tenantId) {
|
||||
return query(
|
||||
`SELECT DISTINCT categoria FROM produtos WHERE tenant_id = $1 AND categoria IS NOT NULL ORDER BY categoria`,
|
||||
[tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static create({ tenant_id, sku, nome, descricao, unidade, preco, preco_promocional, estoque, categoria, imagem }) {
|
||||
return execute(
|
||||
`INSERT INTO produtos (tenant_id,sku,nome,descricao,unidade,preco,preco_promocional,estoque,categoria,imagem)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`,
|
||||
[tenant_id, sku || null, nome, descricao || null, unidade || 'un',
|
||||
preco || 0, preco_promocional || null, estoque || 0, categoria || null, imagem || null]
|
||||
)
|
||||
}
|
||||
|
||||
static update(id, { sku, nome, descricao, unidade, preco, preco_promocional, estoque, categoria, imagem, ativo }) {
|
||||
return execute(
|
||||
`UPDATE produtos SET sku=$1,nome=$2,descricao=$3,unidade=$4,preco=$5,preco_promocional=$6,
|
||||
estoque=$7,categoria=$8,imagem=$9,ativo=$10,updated_at=NOW() WHERE id=$11`,
|
||||
[sku || null, nome, descricao || null, unidade || 'un',
|
||||
preco, preco_promocional || null, estoque, categoria || null, imagem || null, ativo !== false, id]
|
||||
)
|
||||
}
|
||||
|
||||
static updateEstoque(id, estoque) {
|
||||
return execute('UPDATE produtos SET estoque=$1, updated_at=NOW() WHERE id=$2', [estoque, id])
|
||||
}
|
||||
|
||||
static updatePreco(id, preco) {
|
||||
return execute('UPDATE produtos SET preco=$1, updated_at=NOW() WHERE id=$2', [preco, id])
|
||||
}
|
||||
|
||||
static delete(id) {
|
||||
return execute('DELETE FROM produtos WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
// Upsert por SKU — usado na importação CSV
|
||||
static upsertBySku({ tenant_id, sku, nome, descricao, unidade, preco, preco_promocional, estoque, categoria }) {
|
||||
return execute(
|
||||
`INSERT INTO produtos (tenant_id,sku,nome,descricao,unidade,preco,preco_promocional,estoque,categoria)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (sku, tenant_id) DO UPDATE
|
||||
SET nome=$3, descricao=$4, unidade=$5, preco=$6, preco_promocional=$7, estoque=$8, categoria=$9, updated_at=NOW()
|
||||
RETURNING *`,
|
||||
[tenant_id, sku, nome, descricao || null, unidade || 'un',
|
||||
preco || 0, preco_promocional || null, estoque || 0, categoria || null]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Produto
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Promotion {
|
||||
static findAllActive(tenantId) {
|
||||
if (tenantId) return query('SELECT * FROM promotions WHERE ativo = TRUE AND tenant_id=$1 ORDER BY data_criacao DESC', [tenantId])
|
||||
return query('SELECT * FROM promotions WHERE ativo = TRUE ORDER BY data_criacao DESC')
|
||||
}
|
||||
|
||||
static findAll(tenantId) {
|
||||
if (tenantId) return query('SELECT * FROM promotions WHERE tenant_id=$1 ORDER BY data_criacao DESC', [tenantId])
|
||||
return query('SELECT * FROM promotions ORDER BY data_criacao DESC')
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return queryOne('SELECT * FROM promotions WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static create({ nome, descricao, preco_original, preco_promocional, imagem, tenant_id }) {
|
||||
return execute(
|
||||
`INSERT INTO promotions (nome, descricao, preco_original, preco_promocional, imagem, tenant_id, ativo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, TRUE) RETURNING id`,
|
||||
[nome, descricao || null, preco_original || null, preco_promocional, imagem || '', tenant_id || 1]
|
||||
)
|
||||
}
|
||||
|
||||
static update(id, { nome, descricao, preco_original, preco_promocional, imagem, ativo }) {
|
||||
return execute(
|
||||
`UPDATE promotions
|
||||
SET nome=$1, descricao=$2, preco_original=$3, preco_promocional=$4, imagem=$5, ativo=$6
|
||||
WHERE id=$7`,
|
||||
[nome, descricao || null, preco_original || null, preco_promocional, imagem || '', ativo !== false, id]
|
||||
)
|
||||
}
|
||||
|
||||
static deleteById(id) {
|
||||
return execute('DELETE FROM promotions WHERE id = $1', [id])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Promotion
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class Raffle {
|
||||
static findAll(tenantId) {
|
||||
if (tenantId) return query('SELECT * FROM raffles WHERE tenant_id=$1 ORDER BY data_criacao DESC', [tenantId])
|
||||
return query('SELECT * FROM raffles ORDER BY data_criacao DESC')
|
||||
}
|
||||
|
||||
static findActive(tenantId) {
|
||||
if (tenantId) return query(
|
||||
"SELECT * FROM raffles WHERE ativo=1 AND status!='finished' AND tenant_id=$1 ORDER BY data_criacao DESC",
|
||||
[tenantId]
|
||||
)
|
||||
return query("SELECT * FROM raffles WHERE ativo=1 AND status!='finished' ORDER BY data_criacao DESC")
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return queryOne('SELECT * FROM raffles WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static create({ titulo, descricao, preco_numero, total_numeros, start_at, end_at, tenant_id }) {
|
||||
return execute(
|
||||
`INSERT INTO raffles (titulo, descricao, preco_numero, total_numeros, start_at, end_at, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[titulo, descricao || null, preco_numero || 5.0, total_numeros || 500,
|
||||
start_at || null, end_at || null, tenant_id || 1]
|
||||
)
|
||||
}
|
||||
|
||||
static update(id, { titulo, descricao, preco_numero, total_numeros, ativo, start_at, end_at }) {
|
||||
return execute(
|
||||
`UPDATE raffles SET titulo=$1, descricao=$2, preco_numero=$3, total_numeros=$4, ativo=$5, start_at=$6, end_at=$7
|
||||
WHERE id=$8`,
|
||||
[titulo, descricao, preco_numero, total_numeros, ativo, start_at || null, end_at || null, id]
|
||||
)
|
||||
}
|
||||
|
||||
static finish(id) {
|
||||
return execute(`UPDATE raffles SET status='finished', ativo=0 WHERE id=$1`, [id])
|
||||
}
|
||||
|
||||
static deleteById(id) {
|
||||
return execute('DELETE FROM raffles WHERE id = $1', [id])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Raffle
|
||||
@@ -0,0 +1,57 @@
|
||||
'use strict'
|
||||
|
||||
const { query, execute, queryOne } = require('../database/postgres')
|
||||
|
||||
class RaffleDiscount {
|
||||
static listByRaffle(raffleId) {
|
||||
return query(
|
||||
`SELECT id, min_qty, discount_pct, active
|
||||
FROM raffle_discounts
|
||||
WHERE raffle_id = $1
|
||||
ORDER BY min_qty ASC`,
|
||||
[raffleId]
|
||||
)
|
||||
}
|
||||
|
||||
// Returns the best (highest) active discount for the given quantity
|
||||
static getBestForQty(raffleId, qty) {
|
||||
return queryOne(
|
||||
`SELECT id, min_qty, discount_pct
|
||||
FROM raffle_discounts
|
||||
WHERE raffle_id = $1 AND min_qty <= $2 AND active = TRUE
|
||||
ORDER BY discount_pct DESC
|
||||
LIMIT 1`,
|
||||
[raffleId, qty]
|
||||
)
|
||||
}
|
||||
|
||||
// Returns the next discount tier above current qty (for "buy X more" hint)
|
||||
static getNextTier(raffleId, qty) {
|
||||
return queryOne(
|
||||
`SELECT id, min_qty, discount_pct
|
||||
FROM raffle_discounts
|
||||
WHERE raffle_id = $1 AND min_qty > $2 AND active = TRUE
|
||||
ORDER BY min_qty ASC
|
||||
LIMIT 1`,
|
||||
[raffleId, qty]
|
||||
)
|
||||
}
|
||||
|
||||
static deleteByRaffle(raffleId) {
|
||||
return execute('DELETE FROM raffle_discounts WHERE raffle_id = $1', [raffleId])
|
||||
}
|
||||
|
||||
static bulkInsert(raffleId, tiers) {
|
||||
if (!tiers || tiers.length === 0) return Promise.resolve()
|
||||
const values = tiers
|
||||
.map((t, i) => `($1, $${i * 2 + 2}, $${i * 2 + 3}, TRUE)`)
|
||||
.join(', ')
|
||||
const params = [raffleId, ...tiers.flatMap(t => [parseInt(t.min_qty), parseFloat(t.discount_pct)])]
|
||||
return execute(
|
||||
`INSERT INTO raffle_discounts (raffle_id, min_qty, discount_pct, active) VALUES ${values}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RaffleDiscount
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne } = require('../database/postgres')
|
||||
|
||||
class RaffleFlashPromo {
|
||||
static getActive(raffleId) {
|
||||
return queryOne(
|
||||
`SELECT * FROM raffle_flash_promos
|
||||
WHERE raffle_id = $1 AND starts_at <= NOW() AND ends_at > NOW()
|
||||
ORDER BY ends_at DESC LIMIT 1`,
|
||||
[raffleId]
|
||||
)
|
||||
}
|
||||
|
||||
static create({ raffle_id, discount_pct, label, duration_minutes }) {
|
||||
return execute(
|
||||
`INSERT INTO raffle_flash_promos (raffle_id, discount_pct, label, starts_at, ends_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW() + ($4 * INTERVAL '1 minute'))
|
||||
RETURNING *`,
|
||||
[raffle_id, parseFloat(discount_pct), label || 'Promoção Relâmpago', parseInt(duration_minutes)]
|
||||
)
|
||||
}
|
||||
|
||||
static cancel(id) {
|
||||
return execute(
|
||||
`UPDATE raffle_flash_promos SET ends_at = NOW() WHERE id = $1`,
|
||||
[id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RaffleFlashPromo
|
||||
@@ -0,0 +1,104 @@
|
||||
const { execute, queryOne, query, transaction } = require('../database/postgres')
|
||||
const crypto = require('crypto')
|
||||
const NotificationService = require('../services/notificationService')
|
||||
|
||||
class RaffleResult {
|
||||
/**
|
||||
* Executa o sorteio de um raffle de forma segura e auditável.
|
||||
*/
|
||||
static async draw(raffle_id, executedByIp = '127.0.0.1') {
|
||||
const txResult = await transaction(async (client) => {
|
||||
// 1. Lock do sorteio
|
||||
const raffleResult = await client.query(
|
||||
"SELECT id, titulo, end_at, status FROM raffles WHERE id = $1 AND status != 'finished' FOR UPDATE",
|
||||
[raffle_id]
|
||||
)
|
||||
|
||||
const raffle = raffleResult.rows[0]
|
||||
if (!raffle) throw new Error('Sorteio não encontrado ou já finalizado')
|
||||
|
||||
// 2. Busca todos os tickets vendidos, ordenados pela data e id (ordem determinística)
|
||||
const ticketsResult = await client.query(
|
||||
'SELECT id, numero FROM tickets WHERE raffle_id = $1 ORDER BY id ASC',
|
||||
[raffle_id]
|
||||
)
|
||||
|
||||
const tickets = ticketsResult.rows
|
||||
if (tickets.length === 0) throw new Error('Nenhum ticket vendido para este sorteio')
|
||||
|
||||
const totalSold = tickets.length
|
||||
const lastTicketId = tickets[tickets.length - 1].id
|
||||
|
||||
// 3. Gerar SEED fixo e auditável (SHA256)
|
||||
// Baseado em: id do sorteio + end_at + total vendido + id do último ticket
|
||||
const endAtISO = raffle.end_at ? new Date(raffle.end_at).toISOString() : 'no-end-date'
|
||||
const seedString = `${raffle_id}-${endAtISO}-${totalSold}-${lastTicketId}`
|
||||
const seed = crypto.createHash('sha256').update(seedString).digest('hex')
|
||||
|
||||
// 4. Transformar seed (hex) em um número inteiro grande usando BigInt para matemática justa
|
||||
const seedBigInt = BigInt('0x' + seed)
|
||||
const winnerIndex = Number(seedBigInt % BigInt(totalSold))
|
||||
|
||||
const winnerTicket = tickets[winnerIndex]
|
||||
|
||||
// 5. Salvar resultado e logs
|
||||
await client.query(
|
||||
`INSERT INTO raffle_results (raffle_id, winner_ticket_id, seed, algorithm_version)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[raffle_id, winnerTicket.id, seed, '1.0']
|
||||
)
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO raffle_draw_logs (raffle_id, seed, result_number, ip)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[raffle_id, seed, winnerTicket.numero, executedByIp]
|
||||
)
|
||||
|
||||
// 6. Atualizar status do sorteio
|
||||
await client.query(
|
||||
"UPDATE raffles SET status = 'finished', ativo = 0 WHERE id = $1",
|
||||
[raffle_id]
|
||||
)
|
||||
|
||||
return {
|
||||
winner_ticket_id: winnerTicket.id,
|
||||
winner_number: winnerTicket.numero,
|
||||
seed,
|
||||
winner_cpf: winnerTicket.usuario_cpf,
|
||||
winner_nome: winnerTicket.usuario_nome,
|
||||
winner_telefone: winnerTicket.usuario_telefone,
|
||||
raffle_titulo: raffle.titulo,
|
||||
}
|
||||
})
|
||||
|
||||
// Notificação ao ganhador FORA da transação — garante que só dispara após COMMIT
|
||||
if (txResult.winner_cpf) {
|
||||
setImmediate(() => {
|
||||
NotificationService.notifyWinner(
|
||||
txResult.winner_cpf,
|
||||
txResult.winner_nome,
|
||||
txResult.winner_telefone,
|
||||
txResult.raffle_titulo || 'Sorteio #' + raffle_id,
|
||||
txResult.winner_number
|
||||
).catch(console.error)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
winner_ticket_id: txResult.winner_ticket_id,
|
||||
winner_number: txResult.winner_number,
|
||||
seed: txResult.seed,
|
||||
}
|
||||
}
|
||||
|
||||
static findByRaffleId(raffle_id) {
|
||||
return queryOne(`
|
||||
SELECT rr.*, t.numero as winner_number, t.usuario_nome, t.usuario_telefone, t.usuario_cpf
|
||||
FROM raffle_results rr
|
||||
JOIN tickets t ON t.id = rr.winner_ticket_id
|
||||
WHERE rr.raffle_id = $1
|
||||
`, [raffle_id])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RaffleResult
|
||||
@@ -0,0 +1,135 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query, transaction } = require('../database/postgres')
|
||||
|
||||
class Ticket {
|
||||
/**
|
||||
* Reserva atômica: verifica disponibilidade e insere em uma única transação com status reservado.
|
||||
*/
|
||||
static async reserve({ raffle_id, numeros, usuario_nome, usuario_telefone, usuario_cpf, order_id, preco_pago }) {
|
||||
return transaction(async (client) => {
|
||||
const raffle = await client.query(
|
||||
'SELECT id, ativo, total_numeros FROM raffles WHERE id = $1 FOR UPDATE',
|
||||
[raffle_id]
|
||||
)
|
||||
if (!raffle.rows[0]) throw new Error('Sorteio não encontrado')
|
||||
if (!raffle.rows[0].ativo) throw new Error('Sorteio encerrado')
|
||||
|
||||
const insertIds = []
|
||||
|
||||
// Permitir reservar múltiplos números
|
||||
const numsToReserve = Array.isArray(numeros) ? numeros : [numeros]
|
||||
|
||||
for (const numero of numsToReserve) {
|
||||
if (numero < 1 || numero > raffle.rows[0].total_numeros) throw new Error(`Número inválido: ${numero}`)
|
||||
|
||||
// Verifica se número já foi vendido ou reservado ativamente
|
||||
const existing = await client.query(
|
||||
"SELECT id FROM tickets WHERE raffle_id = $1 AND numero = $2 AND (status = 'sold' OR (status = 'reserved' AND reserved_until > NOW()))",
|
||||
[raffle_id, numero]
|
||||
)
|
||||
if (existing.rows.length > 0) throw new Error(`Número ${numero} já está reservado ou vendido`)
|
||||
|
||||
// Remove reserva expirada se existir
|
||||
await client.query(
|
||||
"DELETE FROM tickets WHERE raffle_id = $1 AND numero = $2 AND status = 'reserved' AND reserved_until <= NOW()",
|
||||
[raffle_id, numero]
|
||||
)
|
||||
|
||||
// Insere o ticket como reservado (+5 min TTL)
|
||||
const result = await client.query(
|
||||
`INSERT INTO tickets (raffle_id, numero, usuario_nome, usuario_telefone, usuario_cpf, status, reserved_until, order_id, price_paid)
|
||||
VALUES ($1, $2, $3, $4, $5, 'reserved', NOW() + INTERVAL '5 minutes', $6, $7) RETURNING id`,
|
||||
[raffle_id, numero, usuario_nome, usuario_telefone || null, usuario_cpf || null, order_id, preco_pago]
|
||||
)
|
||||
insertIds.push(result.rows[0].id)
|
||||
}
|
||||
|
||||
return { ids: insertIds }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirma o pagamento convertendo reservados em vendidos
|
||||
*/
|
||||
static async confirmOrder(order_id) {
|
||||
return execute(
|
||||
"UPDATE tickets SET status = 'sold', reserved_until = NULL WHERE order_id = $1 AND status = 'reserved'",
|
||||
[order_id]
|
||||
)
|
||||
}
|
||||
|
||||
static getByOrderId(order_id) {
|
||||
return query(
|
||||
'SELECT t.*, r.titulo as raffle_nome FROM tickets t JOIN raffles r ON r.id = t.raffle_id WHERE t.order_id = $1',
|
||||
[order_id]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Libera tickets reservados não pagos
|
||||
*/
|
||||
static async releaseExpiredReservations() {
|
||||
return execute("DELETE FROM tickets WHERE status = 'reserved' AND reserved_until <= NOW()")
|
||||
}
|
||||
|
||||
static findByRaffle(raffle_id) {
|
||||
return query(
|
||||
'SELECT * FROM tickets WHERE raffle_id = $1 ORDER BY numero ASC',
|
||||
[raffle_id]
|
||||
)
|
||||
}
|
||||
|
||||
static findByNumber(raffle_id, numero) {
|
||||
return queryOne(
|
||||
'SELECT * FROM tickets WHERE raffle_id = $1 AND numero = $2',
|
||||
[raffle_id, numero]
|
||||
)
|
||||
}
|
||||
|
||||
static getSoldNumbers(raffle_id) {
|
||||
return query("SELECT numero FROM tickets WHERE raffle_id = $1 AND (status = 'sold' OR (status = 'reserved' AND reserved_until > NOW()))", [raffle_id])
|
||||
.then(rows => rows.map(r => r.numero))
|
||||
}
|
||||
|
||||
static async countByRaffle(raffle_id) {
|
||||
const row = await queryOne(
|
||||
"SELECT COUNT(*) as total FROM tickets WHERE raffle_id = $1 AND (status = 'sold' OR (status = 'reserved' AND reserved_until > NOW()))",
|
||||
[raffle_id]
|
||||
)
|
||||
return row ? parseInt(row.total) : 0
|
||||
}
|
||||
|
||||
static getAll() {
|
||||
return query(`
|
||||
SELECT t.*, r.titulo AS raffle_name
|
||||
FROM tickets t
|
||||
JOIN raffles r ON t.raffle_id = r.id
|
||||
ORDER BY t.data_compra DESC
|
||||
`)
|
||||
}
|
||||
|
||||
static deleteById(id) {
|
||||
return execute('DELETE FROM tickets WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static deleteByIdAndTenant(id, tenantId) {
|
||||
return execute(
|
||||
'DELETE FROM tickets t USING raffles r WHERE t.id = $1 AND t.raffle_id = r.id AND r.tenant_id = $2',
|
||||
[id, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static findByUserCpf(cpf) {
|
||||
const cleanCpf = cpf.replace(/[^\d]+/g, '')
|
||||
return query(`
|
||||
SELECT t.*, r.titulo AS raffle_name
|
||||
FROM tickets t
|
||||
JOIN raffles r ON t.raffle_id = r.id
|
||||
WHERE t.usuario_cpf = $1
|
||||
ORDER BY t.data_compra DESC
|
||||
`, [cleanCpf])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Ticket
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class User {
|
||||
static findAll(tenantId) {
|
||||
if (tenantId) return query('SELECT * FROM users WHERE tenant_id=$1 ORDER BY timestamp DESC', [tenantId])
|
||||
return query('SELECT * FROM users ORDER BY timestamp DESC')
|
||||
}
|
||||
|
||||
static findById(id) {
|
||||
return queryOne('SELECT * FROM users WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static findByEmail(email, tenantId) {
|
||||
if (tenantId) return queryOne('SELECT * FROM users WHERE email=$1 AND tenant_id=$2', [email, tenantId])
|
||||
return queryOne('SELECT * FROM users WHERE email = $1', [email])
|
||||
}
|
||||
|
||||
static findByCpf(cpf, tenantId) {
|
||||
const clean = cpf.replace(/[^\d]+/g, '')
|
||||
if (tenantId) return queryOne('SELECT * FROM users WHERE cpf=$1 AND tenant_id=$2', [clean, tenantId])
|
||||
return queryOne('SELECT * FROM users WHERE cpf = $1', [clean])
|
||||
}
|
||||
|
||||
static findByPhone(telefone, tenantId) {
|
||||
const clean = telefone.replace(/[^\d]+/g, '')
|
||||
return queryOne(
|
||||
`SELECT * FROM users WHERE REGEXP_REPLACE(telefone,'[^0-9]','','g')=$1 AND tenant_id=$2`,
|
||||
[clean, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
// Login por email+senha (novo) ou CPF+telefone (legado)
|
||||
static findByEmailAndTenant(email, tenantId) {
|
||||
return queryOne(
|
||||
'SELECT * FROM users WHERE LOWER(email)=LOWER($1) AND tenant_id=$2',
|
||||
[email, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static findByCpfPhone(cpf, telefone, tenantId) {
|
||||
const cleanCpf = cpf.replace(/[^\d]+/g, '')
|
||||
const cleanTel = telefone.replace(/[^\d]+/g, '')
|
||||
return queryOne(
|
||||
`SELECT * FROM users
|
||||
WHERE cpf=$1
|
||||
AND REGEXP_REPLACE(telefone,'[^0-9]','','g')=$2
|
||||
AND tenant_id=$3`,
|
||||
[cleanCpf, cleanTel, tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
static create({ nome, email, cpf, telefone, ip, source, password_hash, tenant_id, portal_session_id }) {
|
||||
return execute(
|
||||
`INSERT INTO users (nome, email, cpf, telefone, ip, source, password_hash, tenant_id, portal_session_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
|
||||
[nome, email || null, cpf || null, telefone || null, ip || null,
|
||||
source || 'hotspot', password_hash || null, tenant_id || 1,
|
||||
portal_session_id || null]
|
||||
)
|
||||
}
|
||||
|
||||
static async getStats(tenantId) {
|
||||
const p = tenantId ? [tenantId] : []
|
||||
const w = tenantId ? 'WHERE tenant_id=$1' : ''
|
||||
const w2 = tenantId ? 'AND tenant_id=$1' : ''
|
||||
const total = await queryOne(`SELECT COUNT(*) AS count FROM users ${w}`, p)
|
||||
const today = await queryOne(`SELECT COUNT(*) AS count FROM users WHERE timestamp::date=CURRENT_DATE ${w2}`, p)
|
||||
const withEmail = await queryOne(`SELECT COUNT(*) AS count FROM users WHERE email IS NOT NULL ${w2}`, p)
|
||||
return {
|
||||
total: parseInt(total?.count || 0),
|
||||
today: parseInt(today?.count || 0),
|
||||
withEmail: parseInt(withEmail?.count || 0),
|
||||
}
|
||||
}
|
||||
|
||||
static deleteById(id) {
|
||||
return execute('DELETE FROM users WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
static update(id, { nome, email, cpf, telefone, source }) {
|
||||
return execute(
|
||||
`UPDATE users SET nome=$1, email=$2, cpf=$3, telefone=$4, source=$5 WHERE id=$6`,
|
||||
[nome, email || null, cpf || null, telefone || null, source, id]
|
||||
)
|
||||
}
|
||||
|
||||
static updatePassword(id, password_hash) {
|
||||
return execute('UPDATE users SET password_hash=$1 WHERE id=$2', [password_hash, id])
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = User
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict'
|
||||
|
||||
const { execute } = require('../database/postgres')
|
||||
|
||||
class WhatsappLog {
|
||||
static async create({ user_id, phone, type, payload, tenant_id }) {
|
||||
const jsonPayload = typeof payload === 'object' ? JSON.stringify(payload) : payload
|
||||
return execute(
|
||||
`INSERT INTO whatsapp_logs (user_id, phone, type, payload, tenant_id)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[user_id || null, phone, type, jsonPayload, tenant_id || 1]
|
||||
)
|
||||
}
|
||||
|
||||
static async updateStatus(id, status, responseData = null) {
|
||||
const jsonResponse = responseData && typeof responseData === 'object'
|
||||
? JSON.stringify(responseData)
|
||||
: responseData
|
||||
return execute(
|
||||
`UPDATE whatsapp_logs SET status=$1, response=$2, updated_at=NOW() WHERE id=$3`,
|
||||
[status, jsonResponse, id]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WhatsappLog
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "sistema-nuvem",
|
||||
"version": "1.0.0",
|
||||
"description": "Landing Page + Promoções + Sorteio",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.74.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.20.0",
|
||||
"sql.js": "^1.10.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict'
|
||||
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
|
||||
class PluginConfigStore {
|
||||
constructor(pluginId) {
|
||||
this.pluginId = pluginId
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
const row = await queryOne(
|
||||
'SELECT value FROM plugin_configs WHERE plugin_id = $1 AND key = $2',
|
||||
[this.pluginId, key]
|
||||
)
|
||||
return row ? row.value : null
|
||||
}
|
||||
|
||||
async set(key, value) {
|
||||
const val = value === null ? null : String(value)
|
||||
await execute(
|
||||
`INSERT INTO plugin_configs (plugin_id, key, value) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (plugin_id, key) DO UPDATE SET value = EXCLUDED.value`,
|
||||
[this.pluginId, key, val]
|
||||
)
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
const rows = await query(
|
||||
'SELECT key, value FROM plugin_configs WHERE plugin_id = $1',
|
||||
[this.pluginId]
|
||||
)
|
||||
return Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
||||
}
|
||||
|
||||
async delete(key) {
|
||||
await execute(
|
||||
'DELETE FROM plugin_configs WHERE plugin_id = $1 AND key = $2',
|
||||
[this.pluginId, key]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PluginConfigStore
|
||||
@@ -0,0 +1,140 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { execute, queryOne, query } = require('../database/postgres')
|
||||
const PluginConfigStore = require('./config')
|
||||
|
||||
// ─── Tabelas já criadas no schema.sql ───────────────────────────────────────
|
||||
// plugins e plugin_configs existem — sem necessidade de CREATE aqui.
|
||||
|
||||
// ─── PluginManager ───────────────────────────────────────────────────────────
|
||||
|
||||
class PluginManager {
|
||||
constructor(app, dbAccess, httpServer = null) {
|
||||
this.app = app
|
||||
this.db = dbAccess // { query, queryOne, execute } do postgres.js
|
||||
this.httpServer = httpServer // http.Server nativo — para proxy WS
|
||||
this.plugins = new Map() // id → { manifest, instance, active }
|
||||
}
|
||||
|
||||
// Carrega todos os plugins encontrados em plugins/*/
|
||||
async loadAll() {
|
||||
const dir = __dirname
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const pluginDir = path.join(dir, entry.name)
|
||||
const manifestPath = path.join(pluginDir, 'manifest.json')
|
||||
if (!fs.existsSync(manifestPath)) continue
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
await this._register(manifest, pluginDir)
|
||||
} catch (err) {
|
||||
console.error(`[plugins] Erro ao carregar "${entry.name}":`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Registra um plugin: garante linha na tabela e ativa se estava ativo
|
||||
async _register(manifest, pluginDir) {
|
||||
const { id, name, version, active_by_default = false } = manifest
|
||||
|
||||
// Garante registro na tabela (upsert)
|
||||
await execute(
|
||||
`INSERT INTO plugins (id, active) VALUES ($1, $2)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
[id, active_by_default ? 1 : 0]
|
||||
)
|
||||
|
||||
const record = await queryOne('SELECT active FROM plugins WHERE id = $1', [id])
|
||||
const instance = require(path.join(pluginDir, 'index.js'))
|
||||
|
||||
this.plugins.set(id, { manifest, instance, active: false })
|
||||
|
||||
if (record && record.active) {
|
||||
await this.activate(id)
|
||||
}
|
||||
|
||||
const status = (record && record.active) ? '✓ ativo' : '○ inativo'
|
||||
console.log(`[plugins] ${name} v${version} — ${status}`)
|
||||
}
|
||||
|
||||
// Ativa um plugin
|
||||
async activate(id) {
|
||||
const plugin = this.plugins.get(id)
|
||||
if (!plugin) throw new Error(`Plugin "${id}" não encontrado`)
|
||||
if (plugin.active) return
|
||||
|
||||
const ctx = this._buildContext(id)
|
||||
await plugin.instance.activate(ctx)
|
||||
plugin.active = true
|
||||
await execute('UPDATE plugins SET active = 1 WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
// Desativa um plugin
|
||||
async deactivate(id) {
|
||||
const plugin = this.plugins.get(id)
|
||||
if (!plugin) throw new Error(`Plugin "${id}" não encontrado`)
|
||||
if (!plugin.active) return
|
||||
|
||||
const ctx = this._buildContext(id)
|
||||
if (typeof plugin.instance.deactivate === 'function') {
|
||||
await plugin.instance.deactivate(ctx)
|
||||
}
|
||||
plugin.active = false
|
||||
await execute('UPDATE plugins SET active = 0 WHERE id = $1', [id])
|
||||
}
|
||||
|
||||
// Contexto injetado nos plugins durante activate/deactivate
|
||||
_buildContext(id) {
|
||||
return {
|
||||
app: this.app,
|
||||
db: this.db,
|
||||
httpServer: this.httpServer, // http.Server nativo (pode ser null)
|
||||
config: new PluginConfigStore(id),
|
||||
logger: {
|
||||
info: (msg) => console.log(`[${id}] ${msg}`),
|
||||
warn: (msg) => console.warn(`[${id}] WARN: ${msg}`),
|
||||
error: (msg) => console.error(`[${id}] ERR: ${msg}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Lista todos os plugins
|
||||
async list() {
|
||||
const records = await query('SELECT id, active, installed_at FROM plugins')
|
||||
return records.map((r) => {
|
||||
const p = this.plugins.get(r.id)
|
||||
return {
|
||||
id: r.id,
|
||||
active: !!r.active,
|
||||
installed_at: r.installed_at,
|
||||
name: p?.manifest?.name ?? r.id,
|
||||
version: p?.manifest?.version ?? '?',
|
||||
description: p?.manifest?.description ?? '',
|
||||
config_schema: p?.manifest?.config_schema ?? [],
|
||||
features: p?.manifest?.features ?? [],
|
||||
ui_path: p?.manifest?.ui_path ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Retorna configuração pública de um plugin
|
||||
async getConfig(id) {
|
||||
const store = new PluginConfigStore(id)
|
||||
return store.getAll()
|
||||
}
|
||||
|
||||
// Salva múltiplos pares key/value de uma vez
|
||||
async setConfig(id, values) {
|
||||
const store = new PluginConfigStore(id)
|
||||
for (const [k, v] of Object.entries(values)) {
|
||||
await store.set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PluginManager
|
||||
@@ -0,0 +1,42 @@
|
||||
'use strict'
|
||||
|
||||
const { timingSafeEqual } = require('crypto')
|
||||
const { queryOne } = require('../../database/postgres')
|
||||
|
||||
/**
|
||||
* Middleware de autenticação das rotas /nw/*.
|
||||
* Valida a integration_key enviada pelo motor NewWhats em cada requisição.
|
||||
* Aceita via header: x-nw-key ou Authorization: Bearer <key>
|
||||
*/
|
||||
async function nwAuth(req, res, next) {
|
||||
const key = req.headers['x-nw-key']
|
||||
|| (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()
|
||||
|
||||
if (!key) {
|
||||
return res.status(401).json({ error: 'Chave de integração ausente. Use o header x-nw-key.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = await queryOne(
|
||||
"SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'integration_key'"
|
||||
)
|
||||
|
||||
if (!stored?.value) {
|
||||
return res.status(503).json({ error: 'Plugin NewWhats não configurado. Defina a integration_key no admin.' })
|
||||
}
|
||||
|
||||
const storedBuf = Buffer.from(stored.value)
|
||||
const keyBuf = Buffer.from(key)
|
||||
const valid = storedBuf.length === keyBuf.length && timingSafeEqual(storedBuf, keyBuf)
|
||||
if (!valid) {
|
||||
return res.status(403).json({ error: 'Chave de integração inválida.' })
|
||||
}
|
||||
|
||||
next()
|
||||
} catch (err) {
|
||||
console.error('[nwAuth] Erro ao verificar integration_key:', err.message)
|
||||
return res.status(500).json({ error: 'Erro interno ao validar autenticação.' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { nwAuth }
|
||||
@@ -0,0 +1,842 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* context-builder.js
|
||||
* Constrói o contexto local do projeto Alemão Conveniências para enriquecer
|
||||
* cada chamada à Secretária IA no motor NewWhats.
|
||||
*
|
||||
* O objeto retornado é injetado no system prompt do ProtocolEngine como
|
||||
* "=== CONTEXTO DO CLIENTE (dados reais do projeto) ===".
|
||||
*
|
||||
* Se nada for encontrado, retorna null (sem overhead no motor).
|
||||
*/
|
||||
|
||||
const { queryOne, query } = require('../../database/postgres')
|
||||
|
||||
/**
|
||||
* Normaliza um número de telefone para 10–11 dígitos (sem código de país).
|
||||
* Entrada: JID whatsapp (556799138794@s.whatsapp.net) ou número bruto.
|
||||
*/
|
||||
function normalizePhone(raw) {
|
||||
if (!raw) return ''
|
||||
// Remove tudo que não for dígito e código de país BR
|
||||
const digits = raw.replace(/\D/g, '')
|
||||
if (digits.startsWith('55') && digits.length >= 12) return digits.slice(2)
|
||||
return digits
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenta localizar um usuário pelo telefone normalizado.
|
||||
* Retorna o registro ou null se não encontrado.
|
||||
*/
|
||||
const escapeLike = s => s.replace(/[%_\\]/g, c => '\\' + c)
|
||||
|
||||
async function findUserByPhone(phone) {
|
||||
if (!phone || phone.length < 8) return null
|
||||
try {
|
||||
// Tenta match exato e com variações de comprimento (com/sem 9 extra)
|
||||
const variants = [phone]
|
||||
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3)) // remove 9 extra
|
||||
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2)) // adiciona 9 extra
|
||||
|
||||
for (const v of variants) {
|
||||
const user = await queryOne(
|
||||
`SELECT id, nome, cpf, email, telefone, created_at
|
||||
FROM users
|
||||
WHERE REGEXP_REPLACE(telefone, '[^0-9]', '', 'g') LIKE $1
|
||||
LIMIT 1`,
|
||||
[`%${escapeLike(v)}`],
|
||||
)
|
||||
if (user) return user
|
||||
}
|
||||
} catch (e) {
|
||||
// Tabela pode ter nome diferente — tenta customers
|
||||
try {
|
||||
const user = await queryOne(
|
||||
`SELECT id, nome, cpf, email, telefone, created_at FROM customers WHERE telefone LIKE $1 LIMIT 1`,
|
||||
[`%${escapeLike(phone)}`],
|
||||
)
|
||||
if (user) return user
|
||||
} catch { /* sem tabela customers */ }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca o clube e plano ativo do usuário.
|
||||
*/
|
||||
async function findActivePlan(userCpf) {
|
||||
if (!userCpf) return null
|
||||
try {
|
||||
return await queryOne(
|
||||
`SELECT cm.status, cm.started_at AS member_since,
|
||||
bc.name AS club_name,
|
||||
cp.name AS plan_name, cp.price_monthly AS price
|
||||
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.user_cpf = $1 AND cm.status IN ('active','pending_payment')
|
||||
ORDER BY cm.started_at DESC
|
||||
LIMIT 1`,
|
||||
[userCpf.replace(/\D/g, '')],
|
||||
)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca os últimos pedidos do usuário (tabela pedidos, por user_id).
|
||||
*/
|
||||
async function findRecentOrders(userId, limit = 3) {
|
||||
if (!userId) return []
|
||||
try {
|
||||
return await query(
|
||||
`SELECT id, protocolo, status, forma_pagto, tipo_entrega, total, financeiro_status, created_at
|
||||
FROM pedidos
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, limit],
|
||||
)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca promoções ativas do dia (schema correto do projeto).
|
||||
*/
|
||||
async function findActivePromotions(tenantId = 1) {
|
||||
try {
|
||||
return await query(
|
||||
`SELECT nome, descricao, preco_original, preco_promocional
|
||||
FROM promotions
|
||||
WHERE ativo = TRUE AND tenant_id = $1
|
||||
ORDER BY data_criacao DESC
|
||||
LIMIT 5`,
|
||||
[tenantId],
|
||||
)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Localiza um funcionário/colaborador pelo telefone na tabela accounts.
|
||||
* Retorna o registro ou null. Usado para detectar quando o remetente é
|
||||
* um agente interno — evita que a IA trate o funcionário como cliente externo.
|
||||
*/
|
||||
async function findAccountByPhone(phone) {
|
||||
if (!phone || phone.length < 8) return null
|
||||
const variants = [phone]
|
||||
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3))
|
||||
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2))
|
||||
try {
|
||||
for (const v of variants) {
|
||||
const row = await queryOne(
|
||||
`SELECT id, name, role, availability
|
||||
FROM accounts
|
||||
WHERE REGEXP_REPLACE(phone, '[^0-9]', '', 'g') LIKE $1
|
||||
AND active = true
|
||||
LIMIT 1`,
|
||||
[`%${escapeLike(v)}`],
|
||||
)
|
||||
if (row) return row
|
||||
}
|
||||
} catch { /* tabela accounts pode não existir em todos os projetos */ }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* WPP-02 — Localiza cliente PF/PJ na tabela clientes pelo telefone.
|
||||
*/
|
||||
async function findClienteByPhone(phone, tenantId = 1) {
|
||||
if (!phone || phone.length < 8) return null
|
||||
try {
|
||||
const variants = [phone]
|
||||
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3))
|
||||
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2))
|
||||
|
||||
for (const v of variants) {
|
||||
const row = await queryOne(
|
||||
`SELECT c.id, c.nome, c.tipo, c.cpf_cnpj, c.email, c.status
|
||||
FROM clientes c
|
||||
JOIN cliente_telefones ct ON ct.cliente_id = c.id
|
||||
WHERE c.tenant_id = $1
|
||||
AND REGEXP_REPLACE(ct.numero, '[^0-9]', '', 'g') LIKE $2
|
||||
LIMIT 1`,
|
||||
[tenantId, `%${escapeLike(v)}`],
|
||||
)
|
||||
if (row) return row
|
||||
}
|
||||
} catch { /* tabela pode não existir ainda */ }
|
||||
return null
|
||||
}
|
||||
|
||||
// Mapeamento de status para descrição amigável
|
||||
const STATUS_COTACAO = {
|
||||
rascunho: 'rascunho (carrinho ainda não confirmado)',
|
||||
salva: 'salva (aguardando confirmação do cliente)',
|
||||
enviada: 'enviada (aguardando análise)',
|
||||
processando: 'em processamento',
|
||||
concluida: 'concluída',
|
||||
finalizada: 'finalizada (pedido gerado)',
|
||||
cancelada: 'cancelada',
|
||||
}
|
||||
const STATUS_PEDIDO = {
|
||||
aguardando: 'aguardando (recebido, aguardando separação)',
|
||||
novo: 'novo (recebido, aguardando separação)',
|
||||
separacao: 'em separação (itens sendo separados no estoque)',
|
||||
embalagem: 'em embalagem (sendo embalado)',
|
||||
expedicao: 'saiu para entrega / expedição',
|
||||
entregue: 'entregue',
|
||||
cancelado: 'cancelado',
|
||||
auditoria: 'em auditoria interna',
|
||||
fiscal: 'em processamento fiscal',
|
||||
}
|
||||
const FINANCEIRO_STATUS = {
|
||||
pendente: 'pagamento pendente',
|
||||
aprovado: 'pagamento aprovado',
|
||||
recusado: 'pagamento recusado',
|
||||
reembolsado: 'reembolsado',
|
||||
}
|
||||
|
||||
function descStatus(val, map) {
|
||||
if (!val) return null
|
||||
return map[val] ?? val
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai menções de cotação ou pedido ID/protocolo da mensagem do usuário.
|
||||
* Ex: "cotação #1", "cotação 1", "pedido #3", "PED-20260426-3"
|
||||
*/
|
||||
function extractMentions(msg) {
|
||||
if (!msg) return []
|
||||
const results = []
|
||||
// Protocolo completo: PED-XXXXXXXX-N
|
||||
const protos = msg.match(/PED-\d{8}-\d+/gi) ?? []
|
||||
protos.forEach(p => results.push({ type: 'protocolo', value: p.toUpperCase() }))
|
||||
// cotação #N ou cotacao N
|
||||
const cots = msg.match(/cota[çc][aã]o\s*[#nº]?\s*(\d+)/gi) ?? []
|
||||
cots.forEach(m => {
|
||||
const id = m.match(/\d+/)?.[0]
|
||||
if (id) results.push({ type: 'cotacao_id', value: id })
|
||||
})
|
||||
// pedido #N
|
||||
const peds = msg.match(/pedido\s*[#nº]?\s*(\d+)/gi) ?? []
|
||||
peds.forEach(m => {
|
||||
const id = m.match(/\d+/)?.[0]
|
||||
if (id) results.push({ type: 'pedido_id', value: id })
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca cotação + pedido vinculado diretamente por ID (qualquer usuário).
|
||||
*/
|
||||
async function findCotacaoById(id, tenantId = 1) {
|
||||
try {
|
||||
const c = await queryOne(
|
||||
`SELECT c.id, c.status AS cotacao_status, c.total,
|
||||
p.id AS pedido_id, p.protocolo, p.status AS pedido_status,
|
||||
p.forma_pagto, p.tipo_entrega, p.financeiro_status,
|
||||
p.created_at AS pedido_criado_em
|
||||
FROM cotacoes c
|
||||
LEFT JOIN pedidos p ON p.cotacao_id = c.id AND p.tenant_id = c.tenant_id
|
||||
WHERE c.id = $1 AND c.tenant_id = $2
|
||||
ORDER BY p.id DESC
|
||||
LIMIT 1`,
|
||||
[id, tenantId],
|
||||
)
|
||||
return c
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca pedido por protocolo (qualquer usuário).
|
||||
*/
|
||||
async function findPedidoByProtocolo(protocolo, tenantId = 1) {
|
||||
try {
|
||||
return await queryOne(
|
||||
`SELECT id, protocolo, status, forma_pagto, tipo_entrega,
|
||||
financeiro_status, total, created_at
|
||||
FROM pedidos
|
||||
WHERE protocolo ILIKE $1 AND tenant_id = $2
|
||||
LIMIT 1`,
|
||||
[protocolo, tenantId],
|
||||
)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
/**
|
||||
* WPP-04 — Busca cotação aberta do cliente.
|
||||
*/
|
||||
async function findCotacaoAberta(clienteId) {
|
||||
if (!clienteId) return null
|
||||
try {
|
||||
return await queryOne(
|
||||
`SELECT c.id, c.status, c.total,
|
||||
COUNT(ci.id) AS total_itens
|
||||
FROM cotacoes c
|
||||
LEFT JOIN cotacao_itens ci ON ci.cotacao_id = c.id
|
||||
WHERE c.cliente_id = $1 AND c.status IN ('rascunho','salva')
|
||||
GROUP BY c.id
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 1`,
|
||||
[clienteId],
|
||||
)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
/**
|
||||
* WPP-04 — Busca últimos pedidos do cliente no módulo de cotação.
|
||||
*/
|
||||
async function findPedidosCliente(clienteId, limit = 3) {
|
||||
if (!clienteId) return []
|
||||
try {
|
||||
return await query(
|
||||
`SELECT id, status, total, tipo_entrega, created_at
|
||||
FROM pedidos
|
||||
WHERE cliente_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[clienteId, limit],
|
||||
)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* WPP-03 — WPP-04 — Produtos em destaque por categoria para sugestão rápida.
|
||||
*/
|
||||
async function findProdutosDestaque(tenantId = 1) {
|
||||
try {
|
||||
return await query(
|
||||
`SELECT nome, sku, preco, preco_promocional AS preco_promo, unidade, estoque, categoria
|
||||
FROM produtos p
|
||||
WHERE tenant_id = $1 AND ativo = TRUE AND estoque > 0
|
||||
ORDER BY categoria, nome
|
||||
LIMIT 12`,
|
||||
[tenantId],
|
||||
)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
// ── Saudação contextual por horário (America/Sao_Paulo) ─────────────────────
|
||||
function saudacaoPorHorario(date = new Date()) {
|
||||
// Ajusta para fuso de SP (-03:00) sem dependência externa
|
||||
const utc = date.getUTCHours() * 60 + date.getUTCMinutes()
|
||||
const minSP = (utc - 180 + 1440) % 1440
|
||||
const h = Math.floor(minSP / 60)
|
||||
const dow = (date.getUTCDay() + (utc - 180 < 0 ? -1 : 0) + 7) % 7
|
||||
const periodo = h < 5 ? 'madrugada' : h < 12 ? 'manhã' : h < 18 ? 'tarde' : 'noite'
|
||||
const saudacoes = {
|
||||
madrugada: 'Boa madrugada',
|
||||
'manhã': 'Bom dia',
|
||||
tarde: 'Boa tarde',
|
||||
noite: 'Boa noite',
|
||||
}
|
||||
// Verifica se a loja está aberta conforme brain (Seg-Qui 8-23, Sex-Sab 8-2, Dom 10-22)
|
||||
let aberto
|
||||
if (dow === 0) aberto = h >= 10 && h < 22 // domingo
|
||||
else if (dow >= 1 && dow <= 4) aberto = h >= 8 && h < 23 // seg-qui
|
||||
else if (dow === 5) aberto = h >= 8 || h < 2 // sexta (atravessa madrugada)
|
||||
else aberto = h >= 8 || h < 2 // sábado
|
||||
return {
|
||||
saudacao: saudacoes[periodo],
|
||||
periodo,
|
||||
hora_local:`${String(h).padStart(2, '0')}h`,
|
||||
loja_aberta: aberto,
|
||||
dia_semana: ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'][dow],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Detecção de intenções na mensagem ───────────────────────────────────────
|
||||
const STOPWORDS = new Set([
|
||||
'o','a','os','as','um','uma','de','da','do','das','dos','em','no','na','nos','nas',
|
||||
'e','ou','que','para','pra','por','com','sem','meu','minha','seu','sua','tem','ter',
|
||||
'tinha','vou','quero','queria','gostaria','poderia','pode','podem','é','são','foi',
|
||||
'voce','você','vc','ai','aí','la','lá','aqui','tudo','bem','oi','ola','olá','bom',
|
||||
'boa','dia','tarde','noite','obrigado','obrigada','valeu','flw','ok','sim','não','nao',
|
||||
])
|
||||
|
||||
function tokenize(msg) {
|
||||
return (msg || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(t => t.length >= 3 && !STOPWORDS.has(t))
|
||||
}
|
||||
|
||||
function detectIntents(msg) {
|
||||
const m = (msg || '').toLowerCase()
|
||||
return {
|
||||
pergunta_horario: /\b(abert[oa]s?|fechad[oa]s?|hor[áa]rio|funcion|que\s+horas)\b/i.test(m),
|
||||
pergunta_endereco: /\b(endere[çc]o|onde\s+(fica|esta|est[áa])|localiza|como\s+chego)\b/i.test(m),
|
||||
pergunta_entrega: /\b(entrega|entregar|delivery|tele|leva\s+at[ée]|taxa\s+de\s+entrega|frete|prazo)\b/i.test(m),
|
||||
pergunta_pagamento: /\b(pagamento|pagar|pix|cart[ãa]o|d[ée]bito|cr[ée]dito|dinheiro|boleto|forma\s+de\s+pag)\b/i.test(m),
|
||||
listar_meus_pedidos: /\b(meus\s+pedidos|minhas\s+compras|hist[óo]rico|o\s+que\s+(j[áa]\s+)?comprei)\b/i.test(m),
|
||||
pergunta_sorteio: /\b(sorteio|rifa|ticket|sorteios|premia[çc][aã]o|ganhei)\b/i.test(m),
|
||||
pergunta_clube: /\b(clube|fideli|assinatura|plano|membro|benef[íi]cio)\b/i.test(m),
|
||||
urgencia: /\b(urgente|urg[ée]ncia|r[áa]pido|imediato|j[áa]\s+(j[áa]|ja)|tem\s+pressa|t[ôo]\s+com\s+pressa|estou\s+com\s+pressa|emerg[êe]ncia)\b/i.test(m),
|
||||
frustracao: /\b(absurdo|p[ée]ssimo|horr[ií]vel|nunca\s+mais|cad[êe]\s+meu|reclama[çc][aã]o|cancela|inaceit[áa]vel|demor[oa]u\s+demais|n[ãa]o\s+aguento|t[ôo]\s+puto)\b/i.test(m),
|
||||
despedida: /^(obrigad[oa]|valeu|flw|tchau|at[ée]|brigad[oa]|t[áa]\s+bom|ok\s+obrigad|nada\s+mais)[\s!.\,]*$/i.test(m.trim()),
|
||||
saudacao_simples: /^(oi|ol[áa]|opa|eai|e\s+a[íi]|bom\s+dia|boa\s+(tarde|noite|madrugada)|alguem\s+a[íi])[\s!.\,?]*$/i.test(m.trim()),
|
||||
msg_curta_ambigua: m.trim().length > 0 && m.trim().length < 4, // "?", "ok", "..."
|
||||
}
|
||||
}
|
||||
|
||||
// ── Busca de produtos por palavras-chave da mensagem ────────────────────────
|
||||
async function findProdutosByKeywords(tokens, tenantId = 1, limit = 8) {
|
||||
if (!tokens?.length) return []
|
||||
try {
|
||||
// Cria pattern ILIKE para cada token e busca em nome/categoria/descrição
|
||||
const conditions = tokens.map((_, i) => `(
|
||||
p.nome ILIKE $${i + 2}
|
||||
OR p.categoria ILIKE $${i + 2}
|
||||
OR COALESCE(p.descricao, '') ILIKE $${i + 2}
|
||||
)`).join(' OR ')
|
||||
const params = [tenantId, ...tokens.map(t => `%${escapeLike(t)}%`)]
|
||||
return await query(
|
||||
`SELECT nome, sku, preco, preco_promocional AS preco_promo, unidade, estoque, categoria
|
||||
FROM produtos p
|
||||
WHERE tenant_id = $1 AND ativo = TRUE AND (${conditions})
|
||||
ORDER BY (estoque > 0) DESC, preco ASC
|
||||
LIMIT ${Number(limit)}`,
|
||||
params,
|
||||
)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
// ── Mídias recentes processadas (PDF / imagem) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Busca as últimas mídias processadas neste chat (PDFs e imagens) nas últimas
|
||||
* `lookbackMinutes` minutos. O texto extraído fica em nw_auto_replies.user_msg
|
||||
* com prefixo "[documento PDF]" ou "[imagem]".
|
||||
*
|
||||
* Útil para que o LLM saiba que o cliente enviou um comprovante/cupom mesmo
|
||||
* que a mensagem atual seja apenas texto ou um ping de follow-up.
|
||||
*/
|
||||
async function findRecentMedia(chatId, lookbackMinutes = 120) {
|
||||
if (!chatId) return []
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT user_msg, created_at
|
||||
FROM nw_auto_replies
|
||||
WHERE chat_id = $1
|
||||
AND created_at > NOW() - ($2 || ' minutes')::interval
|
||||
AND status IN ('sent','sent_local')
|
||||
AND (user_msg ILIKE '[documento PDF]%' OR user_msg ILIKE '[imagem]%')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 3`,
|
||||
[chatId, String(lookbackMinutes)],
|
||||
)
|
||||
return rows.map(r => {
|
||||
const ageMs = Date.now() - new Date(r.created_at).getTime()
|
||||
const ageMin = Math.round(ageMs / 60_000)
|
||||
const age = ageMin < 60 ? `há ${ageMin}min` : `há ${Math.round(ageMin / 60)}h`
|
||||
const tipo = r.user_msg.startsWith('[documento PDF]') ? 'PDF' : 'imagem'
|
||||
return { tipo, conteudo: r.user_msg, enviado: age }
|
||||
})
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
// ── Sorteios ativos ─────────────────────────────────────────────────────────
|
||||
async function findSorteiosAtivos(tenantId = 1) {
|
||||
try {
|
||||
return await query(
|
||||
`SELECT id, titulo, descricao, data_sorteio, premio
|
||||
FROM sorteios
|
||||
WHERE tenant_id = $1 AND status = 'ativo' AND data_sorteio > NOW()
|
||||
ORDER BY data_sorteio ASC
|
||||
LIMIT 5`,
|
||||
[tenantId],
|
||||
)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ponto de entrada principal.
|
||||
* @param {string} rawPhone — número do remetente (com ou sem código de país)
|
||||
* @param {number} tenantId
|
||||
* @param {string} userMsg — mensagem do usuário (para detectar menções de cotação/pedido)
|
||||
* @param {string|null} chatId — chat_id do WhatsApp (ex: 5567…@s.whatsapp.net); habilita histórico de mídias
|
||||
* @returns {object|null} contexto para injeção no motor, ou null se nada encontrado
|
||||
*/
|
||||
async function buildContext(rawPhone, tenantId = 1, userMsg = '', chatId = null) {
|
||||
const phone = normalizePhone(rawPhone)
|
||||
const result = {}
|
||||
let hasData = false
|
||||
|
||||
// ── Saudação contextual e estado da loja ─────────────────────────────────
|
||||
result.contexto_temporal = saudacaoPorHorario()
|
||||
hasData = true // sempre injeta, custo zero
|
||||
|
||||
// ── Detecção de intenções da mensagem ──────────────────────────────────
|
||||
const intents = detectIntents(userMsg)
|
||||
const intentsAtivas = Object.entries(intents).filter(([, v]) => v).map(([k]) => k)
|
||||
if (intentsAtivas.length > 0) result.intencoes_detectadas = intentsAtivas
|
||||
|
||||
// ── Detecção de funcionário/colaborador interno ───────────────────────────
|
||||
// Se o remetente for um agente cadastrado em accounts, injeta aviso para a IA
|
||||
// não tratar como cliente externo — evita paradoxos como "como falo com [eu mesmo]?"
|
||||
const account = await findAccountByPhone(phone)
|
||||
if (account) {
|
||||
hasData = true
|
||||
result.eh_funcionario = true
|
||||
result.funcionario = {
|
||||
nome: account.name,
|
||||
cargo: account.role,
|
||||
disponivel: account.availability === 'online',
|
||||
}
|
||||
}
|
||||
|
||||
// ── WPP-02: Cliente do módulo Cotação (PF/PJ) ─────────────────────────────
|
||||
const cliente = await findClienteByPhone(phone, tenantId)
|
||||
if (cliente) {
|
||||
hasData = true
|
||||
result.cliente_cadastro = {
|
||||
nome: cliente.nome,
|
||||
tipo: cliente.tipo, // PF ou PJ
|
||||
cpf_cnpj: cliente.cpf_cnpj,
|
||||
email: cliente.email ?? null,
|
||||
status: cliente.status,
|
||||
}
|
||||
|
||||
// WPP-04: cotação aberta do cliente
|
||||
const cotacao = await findCotacaoAberta(cliente.id)
|
||||
if (cotacao) {
|
||||
result.cotacao_aberta = {
|
||||
id: cotacao.id,
|
||||
status: cotacao.status,
|
||||
total: cotacao.total ? `R$ ${parseFloat(cotacao.total).toFixed(2)}` : 'R$ 0,00',
|
||||
total_itens: cotacao.total_itens,
|
||||
}
|
||||
}
|
||||
|
||||
// WPP-04: pedidos recentes do cliente (módulo cotação)
|
||||
const pedidos = await findPedidosCliente(cliente.id)
|
||||
if (pedidos.length > 0) {
|
||||
result.pedidos_cotacao = pedidos.map(p => ({
|
||||
id: p.id,
|
||||
status: descStatus(p.status, STATUS_PEDIDO),
|
||||
valor: p.total ? `R$ ${parseFloat(p.total).toFixed(2)}` : null,
|
||||
tipo_entrega: p.tipo_entrega,
|
||||
data: p.created_at ? new Date(p.created_at).toLocaleDateString('pt-BR') : null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cliente legacy (tabela users / hotspot) ───────────────────────────────
|
||||
const user = await findUserByPhone(phone)
|
||||
if (user) {
|
||||
hasData = true
|
||||
if (!result.cliente_cadastro) {
|
||||
result.cliente = {
|
||||
nome: user.nome ?? 'Não identificado',
|
||||
email: user.email ?? null,
|
||||
telefone: user.telefone ?? phone,
|
||||
cliente_desde: user.created_at ? new Date(user.created_at).toLocaleDateString('pt-BR') : null,
|
||||
}
|
||||
}
|
||||
|
||||
// Plano/Clube
|
||||
const plan = await findActivePlan(user.cpf)
|
||||
if (plan) {
|
||||
result.plano = {
|
||||
clube: plan.club_name,
|
||||
plano: plan.plan_name ?? 'Sem plano específico',
|
||||
status: plan.status,
|
||||
membro_desde: plan.member_since ? new Date(plan.member_since).toLocaleDateString('pt-BR') : null,
|
||||
preco: plan.price ? `R$ ${parseFloat(plan.price).toFixed(2)}` : null,
|
||||
}
|
||||
}
|
||||
|
||||
// Pedidos do cliente (app de cotação, via user_id)
|
||||
const orders = await findRecentOrders(user.id)
|
||||
if (orders.length > 0) {
|
||||
result.pedidos_recentes = orders.map(o => ({
|
||||
protocolo: o.protocolo || `#${o.id}`,
|
||||
status: o.status,
|
||||
forma_pagto: o.forma_pagto || null,
|
||||
tipo_entrega: o.tipo_entrega || null,
|
||||
valor: o.total ? `R$ ${parseFloat(o.total).toFixed(2)}` : null,
|
||||
financeiro: o.financeiro_status || null,
|
||||
data: o.created_at ? new Date(o.created_at).toLocaleDateString('pt-BR') : null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Promoções/produtos: gate por intenção (economia de tokens) ──────────
|
||||
// Carrega promoções e destaques só se o cliente está em modo "explorar":
|
||||
// - saudação simples (mostrar o que tem de novo)
|
||||
// - intent de promoção/clube/produto
|
||||
// - mensagem com tokens significativos (busca de produto)
|
||||
const querUmCatalogo = intents.saudacao_simples ||
|
||||
intents.pergunta_clube ||
|
||||
/\b(promo[çc][aã]o|oferta|desconto|tem\s+(o\s+que|algo)|cat[áa]logo|destaque)\b/i.test(userMsg)
|
||||
|
||||
if (querUmCatalogo) {
|
||||
const promos = await findActivePromotions(tenantId)
|
||||
if (promos.length > 0) {
|
||||
hasData = true
|
||||
result.promocoes_ativas = promos.slice(0, 3).map(p => ({
|
||||
produto: p.nome,
|
||||
preco_promocional: `R$ ${parseFloat(p.preco_promocional).toFixed(2)}`,
|
||||
desconto_pct: p.preco_original
|
||||
? `${Math.round((1 - p.preco_promocional / p.preco_original) * 100)}%`
|
||||
: null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Busca de produtos por palavras-chave da mensagem ────────────────────
|
||||
// Só dispara se a mensagem tiver tokens significativos (não só saudação)
|
||||
if (!intents.saudacao_simples && !intents.despedida && !intents.msg_curta_ambigua) {
|
||||
const tokens = tokenize(userMsg)
|
||||
if (tokens.length > 0) {
|
||||
const buscados = await findProdutosByKeywords(tokens, tenantId, 8)
|
||||
if (buscados.length > 0) {
|
||||
hasData = true
|
||||
result.produtos_buscados = buscados.map(p => ({
|
||||
nome: p.nome,
|
||||
sku: p.sku,
|
||||
preco: p.preco_promo
|
||||
? `R$ ${parseFloat(p.preco_promo).toFixed(2)} (promo, era R$ ${parseFloat(p.preco).toFixed(2)})`
|
||||
: `R$ ${parseFloat(p.preco).toFixed(2)}`,
|
||||
unidade: p.unidade,
|
||||
estoque: Number(p.estoque) > 0 ? 'disponível' : 'indisponível',
|
||||
categoria: p.categoria,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sorteios ativos (somente se houver intenção/menção) ────────────────
|
||||
if (intents.pergunta_sorteio) {
|
||||
const sorteios = await findSorteiosAtivos(tenantId)
|
||||
if (sorteios.length > 0) {
|
||||
hasData = true
|
||||
result.sorteios_ativos = sorteios.map(s => ({
|
||||
id: s.id,
|
||||
titulo: s.titulo,
|
||||
premio: s.premio,
|
||||
data_sorteio: s.data_sorteio ? new Date(s.data_sorteio).toLocaleDateString('pt-BR') : null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mídias recentes (PDF / imagem processados neste chat) ─────────────────
|
||||
// Carrega sempre que houver chatId e a mensagem atual não for uma saudação
|
||||
// simples sem contexto — evita ruído em "oi" de primeiro contato.
|
||||
if (chatId && !intents.saudacao_simples) {
|
||||
const midias = await findRecentMedia(chatId)
|
||||
if (midias.length > 0) {
|
||||
result.midias_recentes = midias
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Carrinho abandonado (cotação salva sem finalizar há > 4h) ──────────
|
||||
// Só checa se temos cliente ou user identificado
|
||||
if (cliente || user) {
|
||||
try {
|
||||
const ownerCol = cliente ? 'cliente_id' : 'user_id'
|
||||
const ownerVal = cliente ? cliente.id : user.id
|
||||
const carrinho = await queryOne(
|
||||
`SELECT id, total, updated_at,
|
||||
(SELECT COUNT(*) FROM cotacao_itens ci WHERE ci.cotacao_id = c.id) AS total_itens
|
||||
FROM cotacoes c
|
||||
WHERE ${ownerCol} = $1 AND tenant_id = $2
|
||||
AND status IN ('rascunho','salva')
|
||||
AND updated_at < NOW() - INTERVAL '4 hours'
|
||||
AND updated_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY updated_at DESC LIMIT 1`,
|
||||
[ownerVal, tenantId],
|
||||
)
|
||||
if (carrinho && Number(carrinho.total_itens) > 0) {
|
||||
result.carrinho_abandonado = {
|
||||
id: carrinho.id,
|
||||
total_itens: Number(carrinho.total_itens),
|
||||
valor: carrinho.total ? `R$ ${parseFloat(carrinho.total).toFixed(2)}` : null,
|
||||
desde: carrinho.updated_at ? new Date(carrinho.updated_at).toLocaleDateString('pt-BR') : null,
|
||||
}
|
||||
}
|
||||
} catch { /* tabelas podem variar */ }
|
||||
}
|
||||
|
||||
// ── Lookup direto por menção na mensagem (cotação #N, pedido #N, PED-…) ──
|
||||
const mentions = extractMentions(userMsg)
|
||||
// Busca itens de um pedido para incluir no contexto da IA
|
||||
async function itensResumidosPedido(pedidoId) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT nome_produto, quantidade, observacao FROM pedido_itens WHERE pedido_id=$1 ORDER BY id LIMIT 20`,
|
||||
[pedidoId]
|
||||
)
|
||||
return rows.map(i => {
|
||||
const obs = i.observacao ? ` (obs: ${i.observacao})` : ''
|
||||
return `${Number(i.quantidade)}x ${i.nome_produto}${obs}`
|
||||
})
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
async function itensResumidosCotacao(cotacaoId) {
|
||||
try {
|
||||
const rows = await query(
|
||||
`SELECT nome_produto, quantidade, observacao FROM cotacao_itens WHERE cotacao_id=$1 ORDER BY id LIMIT 20`,
|
||||
[cotacaoId]
|
||||
)
|
||||
return rows.map(i => {
|
||||
const obs = i.observacao ? ` (obs: ${i.observacao})` : ''
|
||||
return `${Number(i.quantidade)}x ${i.nome_produto}${obs}`
|
||||
})
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
if (mentions.length > 0) {
|
||||
const consultados = []
|
||||
for (const m of mentions) {
|
||||
if (m.type === 'cotacao_id') {
|
||||
const row = await findCotacaoById(m.value, tenantId)
|
||||
if (row) {
|
||||
const jaEPedido = row.cotacao_status === 'finalizada' && row.pedido_id
|
||||
if (jaEPedido) {
|
||||
// Cotação virou pedido — a IA deve falar do pedido, não da cotação
|
||||
const itens = await itensResumidosPedido(row.pedido_id)
|
||||
consultados.push({
|
||||
nota_ia: `O cliente se referiu a "cotação #${m.value}" mas esta já foi confirmada e gerou um PEDIDO. Responda sempre sobre o PEDIDO abaixo, nunca diga "cotação finalizada".`,
|
||||
pedido_protocolo: row.protocolo ?? `#${row.pedido_id}`,
|
||||
pedido_status: descStatus(row.pedido_status, STATUS_PEDIDO),
|
||||
forma_pagto: row.forma_pagto ?? null,
|
||||
tipo_entrega: row.tipo_entrega ?? null,
|
||||
financeiro: descStatus(row.financeiro_status, FINANCEIRO_STATUS),
|
||||
valor: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
|
||||
criado_em: row.pedido_criado_em ? new Date(row.pedido_criado_em).toLocaleDateString('pt-BR') : null,
|
||||
itens: itens.length > 0 ? itens : undefined,
|
||||
})
|
||||
} else {
|
||||
// Ainda é cotação (rascunho ou salva, sem pedido gerado)
|
||||
const itens = await itensResumidosCotacao(row.id)
|
||||
consultados.push({
|
||||
cotacao_id: row.id,
|
||||
cotacao_status: descStatus(row.cotacao_status, STATUS_COTACAO),
|
||||
total: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
|
||||
itens: itens.length > 0 ? itens : undefined,
|
||||
})
|
||||
}
|
||||
hasData = true
|
||||
}
|
||||
} else if (m.type === 'protocolo') {
|
||||
const row = await findPedidoByProtocolo(m.value, tenantId)
|
||||
if (row) {
|
||||
const itens = await itensResumidosPedido(row.id)
|
||||
consultados.push({
|
||||
pedido_protocolo: row.protocolo,
|
||||
pedido_status: descStatus(row.status, STATUS_PEDIDO),
|
||||
forma_pagto: row.forma_pagto ?? null,
|
||||
tipo_entrega: row.tipo_entrega ?? null,
|
||||
financeiro: descStatus(row.financeiro_status, FINANCEIRO_STATUS),
|
||||
valor: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
|
||||
criado_em: row.created_at ? new Date(row.created_at).toLocaleDateString('pt-BR') : null,
|
||||
itens: itens.length > 0 ? itens : undefined,
|
||||
})
|
||||
hasData = true
|
||||
}
|
||||
} else if (m.type === 'pedido_id') {
|
||||
// 1) tenta protocolo terminando com o número (ex: PED-…-1)
|
||||
let pedRow = await findPedidoByProtocolo(`%${m.value}`, tenantId).catch(() => null)
|
||||
// 2) tenta pedido por id numérico direto
|
||||
if (!pedRow) {
|
||||
pedRow = await queryOne(
|
||||
`SELECT id, protocolo, status, forma_pagto, tipo_entrega, financeiro_status, total, created_at
|
||||
FROM pedidos WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
|
||||
[m.value, tenantId]
|
||||
).catch(() => null)
|
||||
}
|
||||
if (pedRow) {
|
||||
const itens = await itensResumidosPedido(pedRow.id)
|
||||
consultados.push({
|
||||
pedido_protocolo: pedRow.protocolo ?? `#${pedRow.id}`,
|
||||
pedido_status: descStatus(pedRow.status, STATUS_PEDIDO),
|
||||
forma_pagto: pedRow.forma_pagto ?? null,
|
||||
tipo_entrega: pedRow.tipo_entrega ?? null,
|
||||
financeiro: descStatus(pedRow.financeiro_status, FINANCEIRO_STATUS),
|
||||
valor: pedRow.total ? `R$ ${parseFloat(pedRow.total).toFixed(2)}` : null,
|
||||
criado_em: pedRow.created_at ? new Date(pedRow.created_at).toLocaleDateString('pt-BR') : null,
|
||||
itens: itens.length > 0 ? itens : undefined,
|
||||
})
|
||||
hasData = true
|
||||
} else {
|
||||
// 3) último fallback: o cliente pode estar referindo-se ao número da cotação
|
||||
// (ex: "pedido #1" → cotação #1 → PED-20260426-3)
|
||||
const cot = await findCotacaoById(m.value, tenantId)
|
||||
if (cot) {
|
||||
const jaEPedido = cot.cotacao_status === 'finalizada' && cot.pedido_id
|
||||
if (jaEPedido) {
|
||||
const itens = await itensResumidosPedido(cot.pedido_id)
|
||||
consultados.push({
|
||||
nota_ia: `O cliente disse "pedido #${m.value}" mas este número é o da cotação original. O pedido real é ${cot.protocolo ?? '#' + cot.pedido_id}. Informe o protocolo correto ao cliente.`,
|
||||
pedido_protocolo: cot.protocolo ?? `#${cot.pedido_id}`,
|
||||
pedido_status: descStatus(cot.pedido_status, STATUS_PEDIDO),
|
||||
forma_pagto: cot.forma_pagto ?? null,
|
||||
tipo_entrega: cot.tipo_entrega ?? null,
|
||||
financeiro: descStatus(cot.financeiro_status, FINANCEIRO_STATUS),
|
||||
valor: cot.total ? `R$ ${parseFloat(cot.total).toFixed(2)}` : null,
|
||||
criado_em: cot.pedido_criado_em ? new Date(cot.pedido_criado_em).toLocaleDateString('pt-BR') : null,
|
||||
itens: itens.length > 0 ? itens : undefined,
|
||||
})
|
||||
} else {
|
||||
const itens = await itensResumidosCotacao(cot.id)
|
||||
consultados.push({
|
||||
cotacao_id: cot.id,
|
||||
cotacao_status: descStatus(cot.cotacao_status, STATUS_COTACAO),
|
||||
total: cot.total ? `R$ ${parseFloat(cot.total).toFixed(2)}` : null,
|
||||
itens: itens.length > 0 ? itens : undefined,
|
||||
})
|
||||
}
|
||||
hasData = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (consultados.length > 0) result.consulta_mencionada = consultados
|
||||
}
|
||||
|
||||
if (!hasData) return null
|
||||
|
||||
// ── SystemExtra: header de grounding + sinais em tempo real ──────────────
|
||||
// O brain (sector_brain_nodes) já carrega persona, regras, conhecimento.
|
||||
// Aqui adicionamos só o que muda por mensagem: rotular DADOS_REAIS + flags.
|
||||
const sinais = []
|
||||
|
||||
// Grounding: nomeia o JSON do contexto como DADOS_REAIS (alinhado com brain)
|
||||
sinais.push(
|
||||
'[DADOS_REAIS DESTA CONVERSA]\n' +
|
||||
'O JSON de "context" abaixo é a única fonte da verdade para fatos do cliente, pedidos e produtos. ' +
|
||||
'NUNCA afirme valores, status ou prazos que não estejam nele. ' +
|
||||
'Se o dado não está em DADOS_REAIS: pergunte ao cliente OU chame escalar_humano.'
|
||||
)
|
||||
|
||||
// Sinais em tempo real (não estão em CONHECIMENTO estático)
|
||||
const flags = []
|
||||
if (!result.contexto_temporal.loja_aberta) {
|
||||
flags.push(`LOJA_FECHADA (${result.contexto_temporal.dia_semana} ${result.contexto_temporal.hora_local})`)
|
||||
}
|
||||
if (result.consulta_mencionada?.length > 0) flags.push('PERGUNTA_SOBRE_PEDIDO_ESPECIFICO')
|
||||
if (result.carrinho_abandonado) flags.push(`CARRINHO_ABANDONADO_${result.carrinho_abandonado.total_itens}_itens_desde_${result.carrinho_abandonado.desde}`)
|
||||
if (intents.urgencia) flags.push('URGENCIA')
|
||||
if (intents.frustracao) flags.push('FRUSTRACAO')
|
||||
if (intents.despedida) flags.push('DESPEDIDA')
|
||||
if (intents.msg_curta_ambigua) flags.push('MSG_AMBIGUA')
|
||||
if (!user && !cliente) flags.push('CLIENTE_NAO_IDENTIFICADO')
|
||||
if (cliente?.tipo === 'PJ') flags.push('CLIENTE_PJ')
|
||||
if (result.midias_recentes?.length > 0) {
|
||||
const tipos = result.midias_recentes.map(m => `${m.tipo}(${m.enviado})`).join(',')
|
||||
flags.push(`MIDIA_RECENTE[${tipos}]`)
|
||||
}
|
||||
|
||||
if (flags.length > 0) sinais.push('[SINAIS]\n' + flags.join(' | '))
|
||||
|
||||
result._systemExtra = sinais.join('\n\n')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = { buildContext, extractMentions, findCotacaoById, findClienteByPhone, normalizePhone }
|
||||
@@ -0,0 +1,176 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* follow-up-detector.js — detecta quando o cliente está cobrando uma resposta
|
||||
* que ele acha que ficou sem retorno.
|
||||
*
|
||||
* Padrões típicos no WhatsApp:
|
||||
* - "?????" / "???" / "??" — apenas pontuação
|
||||
* - "verificou?" "verificou??"
|
||||
* - "viu?" "olá??"
|
||||
* - "alô??" "ei??"
|
||||
* - "tá aí?" "alguém aí?"
|
||||
* - "e aí?" "e então?"
|
||||
* - "?" (interrogação isolada)
|
||||
* - mensagens curtas (<= 2 palavras) terminando com ?
|
||||
*
|
||||
* Quando detecta, recupera a última pergunta do cliente que ficou sem resposta
|
||||
* e devolve um payload enriquecido para a IA: "[Cliente está cobrando a resposta
|
||||
* de uma pergunta anterior — pergunta original: 'X']".
|
||||
*
|
||||
* Bypassa cooldown — cliente impaciente precisa de feedback rápido (mesmo que
|
||||
* seja só uma mensagem-segura "estamos verificando, um momento").
|
||||
*/
|
||||
|
||||
const { query, queryOne } = require('../../database/postgres')
|
||||
|
||||
// ── Detecção de padrão ────────────────────────────────────────────────────────
|
||||
|
||||
const PING_PATTERNS = [
|
||||
/^\s*\?{1,}\s*$/, // só "?", "??", "?????"
|
||||
/^\s*\.{2,}\s*$/, // "..", "..." (cobrança silenciosa)
|
||||
/^\s*(verificou|viu|olha|al[ôo]|ei|oi|ol[áa])\s*\?+\s*$/i,
|
||||
/^\s*(t[áa]|est[áa])\s+a[íi]\s*\?+\s*$/i,
|
||||
/^\s*(algu[ée]m|tem\s+algu[ée]m)\s+a[íi]\s*\?+\s*$/i,
|
||||
/^\s*e?\s*(a[íi]|ent[ãa]o|n[ãa]o)\s*\?+\s*$/i,
|
||||
/^\s*(j[áa]\s+)?(viu|verificou|conferiu|olhou)\s*\?+\s*$/i,
|
||||
/^\s*(consegue|conseguiu|deu|teve)\s+(ver|olhar|verificar)\s*\?+\s*$/i,
|
||||
/^\s*(pra|para)?\s*hoje\s*\?+\s*$/i,
|
||||
/^\s*responde\s+a[íi]\s*\??\s*$/i,
|
||||
/^\s*me\s+responde\s*\??\s*$/i,
|
||||
]
|
||||
|
||||
/**
|
||||
* Retorna true se a mensagem parece uma cobrança/ping de follow-up.
|
||||
* @param {string} body — texto da mensagem
|
||||
*/
|
||||
function isFollowUpPing(body) {
|
||||
if (!body) return false
|
||||
const t = body.trim()
|
||||
// Mensagens longas (>40 chars) raramente são pings
|
||||
if (t.length > 40) return false
|
||||
for (const re of PING_PATTERNS) {
|
||||
if (re.test(t)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Lookup da pergunta anterior sem resposta ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Busca a última pergunta do cliente neste chat que ainda não foi respondida.
|
||||
* "Não respondida" = não há mensagem fromMe=true ou auto_reply com status='sent*'
|
||||
* APÓS aquela mensagem do cliente.
|
||||
*
|
||||
* @param {string} chatId
|
||||
* @param {number} lookbackMinutes — janela máxima para buscar (default 24h)
|
||||
* @returns {Promise<{ body: string, ageMinutes: number } | null>}
|
||||
*/
|
||||
async function findPendingQuestion(chatId, lookbackMinutes = 1440) {
|
||||
if (!chatId) return null
|
||||
|
||||
// Pega últimos eventos do chat na janela
|
||||
const events = await query(
|
||||
`SELECT direction, from_me, msg_type, payload, created_at
|
||||
FROM nw_event_logs
|
||||
WHERE chat_id = $1
|
||||
AND event = 'message.new'
|
||||
AND created_at > NOW() - ($2 || ' minutes')::interval
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[chatId, String(lookbackMinutes)],
|
||||
).catch(() => [])
|
||||
|
||||
if (!events.length) return null
|
||||
|
||||
// events[0] é o ping ATUAL (mais recente). É o limite superior do intervalo
|
||||
// de busca: queremos saber se a pergunta original foi respondida ENTRE
|
||||
// o momento em que ela foi feita E o momento do ping atual.
|
||||
const currentPingTime = events[0].created_at
|
||||
|
||||
// Procura, do mais recente para o mais antigo (pulando o próprio ping):
|
||||
// - Última msg do cliente (from_me=false) que NÃO seja outro ping
|
||||
// - Se houver fromMe=true entre ela e o ping atual → já foi respondida
|
||||
let foundClientMsg = null
|
||||
let hasOperatorBetween = false
|
||||
|
||||
for (let i = 1; i < events.length; i++) { // i=1 pula o ping atual
|
||||
const ev = events[i]
|
||||
const body = ev.payload?.body ?? ''
|
||||
|
||||
if (ev.from_me) {
|
||||
// Resposta do operador/bot (entre alguma msg antiga do cliente e o ping atual)
|
||||
hasOperatorBetween = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Mensagem do cliente — pula pings sucessivos ("?", "??", "alô?")
|
||||
if (!body || !body.trim() || isFollowUpPing(body)) continue
|
||||
|
||||
// Achou pergunta substantiva. Se houve operador entre ela e o ping → respondida.
|
||||
if (hasOperatorBetween) return null
|
||||
foundClientMsg = ev
|
||||
break
|
||||
}
|
||||
|
||||
if (!foundClientMsg) return null
|
||||
|
||||
// Confirma checando nw_auto_replies — bot pode ter respondido sem aparecer
|
||||
// como event from_me=true (depende do echo da motor). Janela: estritamente
|
||||
// entre o momento da pergunta e o momento do ping atual (exclusivo).
|
||||
//
|
||||
// EXCLUI auto_replies cujo `user_msg` foi um ping ou um body enriquecido
|
||||
// de ping anterior — essas respostas NÃO contam como ter "respondido a
|
||||
// pergunta original", já que foram só reações a cobranças genéricas.
|
||||
const repliesBetween = await query(
|
||||
`SELECT user_msg FROM nw_auto_replies
|
||||
WHERE chat_id = $1
|
||||
AND created_at > $2
|
||||
AND created_at < $3
|
||||
AND status IN ('sent','sent_local')
|
||||
ORDER BY created_at`,
|
||||
[chatId, foundClientMsg.created_at, currentPingTime],
|
||||
).catch(() => [])
|
||||
|
||||
const isPingReply = (userMsg) => {
|
||||
if (!userMsg) return true
|
||||
const t = userMsg.trim()
|
||||
// Body enriquecido por buildEnrichedBody começa com '[O cliente enviou apenas' ou '[Atenção: o cliente está cobrando'
|
||||
if (/^\[(O cliente enviou apenas|Atenção: o cliente está cobrando)/i.test(t)) return true
|
||||
return isFollowUpPing(t)
|
||||
}
|
||||
|
||||
const hasRealReply = repliesBetween.some(r => !isPingReply(r.user_msg))
|
||||
if (hasRealReply) return null // já foi respondida com conteúdo real
|
||||
|
||||
const ageMs = Date.now() - new Date(foundClientMsg.created_at).getTime()
|
||||
return {
|
||||
body: (foundClientMsg.payload?.body ?? '').trim(),
|
||||
ageMinutes: Math.round(ageMs / 60000),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compõe a mensagem que será enviada ao LLM, dando contexto ao ping.
|
||||
* Se há pergunta pendente: instrui a IA a responder ela.
|
||||
* Se não há (ping aleatório, sem histórico): saudação simples.
|
||||
*/
|
||||
function buildEnrichedBody(pingBody, pending) {
|
||||
if (pending) {
|
||||
return (
|
||||
`[Atenção: o cliente está cobrando uma resposta. Há ${pending.ageMinutes}min ele perguntou:\n` +
|
||||
`"${pending.body}"\n` +
|
||||
`Agora ele reenviou apenas "${pingBody}" para chamar atenção. ` +
|
||||
`Responda à pergunta original o mais diretamente possível. ` +
|
||||
`Se realmente não souber a resposta ou se for assunto que precisa de operador humano, ` +
|
||||
`peça desculpa pela demora e diga que vai chamar um atendente.]`
|
||||
)
|
||||
}
|
||||
// Sem pergunta pendente — saudação leve
|
||||
return (
|
||||
`[O cliente enviou apenas "${pingBody}" sem contexto anterior nas últimas 2h. ` +
|
||||
`Responda com uma saudação curta perguntando como pode ajudar.]`
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { isFollowUpPing, findPendingQuestion, buildEnrichedBody }
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict'
|
||||
|
||||
const { createRoutes } = require('./routes')
|
||||
const { createRestProxy, attachWsProxy } = require('./proxy')
|
||||
const { createWebhookReceiver } = require('./webhook-receiver')
|
||||
|
||||
const plugin = {
|
||||
async activate(ctx) {
|
||||
// ── Rotas /nw/* (secretária IA consulta o satélite) ───────────────────
|
||||
const router = createRoutes()
|
||||
ctx.app.use('/nw', router)
|
||||
ctx.logger.info('[newwhats] Rotas registradas em /nw')
|
||||
|
||||
// ── Receptor de webhooks (motor → satélite, sem autenticação de sessão)
|
||||
ctx.app.use(createWebhookReceiver())
|
||||
ctx.logger.info('[newwhats] Receptor de webhook ativo em POST /api/webhook/newwhats')
|
||||
|
||||
// ── Rotas especiais: sec_numbers (rota interna do motor, não ext-api) ────
|
||||
// /api/nw/v1/secretaria/numbers → motor /api/secretaria/numbers (sem auth)
|
||||
const { queryOne: cfgQuery } = require('../../database/postgres')
|
||||
async function getMotorUrl() {
|
||||
const row = await cfgQuery("SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'newwhats_url'")
|
||||
return row?.value?.replace(/\/$/, '') || null
|
||||
}
|
||||
async function numbersProxy(req, res) {
|
||||
const motorUrl = await getMotorUrl()
|
||||
if (!motorUrl) return res.status(503).json({ error: 'Motor não configurado' })
|
||||
const qs = req.originalUrl.split('?')[1]
|
||||
const path = `/api/secretaria/numbers${req.params[0] ? '/' + req.params[0] : ''}${qs ? '?' + qs : ''}`
|
||||
try {
|
||||
const opts = { method: req.method, headers: { 'Content-Type': 'application/json' } }
|
||||
if (['POST','PUT','PATCH'].includes(req.method)) opts.body = JSON.stringify(req.body)
|
||||
const r = await fetch(`${motorUrl}${path}`, opts)
|
||||
const data = await r.json()
|
||||
res.status(r.status).json(data)
|
||||
} catch (e) { res.status(502).json({ error: `Motor indisponível: ${e.message}` }) }
|
||||
}
|
||||
ctx.app.get('/api/nw/v1/secretaria/numbers', numbersProxy)
|
||||
ctx.app.post('/api/nw/v1/secretaria/numbers', numbersProxy)
|
||||
ctx.app.put('/api/nw/v1/secretaria/numbers/:id', (req, res) => {
|
||||
req.params[0] = req.params.id; numbersProxy(req, res)
|
||||
})
|
||||
ctx.app.delete('/api/nw/v1/secretaria/numbers/:id', (req, res) => {
|
||||
req.params[0] = req.params.id; numbersProxy(req, res)
|
||||
})
|
||||
ctx.logger.info('[newwhats] Rotas sec_numbers ativas em /api/nw/v1/secretaria/numbers')
|
||||
|
||||
// ── Proxy REST /api/nw/v1/* → motor /api/ext/v1/* ─────────────────────
|
||||
const restProxy = createRestProxy()
|
||||
ctx.app.all('/api/nw/v1/*', restProxy)
|
||||
ctx.logger.info('[newwhats] Proxy REST ativo em /api/nw/v1/*')
|
||||
|
||||
// ── Proxy WS /api/nw/v1/stream → motor /api/ext/v1/stream ─────────────
|
||||
if (ctx.httpServer) {
|
||||
attachWsProxy(ctx.httpServer)
|
||||
ctx.logger.info('[newwhats] Proxy WS ativo em /api/nw/v1/stream')
|
||||
} else {
|
||||
ctx.logger.warn('[newwhats] httpServer não disponível no ctx — proxy WS desativado')
|
||||
}
|
||||
},
|
||||
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.warn('[newwhats] Desativado. Reinicie o servidor para remover as rotas.')
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = plugin
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "newwhats",
|
||||
"name": "NewWhats — WhatsApp Inbox",
|
||||
"version": "2.1.0",
|
||||
"description": "Integração completa com o motor NewWhats: Inbox WhatsApp em tempo real (sessões, chats, mensagens via WebSocket), proxy autenticado REST + WS, e endpoints /nw/* para a Secretária IA consultar dados deste sistema.",
|
||||
"active_by_default": false,
|
||||
"ui_path": "/wa-inbox",
|
||||
"features": [
|
||||
"Inbox WhatsApp (sessões, chats, mensagens)",
|
||||
"WebSocket em tempo real (QR, status, novas mensagens)",
|
||||
"Proxy REST + WS autenticado para o motor",
|
||||
"Endpoints /nw/* para Secretária IA"
|
||||
],
|
||||
"config_schema": [
|
||||
{
|
||||
"key": "newwhats_url",
|
||||
"label": "URL do Servidor NewWhats",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"help": "Endereço do motor. Ex: http://newwhats.local:8008"
|
||||
},
|
||||
{
|
||||
"key": "integration_key",
|
||||
"label": "Chave de Integração (x-nw-key)",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"help": "Chave compartilhada entre o satélite e o motor. Usada nas rotas /nw/* e no proxy REST/WS."
|
||||
},
|
||||
{
|
||||
"key": "api_token",
|
||||
"label": "Token da API do Motor (Bearer)",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"help": "Token Bearer enviado pelo worker ao enviar mensagens. Gerado em Admin → API Keys no motor."
|
||||
},
|
||||
{
|
||||
"key": "webhook_secret",
|
||||
"label": "Segredo do Webhook (HMAC)",
|
||||
"type": "password",
|
||||
"required": false,
|
||||
"help": "Segredo para validar a assinatura x-nw-signature nos webhooks recebidos do motor. Recomendado em produção."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* media-transcribe.js — transcreve áudio (PTT) e descreve imagens recebidas
|
||||
* via WhatsApp usando Gemini 1.5 Flash (multimodal nativo).
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. webhook-receiver detecta msg.type === 'AUDIO' ou 'IMAGE'
|
||||
* 2. Baixa o blob via /api/ext/v1/media/:messageId no motor (com retry,
|
||||
* pois o motor baixa a mídia de forma assíncrona após emitir o webhook).
|
||||
* 3. Envia base64 + mime para Gemini Flash com prompt curto.
|
||||
* 4. Retorna texto pronto para entrar no fluxo de auto-reply normal.
|
||||
*
|
||||
* Custo: Gemini 1.5 Flash é ~10× mais barato que GPT-4 e processa áudio/imagem
|
||||
* nativamente sem pipeline de Whisper + OCR. Fallback para OpenAI Whisper só
|
||||
* se a chamada Gemini falhar (rede, quota, etc).
|
||||
*/
|
||||
|
||||
const GEMINI_MODEL = 'gemini-2.0-flash'
|
||||
const GEMINI_API = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`
|
||||
|
||||
const OPENAI_WHISPER_MODEL = 'whisper-1'
|
||||
const OPENAI_API_BASE = 'https://api.openai.com/v1'
|
||||
|
||||
const DOWNLOAD_RETRIES = 4 // motor baixa mídia async — espera até ~6s
|
||||
const DOWNLOAD_BACKOFF = [500, 1000, 2000, 3000]
|
||||
|
||||
const MAX_AUDIO_SECONDS = 300 // 5min — áudios maiores rejeita
|
||||
const MAX_IMAGE_BYTES = 8 * 1024 * 1024 // 8MB
|
||||
const MAX_PDF_BYTES = 20 * 1024 * 1024 // 20MB — limite inline do Gemini
|
||||
const MAX_VIDEO_BYTES = 18 * 1024 * 1024 // 18MB — Gemini inline limit prático
|
||||
|
||||
// ── Cache Redis para extrações ────────────────────────────────────────────────
|
||||
// Evita reprocessar a mesma mídia (msg.id) se motor re-entregar o webhook.
|
||||
// Chave: nw:media:<msg.id>; TTL: 24h. Falha silenciosa se Redis indisponível.
|
||||
|
||||
const CACHE_TTL_SECONDS = 86_400
|
||||
|
||||
let _redisClient = null
|
||||
function getRedis() {
|
||||
if (_redisClient) return _redisClient
|
||||
try {
|
||||
const Redis = require('ioredis')
|
||||
_redisClient = new Redis({
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false,
|
||||
lazyConnect: false,
|
||||
})
|
||||
_redisClient.on('error', () => { /* silencia spam — operação cai sem cache */ })
|
||||
} catch { _redisClient = null }
|
||||
return _redisClient
|
||||
}
|
||||
|
||||
async function cacheGet(key) {
|
||||
try {
|
||||
const r = getRedis()
|
||||
if (!r) return null
|
||||
const v = await r.get(`nw:media:${key}`)
|
||||
return v || null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function cacheSet(key, value) {
|
||||
try {
|
||||
const r = getRedis()
|
||||
if (!r || !value) return
|
||||
await r.set(`nw:media:${key}`, value, 'EX', CACHE_TTL_SECONDS)
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
|
||||
// ── Download da mídia do motor ────────────────────────────────────────────────
|
||||
|
||||
async function fetchMediaBuffer(motorUrl, integKey, messageId) {
|
||||
for (let i = 0; i < DOWNLOAD_RETRIES; i++) {
|
||||
try {
|
||||
const res = await fetch(`${motorUrl}/api/ext/v1/media/${encodeURIComponent(messageId)}`, {
|
||||
headers: { 'x-nw-key': integKey },
|
||||
redirect: 'follow',
|
||||
})
|
||||
if (res.status === 404 && i < DOWNLOAD_RETRIES - 1) {
|
||||
// motor ainda não baixou — backoff e tenta de novo
|
||||
await new Promise(r => setTimeout(r, DOWNLOAD_BACKOFF[i]))
|
||||
continue
|
||||
}
|
||||
if (!res.ok) throw new Error(`motor /media respondeu ${res.status}`)
|
||||
const mime = res.headers.get('content-type') || 'application/octet-stream'
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
return { buffer: buf, mime }
|
||||
} catch (err) {
|
||||
if (i === DOWNLOAD_RETRIES - 1) throw err
|
||||
await new Promise(r => setTimeout(r, DOWNLOAD_BACKOFF[i]))
|
||||
}
|
||||
}
|
||||
throw new Error('Mídia indisponível após retries')
|
||||
}
|
||||
|
||||
// ── Chamada Gemini ────────────────────────────────────────────────────────────
|
||||
|
||||
async function callGemini(prompt, inlineData) {
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
if (!apiKey) throw new Error('GEMINI_API_KEY não configurado')
|
||||
|
||||
const body = {
|
||||
contents: [{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: prompt },
|
||||
{ inline_data: { mime_type: inlineData.mime, data: inlineData.base64 } },
|
||||
],
|
||||
}],
|
||||
generationConfig: { temperature: 0.2, maxOutputTokens: 512 },
|
||||
}
|
||||
|
||||
const res = await fetch(`${GEMINI_API}?key=${apiKey}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`Gemini ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
|
||||
if (!text) throw new Error('Gemini retornou resposta vazia')
|
||||
return text
|
||||
}
|
||||
|
||||
// ── Transcrição de áudio ──────────────────────────────────────────────────────
|
||||
|
||||
// ── Transcrição via OpenAI Whisper (purpose-built, $0.006/min) ────────────────
|
||||
|
||||
async function transcribeWithWhisper(buffer, mime) {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
|
||||
|
||||
// FormData multipart (multipart/form-data com Buffer Blob)
|
||||
const ext = mime?.includes('mp3') ? 'mp3' : mime?.includes('wav') ? 'wav' : 'ogg'
|
||||
const blob = new Blob([buffer], { type: mime || 'audio/ogg' })
|
||||
const fd = new FormData()
|
||||
fd.append('file', blob, `audio.${ext}`)
|
||||
fd.append('model', OPENAI_WHISPER_MODEL)
|
||||
fd.append('language', 'pt')
|
||||
fd.append('response_format', 'text')
|
||||
|
||||
const res = await fetch(`${OPENAI_API_BASE}/audio/transcriptions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`Whisper ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const text = (await res.text()).trim()
|
||||
if (!text) throw new Error('Whisper devolveu vazio')
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcreve um áudio (PTT) recebido via WhatsApp.
|
||||
* Tenta Whisper (mais confiável e barato para áudio) e cai para Gemini se falhar.
|
||||
* @returns {Promise<string|null>} texto transcrito ou null em caso de falha
|
||||
*/
|
||||
async function transcribeAudio({ motorUrl, integKey, messageId }) {
|
||||
try {
|
||||
const cached = await cacheGet(`audio:${messageId}`)
|
||||
if (cached) {
|
||||
console.log(`[transcribe] ⚡ cache hit (${cached.length} chars)`)
|
||||
return cached
|
||||
}
|
||||
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
|
||||
|
||||
if (buffer.length > 16 * 1024 * 1024) {
|
||||
console.warn('[transcribe] áudio muito grande, ignorando:', buffer.length)
|
||||
return null
|
||||
}
|
||||
|
||||
// 1ª tentativa: Whisper
|
||||
try {
|
||||
const text = await transcribeWithWhisper(buffer, mime)
|
||||
console.log(`[transcribe] ✅ Whisper transcribed ${buffer.length}B → ${text.length} chars`)
|
||||
cacheSet(`audio:${messageId}`, text)
|
||||
return text
|
||||
} catch (e) {
|
||||
console.warn('[transcribe] Whisper falhou, tentando Gemini:', e.message)
|
||||
}
|
||||
|
||||
// 2ª tentativa: Gemini Flash (multimodal)
|
||||
const prompt =
|
||||
'Transcreva fielmente o áudio em português brasileiro. ' +
|
||||
'Devolva APENAS o texto transcrito, sem comentários, sem timestamps e sem prefixo. ' +
|
||||
'Se o áudio estiver vazio, com muito ruído ou inaudível, devolva exatamente: [inaudível]'
|
||||
|
||||
const text = await callGemini(prompt, {
|
||||
mime: mime?.startsWith('audio/') ? mime : 'audio/ogg',
|
||||
base64: buffer.toString('base64'),
|
||||
})
|
||||
|
||||
if (/^\[inaudível\]/i.test(text)) return null
|
||||
console.log(`[transcribe] ✅ Gemini transcribed → ${text.length} chars`)
|
||||
cacheSet(`audio:${messageId}`, text)
|
||||
return text
|
||||
} catch (err) {
|
||||
console.warn('[transcribe-audio]', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Descrição de imagem ───────────────────────────────────────────────────────
|
||||
|
||||
// ── Vision via OpenAI gpt-4o-mini ($0.15/1M input tokens, suporta imagem) ────
|
||||
|
||||
async function describeWithOpenAI(buffer, mime, captionFromUser) {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
|
||||
|
||||
const dataUrl = `data:${mime || 'image/jpeg'};base64,${buffer.toString('base64')}`
|
||||
const userText =
|
||||
'Analise a imagem enviada por um cliente de uma loja de conveniência via WhatsApp. ' +
|
||||
'Classifique em UMA destas categorias e descreva em uma frase curta:\n' +
|
||||
'- COMPROVANTE_PIX: comprovantes de pagamento PIX/boleto/transferência. Extraia valor e destinatário se visíveis.\n' +
|
||||
'- RECEITA_MEDICA: receitas médicas/odontológicas. Extraia medicamento, médico/CRM se legíveis.\n' +
|
||||
'- PRODUTO: foto de produto (alimento, bebida, item de mercado). Identifique marca/produto.\n' +
|
||||
'- DOCUMENTO: RG, CPF, CNH, conta de luz/água, contrato.\n' +
|
||||
'- OUTRO: qualquer outra coisa.\n\n' +
|
||||
(captionFromUser ? `Legenda do cliente: "${captionFromUser}"\n\n` : '') +
|
||||
'Devolva no máximo UMA linha no formato exato: TIPO: descrição. Sem markdown, sem comentários.'
|
||||
|
||||
const body = {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: userText },
|
||||
{ type: 'image_url', image_url: { url: dataUrl } },
|
||||
],
|
||||
}],
|
||||
max_tokens: 200,
|
||||
temperature: 0.2,
|
||||
}
|
||||
|
||||
const res = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`OpenAI vision ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.choices?.[0]?.message?.content?.trim()
|
||||
if (!text) throw new Error('OpenAI vision devolveu vazio')
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Analisa uma imagem recebida e retorna uma descrição curta + classificação
|
||||
* útil para o LLM entender a intenção (comprovante, receita, foto de produto…).
|
||||
*
|
||||
* Tenta Gemini Flash (multimodal nativo) primeiro; se falhar (quota/rede),
|
||||
* cai para OpenAI gpt-4o-mini vision.
|
||||
*
|
||||
* @returns {Promise<string|null>} texto pronto para entrar no fluxo
|
||||
* formato: "[imagem] TIPO: descrição curta"
|
||||
*/
|
||||
async function describeImage({ motorUrl, integKey, messageId, captionFromUser }) {
|
||||
try {
|
||||
const cached = await cacheGet(`image:${messageId}`)
|
||||
if (cached) {
|
||||
console.log(`[describe-image] ⚡ cache hit (${cached.length} chars)`)
|
||||
return cached
|
||||
}
|
||||
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
|
||||
if (buffer.length > MAX_IMAGE_BYTES) {
|
||||
console.warn('[describe-image] imagem muito grande:', buffer.length)
|
||||
return null
|
||||
}
|
||||
|
||||
const prompt =
|
||||
'Analise a imagem enviada por um cliente de uma loja de conveniência via WhatsApp. ' +
|
||||
'Classifique em UMA destas categorias e descreva em uma frase curta:\n' +
|
||||
'- COMPROVANTE_PIX: prints/fotos de comprovante de pagamento PIX/boleto/transferência. Extraia valor e nome do destinatário se visível.\n' +
|
||||
'- RECEITA_MEDICA: receitas médicas/odontológicas. Extraia nome do medicamento, médico/CRM se legível.\n' +
|
||||
'- PRODUTO: foto de um produto (alimento, bebida, item de mercado). Identifique marca/produto.\n' +
|
||||
'- DOCUMENTO: RG, CPF, CNH, conta de luz/água, contrato.\n' +
|
||||
'- OUTRO: qualquer outra coisa.\n\n' +
|
||||
(captionFromUser ? `O cliente escreveu junto com a imagem: "${captionFromUser}"\n\n` : '') +
|
||||
'Devolva NO MÁXIMO uma linha no formato exato: TIPO: descrição curta. ' +
|
||||
'Não use markdown. Não comente.'
|
||||
|
||||
// 1ª tentativa: Gemini (mais barato em alta escala se quota disponível)
|
||||
try {
|
||||
const text = await callGemini(prompt, {
|
||||
mime: mime?.startsWith('image/') ? mime : 'image/jpeg',
|
||||
base64: buffer.toString('base64'),
|
||||
})
|
||||
console.log(`[describe-image] ✅ Gemini → ${text.length} chars`)
|
||||
const result = `[imagem] ${text}`
|
||||
cacheSet(`image:${messageId}`, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn('[describe-image] Gemini falhou, caindo para OpenAI:', e.message)
|
||||
}
|
||||
|
||||
// 2ª tentativa: OpenAI Vision
|
||||
const text = await describeWithOpenAI(buffer, mime, captionFromUser)
|
||||
console.log(`[describe-image] ✅ OpenAI → ${text.length} chars`)
|
||||
const result = `[imagem] ${text}`
|
||||
cacheSet(`image:${messageId}`, result)
|
||||
return result
|
||||
} catch (err) {
|
||||
console.warn('[describe-image]', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extração de texto de PDF ──────────────────────────────────────────────────
|
||||
|
||||
const PDF_PROMPT = (captionFromUser) =>
|
||||
'Extraia o conteúdo deste documento PDF enviado por um cliente via WhatsApp. ' +
|
||||
'Identifique o tipo do documento (cupom fiscal, nota fiscal, comprovante de pagamento, ' +
|
||||
'contrato, boleto, pedido, etc.) e extraia as informações mais relevantes como: ' +
|
||||
'número do pedido/protocolo, valores, datas, produtos listados, nome do cliente/empresa, ' +
|
||||
'status do pedido, CNPJ/CPF e qualquer instrução ao cliente. ' +
|
||||
(captionFromUser ? `O cliente escreveu junto com o arquivo: "${captionFromUser}"\n\n` : '') +
|
||||
'Devolva em texto corrido objetivo, máximo 3 parágrafos. Sem markdown, sem comentários.'
|
||||
|
||||
async function extractPdfWithOpenAI(buffer, captionFromUser) {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
|
||||
|
||||
const body = {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'file',
|
||||
file: {
|
||||
filename: 'document.pdf',
|
||||
file_data: `data:application/pdf;base64,${buffer.toString('base64')}`,
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: PDF_PROMPT(captionFromUser) },
|
||||
],
|
||||
}],
|
||||
max_tokens: 1024,
|
||||
temperature: 0.2,
|
||||
}
|
||||
|
||||
const res = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`OpenAI PDF ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.choices?.[0]?.message?.content?.trim()
|
||||
if (!text) throw new Error('OpenAI devolveu vazio')
|
||||
return text
|
||||
}
|
||||
|
||||
async function extractPdfWithClaude(buffer, captionFromUser) {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (!apiKey) throw new Error('ANTHROPIC_API_KEY não configurado')
|
||||
|
||||
const body = {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 1024,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'document',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'application/pdf',
|
||||
data: buffer.toString('base64'),
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: PDF_PROMPT(captionFromUser) },
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '')
|
||||
throw new Error(`Anthropic PDF ${res.status}: ${txt.slice(0, 200)}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = data?.content?.[0]?.text?.trim()
|
||||
if (!text) throw new Error('Anthropic devolveu vazio')
|
||||
return text
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai e resume o conteúdo de um PDF recebido via WhatsApp.
|
||||
* Cadeia de fallback: Gemini 2.0 Flash → OpenAI gpt-4o-mini → Claude Haiku.
|
||||
*
|
||||
* @returns {Promise<string|null>} texto extraído prefixado com "[documento PDF]" ou null
|
||||
*/
|
||||
async function extractPdfText({ motorUrl, integKey, messageId, captionFromUser }) {
|
||||
try {
|
||||
const cached = await cacheGet(`pdf:${messageId}`)
|
||||
if (cached) {
|
||||
console.log(`[extract-pdf] ⚡ cache hit (${cached.length} chars)`)
|
||||
return cached
|
||||
}
|
||||
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
|
||||
|
||||
// Aceita por mime OU por magic bytes (%PDF) — motor às vezes serve
|
||||
// documentos como application/octet-stream sem distinguir o tipo real.
|
||||
const isPdfMime = mime?.toLowerCase().includes('pdf')
|
||||
const isPdfMagic = buffer.length >= 4 && buffer.slice(0, 4).toString('ascii') === '%PDF'
|
||||
if (!isPdfMime && !isPdfMagic) {
|
||||
console.log('[extract-pdf] não é PDF — mime:', mime, 'magic:', buffer.slice(0, 4).toString('hex'))
|
||||
return null
|
||||
}
|
||||
if (buffer.length > MAX_PDF_BYTES) {
|
||||
console.warn('[extract-pdf] PDF muito grande para inline:', buffer.length)
|
||||
return null
|
||||
}
|
||||
|
||||
const base64 = buffer.toString('base64')
|
||||
|
||||
// 1ª tentativa: Gemini Flash (mais barato)
|
||||
try {
|
||||
const text = await callGemini(PDF_PROMPT(captionFromUser), {
|
||||
mime: 'application/pdf',
|
||||
base64,
|
||||
})
|
||||
console.log(`[extract-pdf] ✅ Gemini extraiu ${text.length} chars (${buffer.length}B)`)
|
||||
const result = `[documento PDF] ${text}`
|
||||
cacheSet(`pdf:${messageId}`, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn('[extract-pdf] Gemini falhou, tentando OpenAI:', e.message)
|
||||
}
|
||||
|
||||
// 2ª tentativa: OpenAI gpt-4o-mini
|
||||
try {
|
||||
const text = await extractPdfWithOpenAI(buffer, captionFromUser)
|
||||
console.log(`[extract-pdf] ✅ OpenAI extraiu ${text.length} chars`)
|
||||
const result = `[documento PDF] ${text}`
|
||||
cacheSet(`pdf:${messageId}`, result)
|
||||
return result
|
||||
} catch (e) {
|
||||
console.warn('[extract-pdf] OpenAI falhou, tentando Anthropic:', e.message)
|
||||
}
|
||||
|
||||
// 3ª tentativa: Claude Haiku
|
||||
const text = await extractPdfWithClaude(buffer, captionFromUser)
|
||||
console.log(`[extract-pdf] ✅ Anthropic extraiu ${text.length} chars`)
|
||||
const result = `[documento PDF] ${text}`
|
||||
cacheSet(`pdf:${messageId}`, result)
|
||||
return result
|
||||
|
||||
} catch (err) {
|
||||
console.warn('[extract-pdf]', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vídeo (Gemini nativo, sem ffmpeg) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Processa um vídeo enviado via WhatsApp. Gemini 2.0 Flash aceita video/mp4
|
||||
* (e variantes) como inline_data nativamente, descrevendo cena + áudio juntos.
|
||||
* Para vídeos > 18MB retorna null (motor poderia subir via File API; fora do escopo).
|
||||
*
|
||||
* @returns {Promise<string|null>} texto prefixado com "[vídeo]" ou null
|
||||
*/
|
||||
async function processVideo({ motorUrl, integKey, messageId, captionFromUser }) {
|
||||
try {
|
||||
const cached = await cacheGet(`video:${messageId}`)
|
||||
if (cached) {
|
||||
console.log(`[process-video] ⚡ cache hit (${cached.length} chars)`)
|
||||
return cached
|
||||
}
|
||||
|
||||
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
|
||||
if (buffer.length > MAX_VIDEO_BYTES) {
|
||||
console.warn('[process-video] vídeo muito grande para inline:', buffer.length)
|
||||
return null
|
||||
}
|
||||
|
||||
const prompt =
|
||||
'Analise este vídeo enviado por um cliente de loja de conveniência via WhatsApp. ' +
|
||||
'Descreva em UMA frase curta o que está acontecendo na cena (foco em produto, problema, ' +
|
||||
'comprovante ou ambiente). Se houver fala/áudio relevante, transcreva o conteúdo principal. ' +
|
||||
'Classifique em UMA categoria: COMPROVANTE, PRODUTO_DEFEITO, PRODUTO_DUVIDA, RECLAMACAO, OUTRO. ' +
|
||||
(captionFromUser ? `Legenda do cliente: "${captionFromUser}"\n\n` : '') +
|
||||
'Devolva no formato exato: TIPO: descrição (+ fala transcrita se houver). Sem markdown.'
|
||||
|
||||
const text = await callGemini(prompt, {
|
||||
mime: mime?.startsWith('video/') ? mime : 'video/mp4',
|
||||
base64: buffer.toString('base64'),
|
||||
})
|
||||
|
||||
console.log(`[process-video] ✅ Gemini → ${text.length} chars (${buffer.length}B)`)
|
||||
const result = `[vídeo] ${text}`
|
||||
cacheSet(`video:${messageId}`, result)
|
||||
return result
|
||||
} catch (err) {
|
||||
console.warn('[process-video]', err.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── vCard / Contato (parsing local, sem LLM) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Faz parsing de um vCard (cartão de contato) compartilhado via WhatsApp.
|
||||
* O motor pode entregar o vCard cru no msg.body OU em msg.vcard. Aceita ambos.
|
||||
*
|
||||
* @param {{ rawVcard?: string, msgBody?: string }} input
|
||||
* @returns {string|null} texto formatado prefixado com "[contato]" ou null
|
||||
*/
|
||||
function extractVCard({ rawVcard, msgBody } = {}) {
|
||||
const text = (rawVcard || msgBody || '').trim()
|
||||
if (!text || !/BEGIN:VCARD/i.test(text)) return null
|
||||
|
||||
const pick = (re) => {
|
||||
const m = text.match(re)
|
||||
return m ? m[1].trim() : null
|
||||
}
|
||||
|
||||
const fn = pick(/^FN[^:]*:(.+)$/im)
|
||||
const n = pick(/^N[^:]*:(.+)$/im)
|
||||
const tels = [...text.matchAll(/^TEL[^:]*:(\+?[\d\s().-]+)$/gim)]
|
||||
.map(m => m[1].replace(/\D/g, '')).filter(Boolean)
|
||||
const emails = [...text.matchAll(/^EMAIL[^:]*:(\S+@\S+)$/gim)].map(m => m[1])
|
||||
const org = pick(/^ORG[^:]*:(.+)$/im)
|
||||
|
||||
const nome = fn || (n ? n.split(';').filter(Boolean).reverse().join(' ').trim() : null)
|
||||
if (!nome && tels.length === 0) return null
|
||||
|
||||
const partes = []
|
||||
if (nome) partes.push(`nome: ${nome}`)
|
||||
if (org) partes.push(`empresa: ${org}`)
|
||||
if (tels.length > 0) partes.push(`tel: ${tels.join(', ')}`)
|
||||
if (emails.length > 0) partes.push(`email: ${emails.join(', ')}`)
|
||||
|
||||
return `[contato] cliente compartilhou contato — ${partes.join(' | ')}`
|
||||
}
|
||||
|
||||
// ── Detecção automática de intent a partir do conteúdo de mídia ──────────────
|
||||
|
||||
/**
|
||||
* Mapeia o `effectiveBody` (com prefixos [imagem]/[vídeo]/[documento PDF]/[contato])
|
||||
* para tags de intent compreendidas pelo brain (sector_brain_nodes.tags).
|
||||
* Reduz dependência do LLM detectar intenção sozinho — economiza tokens e
|
||||
* melhora roteamento de setor.
|
||||
*
|
||||
* @param {string} effectiveBody
|
||||
* @returns {string[]} array de tags (vazio se nenhuma reconhecida)
|
||||
*/
|
||||
function detectMediaIntent(effectiveBody) {
|
||||
if (!effectiveBody) return []
|
||||
const tags = new Set()
|
||||
const t = effectiveBody.toLowerCase()
|
||||
|
||||
// Imagem
|
||||
if (/\[imagem\]\s*comprovante_pix/i.test(effectiveBody)) tags.add('pagamento')
|
||||
if (/\[imagem\]\s*receita_medica/i.test(effectiveBody)) tags.add('saude')
|
||||
if (/\[imagem\]\s*produto\b/i.test(effectiveBody)) tags.add('produto')
|
||||
if (/\[imagem\]\s*documento\b/i.test(effectiveBody)) tags.add('documento')
|
||||
|
||||
// Vídeo
|
||||
if (/\[v[íi]deo\]\s*comprovante\b/i.test(effectiveBody)) tags.add('pagamento')
|
||||
if (/\[v[íi]deo\]\s*produto_defeito/i.test(effectiveBody)) { tags.add('reclamacao'); tags.add('produto') }
|
||||
if (/\[v[íi]deo\]\s*produto_duvida/i.test(effectiveBody)) tags.add('produto')
|
||||
if (/\[v[íi]deo\]\s*reclamacao/i.test(effectiveBody)) tags.add('reclamacao')
|
||||
|
||||
// Contato
|
||||
if (/^\[contato\]/.test(effectiveBody)) tags.add('contato_compartilhado')
|
||||
|
||||
// PDF — heurística por palavras-chave no texto extraído
|
||||
if (/^\[documento pdf\]/i.test(effectiveBody)) {
|
||||
if (/\b(boleto|2[º°]\s*via|c[óo]digo\s+de\s+barras|linha\s+digit[áa]vel)\b/.test(t)) tags.add('pagamento')
|
||||
if (/\b(comprovante|recibo|pix\s+enviado|transfer[êe]ncia)\b/.test(t)) tags.add('pagamento')
|
||||
if (/\b(cupom\s+fiscal|nota\s+fiscal|nfc-?e|nf-?e|sat\b)\b/.test(t)) tags.add('nota_fiscal')
|
||||
if (/\b(receita\s+m[ée]dica|prescri[çc][ãa]o|crm\b)\b/.test(t)) tags.add('saude')
|
||||
if (/\b(contrato|termo\s+de|ades[ãa]o)\b/.test(t)) tags.add('contrato')
|
||||
if (/\b(pedido|protocolo|ped-\d{8})\b/.test(t)) tags.add('pedido')
|
||||
}
|
||||
|
||||
return [...tags]
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
transcribeAudio,
|
||||
describeImage,
|
||||
extractPdfText,
|
||||
processVideo,
|
||||
extractVCard,
|
||||
detectMediaIntent,
|
||||
fetchMediaBuffer,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* personas.js — biblioteca de personas pré-configuradas para a secretária virtual.
|
||||
*
|
||||
* Cada tenant pode escolher uma persona via plugin_config newwhats.persona_key.
|
||||
* O texto é injetado no `systemExtra` da chamada /secretaria/ask, antes da
|
||||
* BASE DE CONHECIMENTO. Resulta em tom de voz consistente sem precisar reescrever
|
||||
* o agent inteiro no painel do motor.
|
||||
*
|
||||
* Inspirado nas personas administrativas humanizadas da Sofia v1.2.0.
|
||||
*/
|
||||
|
||||
const PERSONAS = {
|
||||
// ── Conveniência / mercado de bairro (default Alemão Conveniências) ─────────
|
||||
conveniencia_amigavel: {
|
||||
label: 'Conveniência amigável',
|
||||
description: 'Tom informal, curto, próximo. Resolve pedidos e tira dúvidas com leveza.',
|
||||
prompt: `Você é a atendente virtual de uma loja de conveniência de bairro.
|
||||
- Fale de forma calorosa e direta, sem formalidade excessiva. Pode usar 1-2 emojis no máximo por mensagem.
|
||||
- Mensagens curtas (até 3 linhas no WhatsApp).
|
||||
- Foco: ajudar a montar pedido, confirmar status, tirar dúvida sobre horário/entrega/pagamento.
|
||||
- Quando não souber, ofereça transferir para a equipe humana.
|
||||
- Nunca invente preços, estoque ou prazo de entrega — use somente dados que vierem na BASE DE CONHECIMENTO.`,
|
||||
},
|
||||
|
||||
// ── Secretária administrativa (clínica, consultório, escritório) ────────────
|
||||
secretaria_admin: {
|
||||
label: 'Secretária administrativa',
|
||||
description: 'Cordial, profissional, formal sem ser fria. Foco em agendamento e confirmação.',
|
||||
prompt: `Você é a secretária virtual de um consultório/escritório.
|
||||
- Tom cordial e profissional. Use "senhor"/"senhora" quando souber o nome ou for solicitado pelo cliente.
|
||||
- Sem emojis. Mensagens objetivas (até 4 linhas).
|
||||
- Foco: agendamento, reagendamento, confirmação, lembrete, recados ao titular.
|
||||
- Antes de agendar, confirme: nome completo, telefone, motivo. Use as ferramentas listar_horarios e agendar_horario quando disponíveis.
|
||||
- Se o assunto for fora do escopo administrativo, ofereça transferir para a equipe responsável.`,
|
||||
},
|
||||
|
||||
// ── Vendedor de promoções / push de oferta ──────────────────────────────────
|
||||
vendedor_promo: {
|
||||
label: 'Vendedor de promoções',
|
||||
description: 'Energético, persuasivo, gera urgência sem ser invasivo.',
|
||||
prompt: `Você é o consultor de promoções da loja.
|
||||
- Tom animado, motivador, sem ser exagerado. Pode usar 1-2 emojis estratégicos (🔥 ⚡ 🎯).
|
||||
- Quando o cliente perguntar por preço/promoção/oferta: destaque a economia em R$ ou %, prazo da oferta e como aproveitar.
|
||||
- Sempre encerre com call-to-action claro ("quer que eu adicione no seu pedido?", "posso reservar pra você?").
|
||||
- Nunca prometa desconto que não esteja na BASE DE CONHECIMENTO. Não invente cupom.
|
||||
- Mensagens até 4 linhas.`,
|
||||
},
|
||||
|
||||
// ── SAC pós-venda (suporte, reclamação, devolução) ──────────────────────────
|
||||
sac_pos_venda: {
|
||||
label: 'SAC pós-venda',
|
||||
description: 'Empático, resolutivo, prioriza desescalada.',
|
||||
prompt: `Você é o SAC pós-venda da loja.
|
||||
- Tom empático e calmo, mesmo se o cliente estiver irritado. Nunca conteste — valide o sentimento ("entendo a situação", "lamento o ocorrido").
|
||||
- Sem emojis em mensagens de reclamação. Permitido 1 emoji discreto em confirmações ("✅").
|
||||
- Sempre que houver erro confirmado (item faltante, atraso, cobrança indevida): ofereça resolução concreta (estorno, reentrega, brinde) ou escale para a equipe humana usando escalar_humano.
|
||||
- Peça número de pedido/protocolo logo no início da conversa para localizar o caso.
|
||||
- Mensagens até 5 linhas.`,
|
||||
},
|
||||
|
||||
// ── Cobrança amigável (financeiro pendente, lembrete de pagamento) ──────────
|
||||
cobranca_amigavel: {
|
||||
label: 'Cobrança amigável',
|
||||
description: 'Firme mas educada. Foca em facilitar o pagamento.',
|
||||
prompt: `Você é a atendente do setor financeiro responsável por lembretes de pagamento.
|
||||
- Tom firme mas respeitoso. Nunca constranja, ameace ou use linguagem que possa ser interpretada como pressão indevida.
|
||||
- Sem emojis.
|
||||
- Sempre informe: valor, vencimento original, formas de regularizar (PIX, link de pagamento, parcelamento se houver).
|
||||
- Se o cliente alegar não-recebimento, problema financeiro ou contestação: escalar_humano imediatamente.
|
||||
- Nunca exponha dívida em conversa de grupo. Se o chat for de grupo, recuse e peça contato privado.
|
||||
- Mensagens até 4 linhas.`,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a persona configurada para o tenant.
|
||||
* Lê plugin_configs (newwhats.persona_key). Default: conveniencia_amigavel.
|
||||
*
|
||||
* @returns {{ key: string, label: string, prompt: string } | null}
|
||||
*/
|
||||
async function resolvePersona(tenantId = 1) {
|
||||
try {
|
||||
const { queryOne } = require('../../database/postgres')
|
||||
const row = await queryOne(
|
||||
`SELECT value FROM plugin_configs
|
||||
WHERE plugin_id='newwhats' AND key='persona_key'`,
|
||||
)
|
||||
const key = row?.value || 'conveniencia_amigavel'
|
||||
const persona = PERSONAS[key]
|
||||
if (!persona) {
|
||||
console.warn(`[personas] chave "${key}" não existe; usando default`)
|
||||
return { key: 'conveniencia_amigavel', ...PERSONAS.conveniencia_amigavel }
|
||||
}
|
||||
return { key, ...persona }
|
||||
} catch {
|
||||
return { key: 'conveniencia_amigavel', ...PERSONAS.conveniencia_amigavel }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PERSONAS, resolvePersona }
|
||||
@@ -0,0 +1,169 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Proxy para a External API do motor NewWhats.
|
||||
*
|
||||
* Encaminha chamadas do satélite (Alemão) para o motor:
|
||||
* REST: GET|POST|... /api/nw/v1/* → {motorUrl}/api/ext/v1/*
|
||||
* WS: UPGRADE /api/nw/v1/stream → {motorUrl}/api/ext/v1/stream
|
||||
*
|
||||
* A integration_key é lida da tabela plugin_configs e injectada automaticamente
|
||||
* como header x-nw-key em todas as chamadas — nunca fica exposta ao browser.
|
||||
*/
|
||||
|
||||
const http = require('http')
|
||||
const https = require('https')
|
||||
const net = require('net')
|
||||
const url = require('url')
|
||||
const { queryOne } = require('../../database/postgres')
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getPluginConfig() {
|
||||
const [keyRow, urlRow] = await Promise.all([
|
||||
queryOne("SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'integration_key'"),
|
||||
queryOne("SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'newwhats_url'"),
|
||||
])
|
||||
return {
|
||||
integrationKey: keyRow?.value || null,
|
||||
motorUrl: urlRow?.value?.replace(/\/$/, '') || null,
|
||||
}
|
||||
}
|
||||
|
||||
function notConfigured(res) {
|
||||
res.status(503).json({ error: 'Plugin NewWhats não configurado. Configure a integration_key e newwhats_url no admin.' })
|
||||
}
|
||||
|
||||
// ─── REST proxy ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Express middleware que faz proxy de /api/nw/v1/* → motor /api/ext/v1/*
|
||||
* Preserva method, path, query string, body e headers (menos host).
|
||||
*/
|
||||
function createRestProxy() {
|
||||
return async function extApiProxy(req, res) {
|
||||
const { integrationKey, motorUrl } = await getPluginConfig()
|
||||
if (!integrationKey || !motorUrl) return notConfigured(res)
|
||||
|
||||
const target = url.parse(motorUrl)
|
||||
const isHttps = target.protocol === 'https:'
|
||||
const lib = isHttps ? https : http
|
||||
|
||||
// Remove /api/nw/v1 do prefix e substitui por /api/ext/v1
|
||||
const suffix = req.path.replace(/^\/api\/nw\/v1/, '')
|
||||
const qs = req.originalUrl.split('?')[1]
|
||||
const fwdPath = `/api/ext/v1${suffix}${qs ? '?' + qs : ''}`
|
||||
|
||||
const options = {
|
||||
hostname: target.hostname,
|
||||
port: target.port || (isHttps ? 443 : 80),
|
||||
path: fwdPath,
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
host: target.hostname,
|
||||
'x-nw-key': integrationKey,
|
||||
// Remove headers que causam problemas no proxy
|
||||
connection: 'close',
|
||||
},
|
||||
}
|
||||
|
||||
const proxyReq = lib.request(options, (proxyRes) => {
|
||||
const status = proxyRes.statusCode || 502
|
||||
|
||||
// Se o motor retornar um redirect (3xx), ele pode incluir um header
|
||||
// Location apontando para sua própria porta (ex: :8008/api/auth/login).
|
||||
// O browser seguiria esse redirect diretamente para o motor — o que
|
||||
// causa ERR_CONNECTION_REFUSED porque a porta não está exposta.
|
||||
// Interceptamos aqui e devolvemos 401 para o browser tratar.
|
||||
if (status >= 300 && status < 400) {
|
||||
proxyRes.resume() // descarta o body
|
||||
return res.status(401).json({ error: 'Motor NewWhats requer autenticação. Verifique a integration_key no painel de Plugins.' })
|
||||
}
|
||||
|
||||
res.status(status)
|
||||
Object.entries(proxyRes.headers).forEach(([k, v]) => {
|
||||
// Nunca repassa Location ou Set-Cookie do motor — evita vazamento de URL interna
|
||||
if (k !== 'transfer-encoding' && k !== 'location' && k !== 'set-cookie') {
|
||||
res.setHeader(k, v)
|
||||
}
|
||||
})
|
||||
proxyRes.pipe(res)
|
||||
})
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.status(502).json({ error: `Motor indisponível: ${err.message}` })
|
||||
}
|
||||
})
|
||||
|
||||
// Forward body para POST/PUT/PATCH
|
||||
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
||||
const body = JSON.stringify(req.body)
|
||||
proxyReq.setHeader('content-length', Buffer.byteLength(body))
|
||||
proxyReq.setHeader('content-type', 'application/json')
|
||||
proxyReq.write(body)
|
||||
}
|
||||
|
||||
proxyReq.end()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WS proxy (TCP tunnel) ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adiciona handler de upgrade ao httpServer para fazer tunnel do WS.
|
||||
*
|
||||
* Fluxo:
|
||||
* Browser → UPGRADE /api/nw/v1/stream
|
||||
* Satélite → abre TCP para motor + injeta x-nw-key no upgrade
|
||||
* Tunnel bidirecional socket ↔ socket (zero overhead no runtime)
|
||||
*/
|
||||
function attachWsProxy(httpServer) {
|
||||
httpServer.on('upgrade', async (req, clientSocket, head) => {
|
||||
if (!req.url.startsWith('/api/nw/v1/stream')) return
|
||||
|
||||
const { integrationKey, motorUrl } = await getPluginConfig()
|
||||
if (!integrationKey || !motorUrl) {
|
||||
clientSocket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n')
|
||||
clientSocket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
const target = url.parse(motorUrl)
|
||||
const port = parseInt(String(target.port || 80), 10)
|
||||
const host = target.hostname
|
||||
|
||||
const proxySocket = net.connect(port, host, () => {
|
||||
// Reescreve o upgrade request com o path correto + x-nw-key
|
||||
const headers = Object.entries(req.headers)
|
||||
.filter(([k]) => k !== 'host' && k !== 'x-nw-key')
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('\r\n')
|
||||
|
||||
proxySocket.write(
|
||||
`${req.method} /api/ext/v1/stream HTTP/1.1\r\n` +
|
||||
`Host: ${host}\r\n` +
|
||||
`x-nw-key: ${integrationKey}\r\n` +
|
||||
`${headers}\r\n` +
|
||||
`\r\n`
|
||||
)
|
||||
|
||||
if (head && head.length) proxySocket.write(head)
|
||||
|
||||
proxySocket.pipe(clientSocket)
|
||||
clientSocket.pipe(proxySocket)
|
||||
})
|
||||
|
||||
proxySocket.on('error', (err) => {
|
||||
console.error('[nw-proxy] WS tunnel error:', err.message)
|
||||
clientSocket.destroy()
|
||||
})
|
||||
|
||||
clientSocket.on('error', () => proxySocket.destroy())
|
||||
clientSocket.on('close', () => proxySocket.destroy())
|
||||
proxySocket.on('close', () => clientSocket.destroy())
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { createRestProxy, attachWsProxy }
|
||||
@@ -0,0 +1,345 @@
|
||||
'use strict'
|
||||
|
||||
const { Router } = require('express')
|
||||
const { query, queryOne } = require('../../database/postgres')
|
||||
const { nwAuth } = require('./auth')
|
||||
|
||||
function cleanPhone(tel) {
|
||||
return String(tel || '').replace(/\D/g, '')
|
||||
}
|
||||
function cleanCpf(cpf) {
|
||||
return String(cpf || '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function createRoutes() {
|
||||
const router = Router()
|
||||
router.use(nwAuth)
|
||||
|
||||
// ── GET /nw/ping ──────────────────────────────────────────────────────────
|
||||
router.get('/ping', (_req, res) => {
|
||||
res.json({ ok: true, system: 'alemao-conveniencias', ts: new Date().toISOString() })
|
||||
})
|
||||
|
||||
// ── GET /nw/cliente?telefone= ─────────────────────────────────────────────
|
||||
// Retorna dados do cliente + tickets da rifa ativa + clube ativo + candidaturas
|
||||
router.get('/cliente', async (req, res) => {
|
||||
const { telefone } = req.query
|
||||
if (!telefone) return res.status(400).json({ error: '"telefone" é obrigatório' })
|
||||
|
||||
const clean = cleanPhone(telefone)
|
||||
|
||||
try {
|
||||
const user = await queryOne(
|
||||
`SELECT id, nome, telefone, cpf, email, timestamp AS created_at
|
||||
FROM users
|
||||
WHERE REGEXP_REPLACE(telefone, '\\D', '', 'g') = $1`,
|
||||
[clean]
|
||||
)
|
||||
|
||||
if (!user) return res.json({ found: false })
|
||||
|
||||
const cpf = cleanCpf(user.cpf)
|
||||
|
||||
// Tickets da rifa ativa
|
||||
const tickets = await query(
|
||||
`SELECT t.numero, r.titulo AS sorteio, t.data_compra
|
||||
FROM tickets t
|
||||
JOIN raffles r ON r.id = t.raffle_id
|
||||
WHERE REGEXP_REPLACE(t.usuario_telefone, '\\D', '', 'g') = $1
|
||||
AND r.ativo = 1
|
||||
ORDER BY t.numero ASC`,
|
||||
[clean]
|
||||
)
|
||||
|
||||
// Clube(s) ativo(s)
|
||||
const clubes = cpf ? await query(
|
||||
`SELECT cm.id, cm.status, cm.next_billing_at,
|
||||
cp.name AS plano, cp.price_monthly,
|
||||
bc.name AS clube, bc.slug AS clube_slug, bc.specialty
|
||||
FROM club_members cm
|
||||
JOIN club_plans cp ON cp.id = cm.plan_id
|
||||
JOIN benefit_clubs bc ON bc.id = cm.club_id
|
||||
WHERE cm.user_cpf = $1 AND cm.status IN ('active','pending_payment')
|
||||
ORDER BY cm.created_at DESC`,
|
||||
[cpf]
|
||||
) : []
|
||||
|
||||
// Candidaturas ativas (últimas 3)
|
||||
const candidaturas = cpf ? await query(
|
||||
`SELECT ja.status, ja.applied_at,
|
||||
j.title AS vaga, j.work_mode, j.job_type, j.location
|
||||
FROM job_applications ja
|
||||
JOIN jobs j ON j.id = ja.job_id
|
||||
WHERE ja.applicant_cpf = $1
|
||||
ORDER BY ja.applied_at DESC
|
||||
LIMIT 3`,
|
||||
[cpf]
|
||||
) : []
|
||||
|
||||
res.json({
|
||||
found: true,
|
||||
cliente: {
|
||||
...user,
|
||||
tickets,
|
||||
clubes,
|
||||
candidaturas,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[nw/cliente]', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /nw/clube?cpf= ────────────────────────────────────────────────────
|
||||
// Retorna plano ativo do cliente, clube, dependentes e parceiros próximos
|
||||
router.get('/clube', async (req, res) => {
|
||||
const { cpf } = req.query
|
||||
if (!cpf) return res.status(400).json({ error: '"cpf" é obrigatório' })
|
||||
|
||||
const cleanedCpf = cleanCpf(cpf)
|
||||
|
||||
try {
|
||||
const memberships = await query(
|
||||
`SELECT cm.id, cm.status, cm.next_billing_at, cm.started_at,
|
||||
cp.name AS plano, cp.price_monthly, cp.max_dependents, cp.features,
|
||||
bc.id AS clube_id, bc.name AS clube, bc.slug AS clube_slug,
|
||||
bc.specialty, bc.conditions, bc.rules
|
||||
FROM club_members cm
|
||||
JOIN club_plans cp ON cp.id = cm.plan_id
|
||||
JOIN benefit_clubs bc ON bc.id = cm.club_id
|
||||
WHERE cm.user_cpf = $1
|
||||
ORDER BY cm.status = 'active' DESC, cm.created_at DESC`,
|
||||
[cleanedCpf]
|
||||
)
|
||||
|
||||
if (!memberships.length) return res.json({ encontrado: false, memberships: [] })
|
||||
|
||||
// Para cada associação ativa, busca dependentes e parceiros
|
||||
const result = await Promise.all(memberships.map(async (m) => {
|
||||
const [dependentes, parceiros] = await Promise.all([
|
||||
query(
|
||||
`SELECT nome, parentesco, cpf, nascimento
|
||||
FROM club_dependents
|
||||
WHERE member_id = $1
|
||||
ORDER BY nome ASC`,
|
||||
[m.id]
|
||||
),
|
||||
query(
|
||||
`SELECT name, specialty, phone, address, discount_percent, notes
|
||||
FROM club_partners
|
||||
WHERE club_id = $1 AND active = true
|
||||
ORDER BY name ASC
|
||||
LIMIT 10`,
|
||||
[m.clube_id]
|
||||
),
|
||||
])
|
||||
return { ...m, dependentes, parceiros }
|
||||
}))
|
||||
|
||||
res.json({ encontrado: true, memberships: result })
|
||||
} catch (err) {
|
||||
console.error('[nw/clube]', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /nw/candidaturas?cpf= ─────────────────────────────────────────────
|
||||
// Retorna todas as candidaturas do candidato com status detalhado
|
||||
router.get('/candidaturas', async (req, res) => {
|
||||
const { cpf } = req.query
|
||||
if (!cpf) return res.status(400).json({ error: '"cpf" é obrigatório' })
|
||||
|
||||
const cleanedCpf = cleanCpf(cpf)
|
||||
|
||||
try {
|
||||
const candidaturas = await query(
|
||||
`SELECT ja.id, ja.status, ja.applied_at, ja.cover_letter,
|
||||
j.title AS vaga, j.work_mode, j.job_type, j.location, j.category
|
||||
FROM job_applications ja
|
||||
JOIN jobs j ON j.id = ja.job_id
|
||||
WHERE ja.applicant_cpf = $1
|
||||
ORDER BY ja.applied_at DESC`,
|
||||
[cleanedCpf]
|
||||
)
|
||||
|
||||
const statusLabels = {
|
||||
novo: 'Candidatura recebida',
|
||||
em_analise: 'Em análise pela empresa',
|
||||
aprovado: 'Aprovado! Aguarde contato',
|
||||
reprovado: 'Não selecionado desta vez',
|
||||
contratado: 'Contratado — parabéns!',
|
||||
}
|
||||
|
||||
const enriched = candidaturas.map(c => ({
|
||||
...c,
|
||||
status_label: statusLabels[c.status] || c.status,
|
||||
}))
|
||||
|
||||
res.json({ total: enriched.length, candidaturas: enriched })
|
||||
} catch (err) {
|
||||
console.error('[nw/candidaturas]', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /nw/parceiros?clube=slug ──────────────────────────────────────────
|
||||
// Lista parceiros de um clube (para indicar onde o cliente pode usar o benefício)
|
||||
router.get('/parceiros', async (req, res) => {
|
||||
const { clube } = req.query
|
||||
if (!clube) return res.status(400).json({ error: '"clube" (slug) é obrigatório' })
|
||||
|
||||
try {
|
||||
const clubeData = await queryOne(
|
||||
`SELECT id, name, specialty FROM benefit_clubs WHERE slug = $1 AND active = true`,
|
||||
[clube]
|
||||
)
|
||||
if (!clubeData) return res.json({ encontrado: false, parceiros: [] })
|
||||
|
||||
const parceiros = await query(
|
||||
`SELECT name, specialty, phone, address, discount_percent, notes
|
||||
FROM club_partners
|
||||
WHERE club_id = $1 AND active = true
|
||||
ORDER BY name ASC`,
|
||||
[clubeData.id]
|
||||
)
|
||||
|
||||
res.json({
|
||||
encontrado: true,
|
||||
clube: { name: clubeData.name, specialty: clubeData.specialty },
|
||||
parceiros,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[nw/parceiros]', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /nw/vagas ─────────────────────────────────────────────────────────
|
||||
// Lista vagas abertas (para responder perguntas sobre oportunidades de emprego)
|
||||
router.get('/vagas', async (_req, res) => {
|
||||
try {
|
||||
const vagas = await query(
|
||||
`SELECT title, category, work_mode, job_type, location,
|
||||
salary_min, salary_max, salary_hide, slots,
|
||||
published_at, expires_at
|
||||
FROM jobs
|
||||
WHERE active = true
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 20`
|
||||
)
|
||||
|
||||
const workModeLabels = { presencial: 'Presencial', hibrido: 'Híbrido', remoto: 'Remoto' }
|
||||
const jobTypeLabels = { clt: 'CLT', pj: 'PJ', freelance: 'Freelance', estagio: 'Estágio', temporario: 'Temporário' }
|
||||
|
||||
const enriched = vagas.map(v => ({
|
||||
...v,
|
||||
work_mode_label: workModeLabels[v.work_mode] || v.work_mode,
|
||||
job_type_label: jobTypeLabels[v.job_type] || v.job_type,
|
||||
salario: v.salary_hide || (!v.salary_min && !v.salary_max)
|
||||
? 'A combinar'
|
||||
: v.salary_min && v.salary_max
|
||||
? `R$ ${Number(v.salary_min).toLocaleString('pt-BR', { maximumFractionDigits: 0 })} – R$ ${Number(v.salary_max).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
|
||||
: v.salary_min
|
||||
? `A partir de R$ ${Number(v.salary_min).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
|
||||
: `Até R$ ${Number(v.salary_max).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`,
|
||||
}))
|
||||
|
||||
res.json({ total: enriched.length, vagas: enriched })
|
||||
} catch (err) {
|
||||
console.error('[nw/vagas]', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /nw/promocoes ─────────────────────────────────────────────────────
|
||||
router.get('/promocoes', async (_req, res) => {
|
||||
try {
|
||||
const promos = await query(
|
||||
`SELECT id, nome, descricao, preco_original, preco_promocional
|
||||
FROM promotions
|
||||
WHERE ativo = 1
|
||||
ORDER BY id ASC`
|
||||
)
|
||||
res.json(promos)
|
||||
} catch (err) {
|
||||
console.error('[nw/promocoes]', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /nw/sorteio ───────────────────────────────────────────────────────
|
||||
router.get('/sorteio', async (_req, res) => {
|
||||
try {
|
||||
const sorteio = await queryOne(
|
||||
"SELECT * FROM raffles WHERE ativo = 1 ORDER BY id DESC LIMIT 1"
|
||||
)
|
||||
if (!sorteio) return res.json({ ativo: false })
|
||||
|
||||
const countRow = await queryOne(
|
||||
`SELECT COUNT(*) AS total
|
||||
FROM tickets
|
||||
WHERE raffle_id = $1
|
||||
AND (status = 'sold' OR (status = 'reserved' AND reserved_until > NOW()))`,
|
||||
[sorteio.id]
|
||||
)
|
||||
const total_vendidos = parseInt(countRow?.total ?? 0, 10)
|
||||
|
||||
res.json({
|
||||
ativo: true,
|
||||
sorteio: {
|
||||
id: sorteio.id,
|
||||
titulo: sorteio.titulo,
|
||||
descricao: sorteio.descricao,
|
||||
preco_numero: sorteio.preco_numero,
|
||||
total_numeros: sorteio.total_numeros,
|
||||
numeros_vendidos: total_vendidos,
|
||||
numeros_disponiveis: sorteio.total_numeros - total_vendidos,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[nw/sorteio]', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /nw/sorteio/tickets?telefone= ────────────────────────────────────
|
||||
router.get('/sorteio/tickets', async (req, res) => {
|
||||
const { telefone } = req.query
|
||||
if (!telefone) return res.status(400).json({ error: '"telefone" é obrigatório' })
|
||||
|
||||
const clean = cleanPhone(telefone)
|
||||
|
||||
try {
|
||||
const sorteio = await queryOne(
|
||||
"SELECT id, titulo FROM raffles WHERE ativo = 1 ORDER BY id DESC LIMIT 1"
|
||||
)
|
||||
if (!sorteio) return res.json({ ativo: false, tickets: [] })
|
||||
|
||||
const tickets = await query(
|
||||
`SELECT t.numero, t.data_compra
|
||||
FROM tickets t
|
||||
WHERE t.raffle_id = $1
|
||||
AND REGEXP_REPLACE(t.usuario_telefone, '\\D', '', 'g') = $2
|
||||
AND (t.status = 'sold' OR (t.status = 'reserved' AND t.reserved_until > NOW()))
|
||||
ORDER BY t.numero ASC`,
|
||||
[sorteio.id, clean]
|
||||
)
|
||||
|
||||
res.json({
|
||||
sorteio: sorteio.titulo,
|
||||
telefone,
|
||||
total: tickets.length,
|
||||
tickets,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[nw/sorteio/tickets]', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
module.exports = { createRoutes }
|
||||
@@ -0,0 +1,339 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* smart-router.js — Roteador determinístico (sem LLM) para mensagens de alta confiança.
|
||||
*
|
||||
* Estratégia:
|
||||
* 1. Classifica a mensagem do usuário com regex/intents.
|
||||
* 2. Para intents "deterministicos" (cotação por ID, horário, endereço, entrega,
|
||||
* pagamento, meus pedidos), busca o dado no DB e responde com template.
|
||||
* 3. Bypassa completamente o LLM: 0 tokens, resposta instantânea, 100% precisa.
|
||||
* 4. Loga em nw_auto_replies como status='sent_local' para auditoria.
|
||||
*
|
||||
* Para intents ambíguas ou conversacionais → retorna null e o fluxo segue ao LLM.
|
||||
*/
|
||||
|
||||
const { queryOne, query, execute } = require('../../database/postgres')
|
||||
|
||||
// ── Tabela de horário (mantida em sincronia com brain_node "HORÁRIO") ───────
|
||||
function lojaAberta(date = new Date()) {
|
||||
const utc = date.getUTCHours() * 60 + date.getUTCMinutes()
|
||||
const minSP = (utc - 180 + 1440) % 1440
|
||||
const h = Math.floor(minSP / 60)
|
||||
const dow = (date.getUTCDay() + (utc - 180 < 0 ? -1 : 0) + 7) % 7
|
||||
let aberto, abreAs, fechaAs
|
||||
if (dow === 0) { aberto = h >= 10 && h < 22; abreAs = '10h'; fechaAs = '22h' }
|
||||
else if (dow >= 1 && dow <= 4) { aberto = h >= 8 && h < 23; abreAs = '08h'; fechaAs = '23h' }
|
||||
else { aberto = h >= 8 || h < 2; abreAs = '08h'; fechaAs = '02h da madrugada' }
|
||||
return { aberto, abreAs, fechaAs, h, dow }
|
||||
}
|
||||
|
||||
// ── Fases do pedido (em linguagem natural) ──────────────────────────────────
|
||||
const FASE_NATURAL = {
|
||||
novo: 'recebemos seu pedido e já vamos começar a separar 📦',
|
||||
separacao: 'estamos separando os itens no estoque agora 📦',
|
||||
embalagem: 'já separamos tudo, estamos embalando 🎁',
|
||||
expedicao: 'saiu para entrega! 🚚 Se for retirada, já está pronto pra buscar.',
|
||||
entregue: 'foi marcado como *entregue* ✅',
|
||||
cancelado: 'foi cancelado',
|
||||
auditoria: 'está em conferência interna, libera em alguns minutos',
|
||||
fiscal: 'está em processamento fiscal, libera em instantes',
|
||||
}
|
||||
|
||||
const STATUS_FINANCEIRO_TXT = {
|
||||
pendente: 'pagamento pendente',
|
||||
aprovado: 'pagamento aprovado ✅',
|
||||
recusado: 'pagamento recusado ❌',
|
||||
reembolsado: 'reembolsado',
|
||||
}
|
||||
|
||||
// ── Detectores de intents determinísticos ───────────────────────────────────
|
||||
|
||||
function intentCotacaoOuPedido(msg) {
|
||||
// "em que fase está a cotação #1", "status do pedido 3", "PED-20260426-3"
|
||||
const proto = msg.match(/PED-\d{8}-\d+/i)
|
||||
if (proto) return { tipo: 'protocolo', valor: proto[0].toUpperCase() }
|
||||
const cot = msg.match(/cota[çc][aã]o\s*[#nº]?\s*(\d+)/i)
|
||||
if (cot) return { tipo: 'cotacao_id', valor: cot[1] }
|
||||
const ped = msg.match(/pedido\s*[#nº]?\s*(\d+)/i)
|
||||
if (ped) return { tipo: 'pedido_id', valor: ped[1] }
|
||||
// "qual o status", "fase do meu pedido" sem número
|
||||
if (/\b(status|fase|andamento)\s+(do|da)\s+(meu|minha|ultimo|último)\s+(pedido|cota[çc][aã]o)/i.test(msg)) {
|
||||
return { tipo: 'ultimo_do_cliente', valor: null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function intentHorario(msg) {
|
||||
return /\b(abert[oa]s?|fechad[oa]s?|hor[áa]rio|funcion(am|ando|a)|que\s+horas\s+(abre|fecha|fecham))\b/i.test(msg)
|
||||
&& !/\b(meu|minha)\s+pedido\b/i.test(msg)
|
||||
}
|
||||
|
||||
function intentEndereco(msg) {
|
||||
return /\b(endere[çc]o|onde\s+(fica|esta|est[áa]o|voc[êe]s)|localiza[çc][aã]o|como\s+chego|qual\s+(o|a)\s+rua)\b/i.test(msg)
|
||||
}
|
||||
|
||||
function intentEntrega(msg) {
|
||||
return /\b(taxa\s+de\s+entrega|frete|entreg(am|a\s+em)|delivery|tele.?entrega|prazo\s+de\s+entrega|valor\s+da\s+entrega|leva\s+at[ée])\b/i.test(msg)
|
||||
}
|
||||
|
||||
function intentPagamento(msg) {
|
||||
return /\b(formas?\s+de\s+pagamento|aceitam?\s+(pix|cart[ãa]o|d[ée]bito|cr[ée]dito|dinheiro|boleto)|qual\s+pagamento|posso\s+pagar)\b/i.test(msg)
|
||||
}
|
||||
|
||||
function intentMeusPedidos(msg) {
|
||||
return /\b(meus\s+pedidos|minhas\s+compras|hist[óo]rico\s+de\s+(pedidos|compras)|o\s+que\s+(j[áa]\s+)?comprei)\b/i.test(msg)
|
||||
}
|
||||
|
||||
function intentMenu(msg) {
|
||||
return /^(menu|op[çc][õo]es|ajuda|help|comandos?)[\s!.\,?]*$/i.test(msg.trim())
|
||||
}
|
||||
|
||||
// ── Templates de resposta ───────────────────────────────────────────────────
|
||||
|
||||
function tplHorario() {
|
||||
const { aberto, abreAs, fechaAs, dow } = lojaAberta()
|
||||
const dia = ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'][dow]
|
||||
if (aberto) {
|
||||
return `Sim, estamos *abertos* agora! 🟢\nHoje (${dia}) atendemos até ${fechaAs}.\nPosso te ajudar com algum pedido?`
|
||||
}
|
||||
return `No momento estamos *fechados* 🔴\nHoje (${dia}) abrimos às ${abreAs} e fechamos às ${fechaAs}.\nVocê pode mandar seu pedido por aqui que assim que abrirmos a equipe processa! 😊`
|
||||
}
|
||||
|
||||
function tplEndereco() {
|
||||
return `📍 *Alemão Conveniências*\nVocê pode pedir por aqui no WhatsApp ou pelo nosso app: alemaocoonveniencias.com.br\n\nPosso te ajudar a montar um pedido?`
|
||||
}
|
||||
|
||||
function tplEntrega() {
|
||||
return `🚚 *Entrega e Retirada*\n\n• *Retirada na loja*: grátis, pronto em até 30 min\n• *Tele-entrega até 3 km*: R$ 5,00\n• *Tele-entrega 3-5 km*: R$ 8,00\n• *Acima de 5 km*: sob consulta\n\nPedido mínimo p/ entrega: R$ 25,00\nPrazo: 30 a 60 min conforme demanda.`
|
||||
}
|
||||
|
||||
function tplPagamento() {
|
||||
return `💳 *Formas de pagamento*\n\n• *Pix* (recomendado, libera o pedido na hora)\n• *Cartão de crédito* (no app ou maquininha)\n• *Cartão de débito* (maquininha)\n• *Dinheiro* (avise se precisa de troco)\n• *Boleto* só pra PJ acima de R$ 200\n\nQual prefere?`
|
||||
}
|
||||
|
||||
function tplMenu() {
|
||||
return `Oi! 😊 Posso te ajudar com:\n\n📦 Status do seu pedido (ex: "cotação #1")\n🛒 Buscar produto (ex: "tem cerveja gelada?")\n🚚 Info sobre entrega e prazos\n💳 Formas de pagamento\n🕐 Horário de funcionamento\n\nÉ só me dizer o que precisa!`
|
||||
}
|
||||
|
||||
function tplCotacao(c, itens) {
|
||||
// c: { cotacao_id, cotacao_status, total, pedido_protocolo, pedido_status, forma_pagto, tipo_entrega, financeiro_status, criado_em }
|
||||
// itens: [{ nome_produto, quantidade, subtotal }]
|
||||
const linhas = []
|
||||
const ref = c.pedido_protocolo ? `*${c.pedido_protocolo}*` : `Cotação *#${c.cotacao_id}*`
|
||||
linhas.push(`${ref}`)
|
||||
if (c.pedido_status && FASE_NATURAL[c.pedido_status]) {
|
||||
linhas.push(FASE_NATURAL[c.pedido_status])
|
||||
} else if (c.cotacao_status) {
|
||||
linhas.push(`Status da cotação: *${c.cotacao_status}*`)
|
||||
}
|
||||
if (c.tipo_entrega) linhas.push(`Modalidade: ${c.tipo_entrega === 'retirada' ? '🏪 retirada na loja' : '🚚 tele-entrega'}`)
|
||||
if (c.forma_pagto) {
|
||||
const pago = c.financeiro_status && STATUS_FINANCEIRO_TXT[c.financeiro_status]
|
||||
linhas.push(`Pagamento: ${c.forma_pagto.toUpperCase()}${pago ? ` (${pago})` : ''}`)
|
||||
}
|
||||
if (c.total) linhas.push(`Total: R$ ${parseFloat(c.total).toFixed(2)}`)
|
||||
|
||||
// Lista de produtos (se houver)
|
||||
if (Array.isArray(itens) && itens.length > 0) {
|
||||
linhas.push('') // linha em branco antes da lista
|
||||
linhas.push('🛒 *Itens:*')
|
||||
itens.forEach(it => {
|
||||
const qty = parseFloat(it.quantidade)
|
||||
const qtyStr = Number.isInteger(qty) ? `${qty}x` : `${qty}`
|
||||
const sub = it.subtotal != null ? ` — R$ ${parseFloat(it.subtotal).toFixed(2)}` : ''
|
||||
linhas.push(`• ${qtyStr} ${it.nome_produto}${sub}`)
|
||||
})
|
||||
}
|
||||
|
||||
// Pergunta de fechamento conforme status
|
||||
if (c.pedido_status === 'entregue') linhas.push(`\nVocê chegou a receber tudo certinho? 🙏`)
|
||||
else if (c.pedido_status === 'expedicao') linhas.push(`\nQualquer coisa é só me chamar!`)
|
||||
return linhas.join('\n')
|
||||
}
|
||||
|
||||
// Busca itens de um pedido ou cotação
|
||||
async function lookupItens({ pedido_id, cotacao_id }, tenantId = 1) {
|
||||
if (pedido_id) {
|
||||
return await query(
|
||||
`SELECT nome_produto, quantidade, subtotal
|
||||
FROM pedido_itens pi
|
||||
JOIN pedidos p ON p.id = pi.pedido_id
|
||||
WHERE pi.pedido_id = $1 AND p.tenant_id = $2
|
||||
ORDER BY pi.id`,
|
||||
[pedido_id, tenantId],
|
||||
).catch(() => [])
|
||||
}
|
||||
if (cotacao_id) {
|
||||
return await query(
|
||||
`SELECT nome_produto, quantidade, subtotal
|
||||
FROM cotacao_itens ci
|
||||
JOIN cotacoes c ON c.id = ci.cotacao_id
|
||||
WHERE ci.cotacao_id = $1 AND c.tenant_id = $2
|
||||
ORDER BY ci.id`,
|
||||
[cotacao_id, tenantId],
|
||||
).catch(() => [])
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function tplMeusPedidos(pedidos) {
|
||||
if (!pedidos || pedidos.length === 0) {
|
||||
return `Não encontrei pedidos no seu histórico ainda. Quer começar um agora? 🛒`
|
||||
}
|
||||
const linhas = ['📋 *Seus últimos pedidos*\n']
|
||||
pedidos.slice(0, 5).forEach(p => {
|
||||
const fase = FASE_NATURAL[p.status]?.replace(/[📦🎁🚚✅]/g, '').trim() || p.status
|
||||
linhas.push(`• *${p.protocolo}* (${p.data}) — ${fase}`)
|
||||
})
|
||||
linhas.push('\nQuer detalhes de algum? Me diga o número.')
|
||||
return linhas.join('\n')
|
||||
}
|
||||
|
||||
// ── Lookups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function lookupCotacaoOuPedido(intent, tenantId = 1) {
|
||||
if (intent.tipo === 'cotacao_id') {
|
||||
return await queryOne(
|
||||
`SELECT c.id AS cotacao_id, c.status AS cotacao_status, c.total,
|
||||
p.id AS pedido_id, p.protocolo AS pedido_protocolo, p.status AS pedido_status,
|
||||
p.forma_pagto, p.tipo_entrega, p.financeiro_status
|
||||
FROM cotacoes c
|
||||
LEFT JOIN pedidos p ON p.cotacao_id = c.id AND p.tenant_id = c.tenant_id
|
||||
WHERE c.id = $1 AND c.tenant_id = $2
|
||||
ORDER BY p.id DESC LIMIT 1`,
|
||||
[intent.valor, tenantId],
|
||||
).catch(() => null)
|
||||
}
|
||||
if (intent.tipo === 'protocolo') {
|
||||
return await queryOne(
|
||||
`SELECT cotacao_id, NULL AS cotacao_status, total,
|
||||
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
|
||||
forma_pagto, tipo_entrega, financeiro_status
|
||||
FROM pedidos WHERE protocolo ILIKE $1 AND tenant_id = $2 LIMIT 1`,
|
||||
[intent.valor, tenantId],
|
||||
).catch(() => null)
|
||||
}
|
||||
if (intent.tipo === 'pedido_id') {
|
||||
return await queryOne(
|
||||
`SELECT cotacao_id, NULL AS cotacao_status, total,
|
||||
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
|
||||
forma_pagto, tipo_entrega, financeiro_status
|
||||
FROM pedidos WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
|
||||
[intent.valor, tenantId],
|
||||
).catch(() => null)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function lookupUltimosPedidos(phone, tenantId = 1) {
|
||||
if (!phone) return []
|
||||
// Tenta achar user pelo telefone
|
||||
const user = await queryOne(
|
||||
`SELECT id FROM users
|
||||
WHERE REGEXP_REPLACE(telefone, '[^0-9]', '', 'g') LIKE $1 LIMIT 1`,
|
||||
[`%${phone}`],
|
||||
).catch(() => null)
|
||||
if (!user) return []
|
||||
return await query(
|
||||
`SELECT protocolo, status, total,
|
||||
TO_CHAR(created_at, 'DD/MM') AS data
|
||||
FROM pedidos WHERE user_id = $1 AND tenant_id = $2
|
||||
ORDER BY created_at DESC LIMIT 5`,
|
||||
[user.id, tenantId],
|
||||
).catch(() => [])
|
||||
}
|
||||
|
||||
// ── Roteador principal ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tenta responder a mensagem localmente sem chamar o LLM.
|
||||
* @param {string} msg — mensagem do usuário
|
||||
* @param {string} fromPhone — número do remetente (digits-only ou JID)
|
||||
* @param {number} tenantId
|
||||
* @returns {Promise<{reply: string, intent: string} | null>}
|
||||
*/
|
||||
async function tryLocalReply(msg, fromPhone, tenantId = 1) {
|
||||
if (!msg || !msg.trim()) return null
|
||||
const m = msg.trim()
|
||||
const phone = (fromPhone || '').replace(/\D/g, '').replace(/^55/, '')
|
||||
|
||||
// 1. Cotação/Pedido por ID — alta prioridade
|
||||
const cot = intentCotacaoOuPedido(m)
|
||||
if (cot && cot.tipo !== 'ultimo_do_cliente') {
|
||||
const data = await lookupCotacaoOuPedido(cot, tenantId)
|
||||
if (data) {
|
||||
const itens = await lookupItens({ pedido_id: data.pedido_id, cotacao_id: data.cotacao_id }, tenantId)
|
||||
return { reply: tplCotacao(data, itens), intent: 'cotacao_lookup' }
|
||||
}
|
||||
// Não achou — deixa o LLM lidar (pode pedir mais detalhes)
|
||||
return null
|
||||
}
|
||||
if (cot?.tipo === 'ultimo_do_cliente') {
|
||||
const lista = await lookupUltimosPedidos(phone, tenantId)
|
||||
if (lista.length > 0) {
|
||||
const last = await queryOne(
|
||||
`SELECT cotacao_id, NULL AS cotacao_status, total,
|
||||
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
|
||||
forma_pagto, tipo_entrega, financeiro_status
|
||||
FROM pedidos WHERE protocolo = $1 AND tenant_id = $2 LIMIT 1`,
|
||||
[lista[0].protocolo, tenantId],
|
||||
).catch(() => null)
|
||||
if (last) {
|
||||
const itens = await lookupItens({ pedido_id: last.pedido_id, cotacao_id: last.cotacao_id }, tenantId)
|
||||
return { reply: tplCotacao(last, itens), intent: 'cotacao_lookup_ultimo' }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 2. FAQ determinísticos (mensagens curtas e objetivas)
|
||||
if (intentMenu(m)) return { reply: tplMenu(), intent: 'menu' }
|
||||
if (intentHorario(m)) return { reply: tplHorario(), intent: 'horario' }
|
||||
if (intentEndereco(m)) return { reply: tplEndereco(), intent: 'endereco' }
|
||||
if (intentEntrega(m)) return { reply: tplEntrega(), intent: 'entrega' }
|
||||
if (intentPagamento(m)) return { reply: tplPagamento(), intent: 'pagamento' }
|
||||
|
||||
// 3. Meus pedidos
|
||||
if (intentMeusPedidos(m)) {
|
||||
const lista = await lookupUltimosPedidos(phone, tenantId)
|
||||
return { reply: tplMeusPedidos(lista), intent: 'meus_pedidos' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Envio direto via motor (bypassa /secretaria/ask) ────────────────────────
|
||||
|
||||
async function sendLocalReply(motorUrl, integKey, instanceId, chatId, reply, intent, userMsg) {
|
||||
try {
|
||||
const res = await fetch(`${motorUrl}/api/ext/v1/inbox/${encodeURIComponent(chatId)}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-nw-key': integKey },
|
||||
body: JSON.stringify({ text: reply }),
|
||||
})
|
||||
const status = res.ok ? 'sent_local' : 'failed_local'
|
||||
await execute(
|
||||
`INSERT INTO nw_auto_replies (instance_id, chat_id, conv_id, user_msg, reply, status)
|
||||
VALUES ($1, $2, NULL, $3, $4, $5)`,
|
||||
[instanceId || '', chatId, userMsg, reply, status],
|
||||
)
|
||||
// Telemetria: baseline ~5000 chars de prompt seriam enviados ao LLM
|
||||
await execute(
|
||||
`INSERT INTO nw_router_metrics (chat_id, handler, intent, user_msg, reply_chars, ctx_chars, saved_chars)
|
||||
VALUES ($1, 'local', $2, $3, $4, 0, 5000)`,
|
||||
[chatId, intent, userMsg.slice(0, 200), reply.length],
|
||||
).catch(() => {})
|
||||
if (res.ok) {
|
||||
console.log(`[smart-router] ✅ [${intent}] ${chatId} → "${reply.slice(0, 50)}…" (0 tokens)`)
|
||||
return true
|
||||
}
|
||||
console.warn(`[smart-router] envio falhou: ${res.status}`)
|
||||
return false
|
||||
} catch (err) {
|
||||
console.error('[smart-router] erro ao enviar:', err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { tryLocalReply, sendLocalReply }
|
||||
@@ -0,0 +1,246 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* sync-knowledge.js
|
||||
* Constrói o nó de conhecimento da Secretária IA com dados reais do banco
|
||||
* e sincroniza com o motor NewWhats via API REST.
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Lê dados do DB Alemão (clubes, planos, parceiros, promoções, sorteio, vagas)
|
||||
* 2. Monta texto estruturado do knowledge node
|
||||
* 3. Chama o motor NewWhats para atualizar o nó (ou cria se não existir)
|
||||
* 4. Retorna { ok, preview, agent_id, node_id }
|
||||
*/
|
||||
|
||||
const { query, queryOne } = require('../../database/postgres')
|
||||
|
||||
// ── Busca configurações do plugin no banco ────────────────────────────────────
|
||||
async function getPluginConfig() {
|
||||
const rows = await query(
|
||||
`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`
|
||||
)
|
||||
const cfg = {}
|
||||
for (const r of rows) cfg[r.key] = r.value
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ── Chama a API interna do motor NewWhats (/api/secretaria/*) ─────────────────
|
||||
// Nota: as rotas /api/secretaria/* são internas do motor (não passam pelo proxy
|
||||
// /api/ext/v1). São acessadas diretamente pela URL do motor configurada no plugin.
|
||||
async function motorFetch(motorUrl, _integKey, method, path, body) {
|
||||
const url = `${motorUrl}/api/secretaria${path}`
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
if (body) opts.body = JSON.stringify(body)
|
||||
const res = await fetch(url, opts)
|
||||
if (!res.ok) {
|
||||
const txt = await res.text()
|
||||
throw new Error(`Motor ${method} /api/secretaria${path} → ${res.status}: ${txt}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Monta o texto do knowledge node ──────────────────────────────────────────
|
||||
async function buildKnowledgeText() {
|
||||
const lines = []
|
||||
|
||||
// ── Empresa ──────────────────────────────────────────────────────────────
|
||||
lines.push(`=== ALEMÃO CONVENIÊNCIAS ===`)
|
||||
lines.push(`Somos uma empresa de conveniências com foco em benefícios para nossos clientes.`)
|
||||
lines.push(`Site: alemaoconveniencias.com | Atendimento via WhatsApp`)
|
||||
lines.push(``)
|
||||
|
||||
// ── Clubes de Benefícios ─────────────────────────────────────────────────
|
||||
const clubes = await query(
|
||||
`SELECT id, name, slug, specialty, conditions, rules
|
||||
FROM benefit_clubs
|
||||
WHERE active = true
|
||||
ORDER BY id ASC`
|
||||
)
|
||||
|
||||
if (clubes.length) {
|
||||
lines.push(`=== CLUBES DE BENEFÍCIOS ===`)
|
||||
for (const clube of clubes) {
|
||||
lines.push(`\n• ${clube.name} (${clube.specialty})`)
|
||||
|
||||
// Planos do clube
|
||||
const planos = await query(
|
||||
`SELECT name, price_monthly, max_dependents, features
|
||||
FROM club_plans
|
||||
WHERE club_id = $1 AND active = true
|
||||
ORDER BY price_monthly ASC`,
|
||||
[clube.id]
|
||||
)
|
||||
if (planos.length) {
|
||||
lines.push(` Planos disponíveis:`)
|
||||
for (const p of planos) {
|
||||
const deps = p.max_dependents > 0 ? ` (até ${p.max_dependents} dependente${p.max_dependents > 1 ? 's' : ''})` : ' (titular)'
|
||||
lines.push(` - ${p.name}${deps}: R$ ${Number(p.price_monthly).toFixed(2).replace('.', ',')}/mês`)
|
||||
let features = []
|
||||
try { features = typeof p.features === 'string' ? JSON.parse(p.features) : (p.features || []) } catch {}
|
||||
if (features.length) lines.push(` Incluído: ${features.slice(0, 4).join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Parceiros do clube
|
||||
const parceiros = await query(
|
||||
`SELECT name, specialty, phone, address, discount_percent
|
||||
FROM club_partners
|
||||
WHERE club_id = $1 AND active = true
|
||||
ORDER BY name ASC
|
||||
LIMIT 8`,
|
||||
[clube.id]
|
||||
)
|
||||
if (parceiros.length) {
|
||||
lines.push(` Parceiros credenciados:`)
|
||||
for (const p of parceiros) {
|
||||
const desc = p.discount_percent ? ` — ${p.discount_percent}% desconto` : ''
|
||||
const tel = p.phone ? ` | Tel: ${p.phone}` : ''
|
||||
lines.push(` - ${p.name} (${p.specialty})${desc}${tel}`)
|
||||
if (p.address) lines.push(` Endereço: ${p.address}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (clube.conditions) lines.push(` Condições: ${clube.conditions}`)
|
||||
if (clube.rules) lines.push(` Regras: ${clube.rules}`)
|
||||
}
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// ── Sorteio Ativo ─────────────────────────────────────────────────────────
|
||||
const sorteio = await queryOne(
|
||||
`SELECT titulo, descricao, preco_numero, total_numeros FROM raffles WHERE ativo = 1 LIMIT 1`
|
||||
)
|
||||
if (sorteio) {
|
||||
const countRow = await queryOne(
|
||||
`SELECT COUNT(*) AS total FROM tickets t
|
||||
JOIN raffles r ON r.id = t.raffle_id
|
||||
WHERE r.ativo = 1 AND (t.status = 'sold' OR (t.status = 'reserved' AND t.reserved_until > NOW()))`
|
||||
)
|
||||
const vendidos = parseInt(countRow?.total ?? 0, 10)
|
||||
const disponiveis = sorteio.total_numeros - vendidos
|
||||
lines.push(`=== SORTEIO ATIVO ===`)
|
||||
lines.push(`Nome: ${sorteio.titulo}`)
|
||||
if (sorteio.descricao) lines.push(`Descrição: ${sorteio.descricao}`)
|
||||
lines.push(`Preço por número: R$ ${Number(sorteio.preco_numero).toFixed(2).replace('.', ',')}`)
|
||||
lines.push(`Números disponíveis: ${disponiveis} de ${sorteio.total_numeros}`)
|
||||
lines.push(`Como comprar: acesse nosso site e escolha seus números favoritos.`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// ── Promoções Ativas ──────────────────────────────────────────────────────
|
||||
const promos = await query(
|
||||
`SELECT nome, descricao, preco_original, preco_promocional
|
||||
FROM promotions WHERE ativo = TRUE ORDER BY id ASC`
|
||||
)
|
||||
if (promos.length) {
|
||||
lines.push(`=== PROMOÇÕES ATIVAS ===`)
|
||||
for (const p of promos) {
|
||||
const economia = p.preco_original && p.preco_promocional
|
||||
? ` (de R$ ${Number(p.preco_original).toFixed(2).replace('.', ',')} por R$ ${Number(p.preco_promocional).toFixed(2).replace('.', ',')})`
|
||||
: ''
|
||||
lines.push(`• ${p.nome}${economia}`)
|
||||
if (p.descricao) lines.push(` ${p.descricao}`)
|
||||
}
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// ── Vagas de Emprego ──────────────────────────────────────────────────────
|
||||
const vagas = await query(
|
||||
`SELECT title, category, work_mode, job_type, location, salary_hide, salary_min, salary_max
|
||||
FROM jobs
|
||||
WHERE active = true AND (expires_at IS NULL OR expires_at > NOW())
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 10`
|
||||
)
|
||||
if (vagas.length) {
|
||||
const modeMap = { presencial: 'Presencial', hibrido: 'Híbrido', remoto: 'Remoto' }
|
||||
const typeMap = { clt: 'CLT', pj: 'PJ', freelance: 'Freelance', estagio: 'Estágio', temporario: 'Temporário' }
|
||||
lines.push(`=== VAGAS DE EMPREGO DISPONÍVEIS ===`)
|
||||
lines.push(`(Temos ${vagas.length} vaga${vagas.length > 1 ? 's' : ''} abertas no momento)`)
|
||||
for (const v of vagas) {
|
||||
const modo = modeMap[v.work_mode] || v.work_mode
|
||||
const tipo = typeMap[v.job_type] || v.job_type
|
||||
const sal = v.salary_hide || (!v.salary_min && !v.salary_max)
|
||||
? 'A combinar'
|
||||
: v.salary_min && v.salary_max
|
||||
? `R$ ${Number(v.salary_min).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}–${Number(v.salary_max).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
|
||||
: v.salary_min
|
||||
? `A partir de R$ ${Number(v.salary_min).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
|
||||
: `Até R$ ${Number(v.salary_max).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
|
||||
const loc = v.location ? ` | ${v.location}` : ''
|
||||
lines.push(`• ${v.title} — ${modo} | ${tipo}${loc} | ${sal}`)
|
||||
}
|
||||
lines.push(`Para se candidatar, acesse nosso site na seção Vagas.`)
|
||||
lines.push(``)
|
||||
}
|
||||
|
||||
// ── Informações Gerais ────────────────────────────────────────────────────
|
||||
lines.push(`=== ATENDIMENTO ===`)
|
||||
lines.push(`Horário: Consulte pelo WhatsApp ou site para horário atualizado.`)
|
||||
lines.push(`Carteirinha digital: disponível na área do cliente em nosso site.`)
|
||||
lines.push(`Dúvidas sobre pagamento, cancelamento ou benefícios: informe que vai encaminhar para nossa equipe.`)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ── Endpoint principal: sincroniza knowledge node no motor ────────────────────
|
||||
async function syncKnowledge() {
|
||||
const cfg = await getPluginConfig()
|
||||
const motorUrl = cfg.newwhats_url
|
||||
const integKey = cfg.integration_key
|
||||
|
||||
if (!motorUrl || !integKey) {
|
||||
throw new Error('Plugin NewWhats não configurado (newwhats_url ou integration_key ausentes)')
|
||||
}
|
||||
|
||||
// 1. Monta o texto
|
||||
const knowledgeText = await buildKnowledgeText()
|
||||
|
||||
// 2. Busca agentes no motor
|
||||
const agents = await motorFetch(motorUrl, integKey, 'GET', '/agents')
|
||||
if (!agents.length) throw new Error('Nenhum agente encontrado no motor. Crie um agente na Secretária IA.')
|
||||
|
||||
const agent = agents.find(a => a.active) || agents[0]
|
||||
|
||||
// 3. Busca nós do agente
|
||||
const nodes = await motorFetch(motorUrl, integKey, 'GET', `/agents/${agent.id}/nodes`)
|
||||
|
||||
// 4. Encontra o nó de knowledge (ou cria)
|
||||
const knowledgeNode = nodes.find(n => n.type === 'knowledge')
|
||||
|
||||
let nodeId
|
||||
if (knowledgeNode) {
|
||||
// Atualiza nó existente
|
||||
await motorFetch(motorUrl, integKey, 'PUT', `/nodes/${knowledgeNode.id}`, {
|
||||
title: 'Base de Conhecimento — Alemão Conveniências',
|
||||
content: knowledgeText,
|
||||
active: true,
|
||||
sort_order: knowledgeNode.sort_order ?? 3,
|
||||
})
|
||||
nodeId = knowledgeNode.id
|
||||
} else {
|
||||
// Cria nó novo
|
||||
const created = await motorFetch(motorUrl, integKey, 'POST', `/agents/${agent.id}/nodes`, {
|
||||
type: 'knowledge',
|
||||
title: 'Base de Conhecimento — Alemão Conveniências',
|
||||
content: knowledgeText,
|
||||
sort_order: 3,
|
||||
})
|
||||
nodeId = created.id
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
agent_id: agent.id,
|
||||
agent: agent.name,
|
||||
node_id: nodeId,
|
||||
preview: knowledgeText,
|
||||
chars: knowledgeText.length,
|
||||
synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { syncKnowledge, buildKnowledgeText }
|
||||
@@ -0,0 +1,662 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* Receptor de Webhooks — motor NewWhats → satélite Alemão
|
||||
*
|
||||
* Rota: POST /api/webhook/newwhats
|
||||
* Origem: motor (newwhats.local:8008) via HTTP direto
|
||||
*
|
||||
* Validação:
|
||||
* Header x-nw-signature: sha256=<hmac>
|
||||
* HMAC-SHA256(webhook_secret, rawBody)
|
||||
*
|
||||
* Eventos tratados:
|
||||
* message.new — nova mensagem recebida/enviada
|
||||
* session.status — instância conectou / desconectou
|
||||
*
|
||||
* Auto-resposta (Frente 2 v2.2):
|
||||
* Modo controlado por plugin_config auto_reply_mode:
|
||||
* off → só loga (comportamento anterior)
|
||||
* automatico → chama brain do motor e envia reply
|
||||
*
|
||||
* Regras de segurança:
|
||||
* - fromMe=true → nunca responde
|
||||
* - role=notificacoes → nunca responde
|
||||
* - cooldown 15s por chat_id → evita flood (era 45s, reduzido pois bloqueava 2ª msg em conversa ativa)
|
||||
* - max 8 replies/conversa em 1h → anti-loop
|
||||
* - só responde msg tipo TEXT
|
||||
*/
|
||||
|
||||
const { createHmac, timingSafeEqual } = require('crypto')
|
||||
const { Router } = require('express')
|
||||
const { queryOne, query, execute } = require('../../database/postgres')
|
||||
|
||||
const SECRET_KEY = 'webhook_secret'
|
||||
const MOTOR_URL_KEY = 'newwhats_url'
|
||||
const INTEG_KEY_KEY = 'integration_key'
|
||||
const AUTO_MODE_KEY = 'auto_reply_mode' // off | automatico
|
||||
const COOLDOWN_MS = 15_000 // 15s entre replies no mesmo chat
|
||||
const MAX_REPLIES_1H = 8 // máx respostas automáticas por chat por hora
|
||||
|
||||
// ── Config helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// Cache em memória: evita query ao banco em cada mensagem recebida.
|
||||
// Config muda raramente (admin altera via painel) — TTL de 60s é seguro.
|
||||
let _cfgCache = null
|
||||
let _cfgExpiry = 0
|
||||
|
||||
async function getPluginConfig() {
|
||||
if (_cfgCache && Date.now() < _cfgExpiry) return _cfgCache
|
||||
const rows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`)
|
||||
_cfgCache = {}
|
||||
for (const r of rows) _cfgCache[r.key] = r.value
|
||||
_cfgExpiry = Date.now() + 60_000
|
||||
return _cfgCache
|
||||
}
|
||||
|
||||
// Cache de role por instance_id — HTTP ao motor em cada msg custa 200-500ms.
|
||||
// TTL de 5min: role muda raramente (admin altera via painel da secretária).
|
||||
const _numberRoleCache = new Map()
|
||||
const NUMBER_ROLE_TTL = 5 * 60_000
|
||||
|
||||
async function getNumberRole(motorUrl, integKey, instanceId) {
|
||||
const cached = _numberRoleCache.get(instanceId)
|
||||
if (cached && Date.now() - cached.ts < NUMBER_ROLE_TTL) return cached
|
||||
try {
|
||||
const res = await fetch(`${motorUrl}/api/ext/v1/secretaria/numbers`, {
|
||||
headers: { 'x-nw-key': integKey ?? '' },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const nums = await res.json()
|
||||
const info = Array.isArray(nums) ? nums.find(n => n.instance_id === instanceId) : null
|
||||
const entry = { role: info?.role ?? null, active: info?.active ?? true, ts: Date.now() }
|
||||
_numberRoleCache.set(instanceId, entry)
|
||||
return entry
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// ── Assinatura HMAC ───────────────────────────────────────────────────────────
|
||||
|
||||
async function getSecret() {
|
||||
const row = await queryOne(
|
||||
"SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = $1",
|
||||
[SECRET_KEY],
|
||||
)
|
||||
return row?.value ?? null
|
||||
}
|
||||
|
||||
function verifySignature(secret, rawBody, sigHeader) {
|
||||
if (!sigHeader?.startsWith('sha256=')) return false
|
||||
const expected = Buffer.from(
|
||||
'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'),
|
||||
)
|
||||
const received = Buffer.from(sigHeader)
|
||||
if (expected.length !== received.length) return false
|
||||
return timingSafeEqual(expected, received)
|
||||
}
|
||||
|
||||
// ── Roteador principal ────────────────────────────────────────────────────────
|
||||
|
||||
function createWebhookReceiver() {
|
||||
const router = Router()
|
||||
|
||||
router.post('/api/webhook/newwhats', async (req, res) => {
|
||||
try {
|
||||
const sig = req.headers['x-nw-signature']
|
||||
const secret = await getSecret()
|
||||
|
||||
if (!secret) {
|
||||
console.warn('[nw-webhook] webhook_secret não configurado — assinatura ignorada')
|
||||
} else {
|
||||
const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body))
|
||||
if (!verifySignature(secret, rawBody, sig)) {
|
||||
console.warn('[nw-webhook] Assinatura inválida — rejeitado')
|
||||
return res.status(401).json({ error: 'Assinatura inválida' })
|
||||
}
|
||||
}
|
||||
|
||||
const { event, data } = req.body ?? {}
|
||||
if (!event || !data) return res.status(400).json({ error: 'Payload inválido' })
|
||||
|
||||
if (event === 'message.new') {
|
||||
await handleMessageNew(data)
|
||||
} else if (event === 'session.status') {
|
||||
await handleSessionStatus(data)
|
||||
}
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch (err) {
|
||||
console.error('[nw-webhook] Erro interno:', err.message)
|
||||
res.status(500).json({ error: 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// ── Handler: message.new ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleMessageNew(data) {
|
||||
const msg = data?.message
|
||||
if (!msg) return
|
||||
|
||||
// 1. Loga sempre — captura id para poder atualizar payload após extração de mídia
|
||||
const logRow = await queryOne(
|
||||
`INSERT INTO nw_event_logs
|
||||
(instance_id, event, direction, chat_id, from_me, msg_type, payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
[
|
||||
data.instanceId ?? '',
|
||||
'message.new',
|
||||
msg.fromMe ? 'out' : 'in',
|
||||
data.chatId ?? null,
|
||||
msg.fromMe ?? false,
|
||||
msg.type ?? null,
|
||||
JSON.stringify({ body: msg.body, hasMedia: msg.hasMedia, author: msg.author, pushName: msg.pushName }),
|
||||
],
|
||||
)
|
||||
const logId = logRow?.id ?? null
|
||||
|
||||
// 2. Nunca responder mensagem enviada pelo próprio número.
|
||||
// Se havia handoff ativo, o operador respondeu — libera a IA imediatamente.
|
||||
if (msg.fromMe) {
|
||||
try {
|
||||
const { sseBroadcast } = require('../../services/sse')
|
||||
const chatId = data.chatId ?? ''
|
||||
const updated = await execute(
|
||||
`UPDATE nw_handoff SET human_until=NULL, updated_at=NOW()
|
||||
WHERE chat_id=$1 AND human_until > NOW()`,
|
||||
[chatId]
|
||||
)
|
||||
if (updated?.rowCount > 0) {
|
||||
console.log(`[nw-webhook] handoff limpo após resposta do operador — ${chatId}`)
|
||||
sseBroadcast('handoff.expired', { chatId })
|
||||
}
|
||||
} catch { /* silencioso */ }
|
||||
return
|
||||
}
|
||||
|
||||
// 2b. Captura avaliação de satisfação (1–5) se houver pesquisa pendente
|
||||
const chatId = data.chatId ?? ''
|
||||
const body = (msg.body ?? '').trim()
|
||||
if (/^[1-5]$/.test(body)) {
|
||||
try {
|
||||
const { queryOne: qOne, execute: dbExec } = require('../../database/postgres')
|
||||
const pending = await qOne(
|
||||
`SELECT id FROM protocol_ratings
|
||||
WHERE chat_id=$1 AND status='pending'
|
||||
ORDER BY sent_at DESC LIMIT 1`,
|
||||
[chatId]
|
||||
)
|
||||
if (pending) {
|
||||
await dbExec(
|
||||
`UPDATE protocol_ratings SET rating=$1, status='rated', rated_at=NOW() WHERE id=$2`,
|
||||
[parseInt(body), pending.id]
|
||||
)
|
||||
console.log(`[nw-webhook] ⭐ Avaliação ${body}/5 registrada para ${chatId}`)
|
||||
return // não processa como auto-reply
|
||||
}
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
|
||||
// 3. Lê config do plugin
|
||||
const cfg = await getPluginConfig()
|
||||
const mode = cfg[AUTO_MODE_KEY] ?? 'off'
|
||||
if (mode === 'off') return
|
||||
|
||||
// 4. Chats de grupos ignorados (@g.us)
|
||||
if (chatId.endsWith('@g.us')) return
|
||||
|
||||
// 5. Tipo de mensagem: TEXT, AUDIO (PTT), IMAGE, DOCUMENT (PDF), VIDEO e
|
||||
// CONTACT/VCARD são processados. Áudio é transcrito por Whisper/Gemini;
|
||||
// imagem é descrita/classificada; PDF tem texto extraído; vídeo é
|
||||
// descrito (cena+áudio) por Gemini nativo; vCard é parseado localmente.
|
||||
// LOCATION (GPS): TODO — resolver coordenadas para endereço via Maps API.
|
||||
// STICKER e outros: ignorados.
|
||||
const msgType = (msg.type ?? '').toUpperCase()
|
||||
let effectiveBody = body
|
||||
|
||||
if (msgType === 'AUDIO' || msgType === 'PTT') {
|
||||
if (!msg.id) {
|
||||
console.warn('[nw-auto] áudio sem msg.id, ignorando')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { transcribeAudio } = require('./media-transcribe')
|
||||
const transcript = await transcribeAudio({
|
||||
motorUrl: cfg[MOTOR_URL_KEY],
|
||||
integKey: cfg[INTEG_KEY_KEY],
|
||||
messageId: msg.id,
|
||||
})
|
||||
if (!transcript) {
|
||||
console.log(`[nw-auto] áudio inaudível ou falha de transcrição em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 🎙️ transcrição (${transcript.length} chars): "${transcript.slice(0, 80)}…"`)
|
||||
effectiveBody = transcript
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] transcribeAudio erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'IMAGE') {
|
||||
if (!msg.id) {
|
||||
console.warn('[nw-auto] imagem sem msg.id, ignorando')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { describeImage } = require('./media-transcribe')
|
||||
const description = await describeImage({
|
||||
motorUrl: cfg[MOTOR_URL_KEY],
|
||||
integKey: cfg[INTEG_KEY_KEY],
|
||||
messageId: msg.id,
|
||||
captionFromUser: body || null,
|
||||
})
|
||||
if (!description) {
|
||||
console.log(`[nw-auto] imagem sem descrição obtida em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 🖼️ ${description.slice(0, 100)}`)
|
||||
effectiveBody = body ? `${description}\nLegenda do cliente: ${body}` : description
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] describeImage erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'DOCUMENT') {
|
||||
if (!msg.id) {
|
||||
console.warn('[nw-auto] documento sem msg.id, ignorando')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { extractPdfText } = require('./media-transcribe')
|
||||
const pdfText = await extractPdfText({
|
||||
motorUrl: cfg[MOTOR_URL_KEY],
|
||||
integKey: cfg[INTEG_KEY_KEY],
|
||||
messageId: msg.id,
|
||||
captionFromUser: body || null,
|
||||
})
|
||||
if (!pdfText) {
|
||||
console.log(`[nw-auto] documento não processável (tipo não suportado ou muito grande) em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 📄 PDF extraído (${pdfText.length} chars) em ${chatId}`)
|
||||
effectiveBody = body ? `${pdfText}\nMensagem do cliente: ${body}` : pdfText
|
||||
|
||||
// Persiste texto extraído no log para que pings de follow-up ("???")
|
||||
// possam recuperar o conteúdo do PDF como "pergunta pendente".
|
||||
if (logId) {
|
||||
execute(
|
||||
`UPDATE nw_event_logs SET payload = $1 WHERE id = $2`,
|
||||
[JSON.stringify({ body: pdfText, hasMedia: true, originalCaption: body || null, author: msg.author, pushName: msg.pushName }), logId],
|
||||
).catch(e => console.warn('[nw-auto] falha ao persistir texto PDF no log:', e.message))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] extractPdfText erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'VIDEO') {
|
||||
if (!msg.id) {
|
||||
console.warn('[nw-auto] vídeo sem msg.id, ignorando')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { processVideo } = require('./media-transcribe')
|
||||
const description = await processVideo({
|
||||
motorUrl: cfg[MOTOR_URL_KEY],
|
||||
integKey: cfg[INTEG_KEY_KEY],
|
||||
messageId: msg.id,
|
||||
captionFromUser: body || null,
|
||||
})
|
||||
if (!description) {
|
||||
console.log(`[nw-auto] vídeo não processável (muito grande ou erro) em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 🎬 ${description.slice(0, 100)}`)
|
||||
effectiveBody = body ? `${description}\nLegenda do cliente: ${body}` : description
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] processVideo erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'CONTACT' || msgType === 'VCARD' || msgType === 'CONTACTS_ARRAY') {
|
||||
try {
|
||||
const { extractVCard } = require('./media-transcribe')
|
||||
const card = extractVCard({ rawVcard: msg.vcard, msgBody: msg.body })
|
||||
if (!card) {
|
||||
console.log(`[nw-auto] vCard sem dados úteis em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 👤 ${card.slice(0, 120)}`)
|
||||
effectiveBody = card
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] extractVCard erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'LOCATION') {
|
||||
// TODO: implementar reverse geocoding (Maps API) para resolver
|
||||
// msg.location.latitude/longitude → endereço legível e injetar em effectiveBody.
|
||||
// Útil para confirmar endereço de entrega quando cliente compartilha localização.
|
||||
console.log(`[nw-auto] LOCATION recebida em ${chatId} — handler ainda não implementado`)
|
||||
return
|
||||
} else if (msgType !== 'TEXT' && msgType !== '') {
|
||||
return
|
||||
}
|
||||
if (!effectiveBody) return
|
||||
|
||||
// 5c. Detecção de follow-up/ping ("?????", "verificou??", "alô?", "tá aí?")
|
||||
// Quando o cliente está cobrando resposta:
|
||||
// - Bypassa cooldown (a inação é o problema dele, não excesso de respostas)
|
||||
// - Recupera a última pergunta sem resposta no chat
|
||||
// - Enriquece o body com contexto antes de mandar ao LLM
|
||||
let isFollowUp = false
|
||||
let pendingQuestion = null
|
||||
try {
|
||||
const { isFollowUpPing, findPendingQuestion, buildEnrichedBody } = require('./follow-up-detector')
|
||||
if (isFollowUpPing(effectiveBody)) {
|
||||
isFollowUp = true
|
||||
pendingQuestion = await findPendingQuestion(chatId, 1440)
|
||||
const enriched = buildEnrichedBody(effectiveBody, pendingQuestion)
|
||||
console.log(`[nw-auto] 🔔 ping detectado em ${chatId} — pergunta pendente: ${pendingQuestion ? `"${pendingQuestion.body.slice(0,60)}…" (${pendingQuestion.ageMinutes}min)` : 'nenhuma'}`)
|
||||
effectiveBody = enriched
|
||||
|
||||
// Se handoff humano está ativo, alertar o painel admin via SSE — operador
|
||||
// precisa ver que o cliente está cobrando. IA segue pausada (não responde).
|
||||
try {
|
||||
const handoffRow = await queryOne(
|
||||
`SELECT human_until FROM nw_handoff WHERE chat_id=$1 ORDER BY updated_at DESC LIMIT 1`,
|
||||
[chatId]
|
||||
)
|
||||
if (handoffRow?.human_until && new Date(handoffRow.human_until) > new Date()) {
|
||||
const { sseBroadcast } = require('../../services/sse')
|
||||
sseBroadcast('handoff.followup_ping', {
|
||||
chatId,
|
||||
instanceId: data.instanceId ?? '',
|
||||
pingBody: body,
|
||||
pendingQuestion: pendingQuestion?.body ?? null,
|
||||
ageMinutes: pendingQuestion?.ageMinutes ?? null,
|
||||
at: new Date().toISOString(),
|
||||
})
|
||||
console.log(`[nw-auto] 📣 handoff ativo + ping → SSE handoff.followup_ping emitido`)
|
||||
}
|
||||
} catch (e) { console.warn('[nw-auto] handoff/SSE check falhou:', e.message) }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] follow-up-detector erro:', e.message)
|
||||
}
|
||||
|
||||
// 6. Verifica role do número — notificacoes nunca auto-responde (com cache 5min)
|
||||
try {
|
||||
const numInfo = await getNumberRole(cfg[MOTOR_URL_KEY], cfg[INTEG_KEY_KEY], data.instanceId ?? '')
|
||||
if (numInfo?.role === 'notificacoes') return
|
||||
if (numInfo && numInfo.active === false) return
|
||||
} catch { /* se falhar a leitura, deixa passar — melhor responder do que parar */ }
|
||||
|
||||
// 7. Cooldown: evita responder o mesmo chat muito rápido
|
||||
// EXCEÇÕES:
|
||||
// - Pings de follow-up bypassam — cliente cobrando precisa de feedback.
|
||||
// - Última resposta foi do smart-router (status='sent_local') — sem custo
|
||||
// de LLM, então não há motivo de cooldown. Cliente fazendo follow-up
|
||||
// imediato a uma resposta determinística não pode ser bloqueado.
|
||||
// Limite anti-loop (step 8) ainda se aplica em todos os casos.
|
||||
const lastReply = await queryOne(
|
||||
`SELECT created_at, status FROM nw_auto_replies
|
||||
WHERE chat_id = $1
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[chatId],
|
||||
)
|
||||
if (lastReply && !isFollowUp && lastReply.status === 'sent') {
|
||||
const elapsedMs = Date.now() - new Date(lastReply.created_at).getTime()
|
||||
if (elapsedMs < COOLDOWN_MS) {
|
||||
console.log(`[nw-auto] cooldown ativo para ${chatId} (${Math.round(elapsedMs/1000)}s < ${COOLDOWN_MS/1000}s)`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Anti-loop: máx MAX_REPLIES_1H por chat por hora
|
||||
const countRow = await queryOne(
|
||||
`SELECT COUNT(*) AS cnt FROM nw_auto_replies
|
||||
WHERE chat_id = $1 AND created_at > NOW() - INTERVAL '1 hour'`,
|
||||
[chatId],
|
||||
)
|
||||
if (parseInt(countRow?.cnt ?? 0, 10) >= MAX_REPLIES_1H) {
|
||||
console.log(`[nw-auto] limite de ${MAX_REPLIES_1H} respostas/hora atingido para ${chatId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 9a. Smart Router — tenta responder localmente sem LLM (cotação, FAQ, horário etc)
|
||||
// Usa effectiveBody (texto original ou transcrição/descrição de mídia)
|
||||
// Em caso de follow-up/ping, pula direto ao LLM — o body foi enriquecido com
|
||||
// contexto da pergunta original e o smart-router não tem como atender.
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
if (isFollowUp) {
|
||||
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
|
||||
return
|
||||
}
|
||||
const { tryLocalReply, sendLocalReply } = require('./smart-router')
|
||||
const local = await tryLocalReply(effectiveBody, data.from, 1)
|
||||
if (local) {
|
||||
const ok = await sendLocalReply(
|
||||
cfg[MOTOR_URL_KEY], cfg[INTEG_KEY_KEY],
|
||||
data.instanceId, chatId, local.reply, local.intent, effectiveBody
|
||||
)
|
||||
if (ok) return // Respondido localmente, não chama LLM
|
||||
}
|
||||
// Se smart-router não acertou ou envio falhou, segue ao LLM
|
||||
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
|
||||
} catch (e) {
|
||||
console.warn('[smart-router] erro, caindo no LLM:', e.message)
|
||||
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Auto-reply: chama brain e envia resposta ──────────────────────────────────
|
||||
|
||||
async function autoReply(cfg, instanceId, chatId, userMsg, contactName, fromPhone) {
|
||||
const motorUrl = cfg[MOTOR_URL_KEY]
|
||||
const integKey = cfg[INTEG_KEY_KEY]
|
||||
|
||||
if (!motorUrl || !integKey) {
|
||||
console.warn('[nw-auto] motor URL ou integration_key não configurados')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Verifica handoff local — se humano está respondendo, não chamar IA
|
||||
try {
|
||||
const { queryOne: qOne } = require('../../database/postgres')
|
||||
const hRow = await qOne(
|
||||
`SELECT human_until FROM nw_handoff WHERE chat_id=$1 ORDER BY updated_at DESC LIMIT 1`,
|
||||
[chatId]
|
||||
)
|
||||
if (hRow?.human_until && new Date(hRow.human_until) > new Date()) {
|
||||
console.log(`[nw-auto] handoff=humano (local) para ${chatId} — IA pausada`)
|
||||
return
|
||||
}
|
||||
} catch { /* falha silenciosa */ }
|
||||
|
||||
// 2. Monta contexto local do projeto
|
||||
let context = undefined
|
||||
let systemExtra = undefined
|
||||
let intentTags = []
|
||||
try {
|
||||
const { buildContext } = require('./context-builder')
|
||||
const phone = (fromPhone ?? '').replace(/\D/g, '').replace(/^55/, '')
|
||||
const ctx = await buildContext(phone, 1, userMsg, chatId)
|
||||
if (ctx) {
|
||||
context = ctx
|
||||
systemExtra = ctx._systemExtra
|
||||
intentTags = ctx.intencoes_detectadas ?? []
|
||||
delete context._systemExtra
|
||||
}
|
||||
} catch (e) { console.warn('[nw-auto] context-builder falhou:', e.message) }
|
||||
|
||||
// 2a-bis. Intent automático por tipo de mídia — adiciona tags ao brain
|
||||
// (mais barato e preciso do que esperar o LLM detectar sozinho).
|
||||
try {
|
||||
const { detectMediaIntent } = require('./media-transcribe')
|
||||
const mediaTags = detectMediaIntent(userMsg)
|
||||
if (mediaTags.length > 0) {
|
||||
intentTags = [...new Set([...intentTags, ...mediaTags])]
|
||||
console.log(`[nw-auto] 🏷️ intent por mídia: [${mediaTags.join(',')}]`)
|
||||
}
|
||||
} catch { /* silencioso */ }
|
||||
|
||||
// 2a. Persona — define tom de voz/escopo de atendimento por tenant.
|
||||
// Vai ANTES da BASE DE CONHECIMENTO para o LLM ler primeiro o "como falar".
|
||||
try {
|
||||
const { resolvePersona } = require('./personas')
|
||||
const persona = await resolvePersona(1)
|
||||
if (persona) {
|
||||
const personaBlock = `=== PERSONA DE ATENDIMENTO (${persona.label}) ===\n${persona.prompt}`
|
||||
systemExtra = [personaBlock, systemExtra].filter(Boolean).join('\n\n')
|
||||
}
|
||||
} catch (e) { console.warn('[nw-auto] persona injection falhou:', e.message) }
|
||||
|
||||
// 2b. Injeta brain — filtrado por intent (core sempre + tags relevantes)
|
||||
try {
|
||||
const { query: dbq, queryOne: qOne } = require('../../database/postgres')
|
||||
const chatSector = await qOne(
|
||||
`SELECT sector_id FROM nw_chat_sectors WHERE chat_id=$1 LIMIT 1`, [chatId]
|
||||
)
|
||||
const sectorClause = chatSector?.sector_id
|
||||
? `AND sbn.sector_id = ${Number(chatSector.sector_id)}`
|
||||
: ''
|
||||
const tagsToLoad = ['core', ...intentTags]
|
||||
const brainRows = await dbq(
|
||||
`SELECT sbn.type, sbn.content, s.name AS sector_name, sbn.sort_order
|
||||
FROM sector_brain_nodes sbn
|
||||
JOIN sectors s ON s.id = sbn.sector_id
|
||||
WHERE sbn.active = TRUE ${sectorClause}
|
||||
AND sbn.tags && $1::text[]
|
||||
ORDER BY s.sort_order, sbn.sort_order LIMIT 20`,
|
||||
[tagsToLoad]
|
||||
)
|
||||
if (brainRows.length > 0) {
|
||||
const brainText = brainRows.map(r =>
|
||||
`[${r.type.toUpperCase()}${chatSector?.sector_id ? '' : ` — ${r.sector_name}`}]\n${r.content}`
|
||||
).join('\n\n')
|
||||
const brainBlock = `=== BASE DE CONHECIMENTO ===\n${brainText}`
|
||||
systemExtra = [systemExtra, brainBlock].filter(Boolean).join('\n\n')
|
||||
console.log(`[nw-auto] brain: ${brainRows.length} nós (~${brainText.length} chars) tags=[${tagsToLoad.join(',')}]`)
|
||||
}
|
||||
} catch (e) { console.warn('[nw-auto] brain injection falhou:', e.message) }
|
||||
|
||||
// 3. Chama /api/ext/v1/secretaria/ask (cria/retoma conversa + brain.chat())
|
||||
const askRes = await fetch(`${motorUrl}/api/ext/v1/secretaria/ask`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-nw-key': integKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chatId,
|
||||
message: userMsg,
|
||||
contactName: contactName ?? chatId,
|
||||
context,
|
||||
systemExtra,
|
||||
tools: ['listar_horarios', 'agendar_horario', 'escalar_humano', 'encerrar_protocolo', 'human_api'],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!askRes.ok) {
|
||||
const txt = await askRes.text()
|
||||
console.error(`[nw-auto] brain ask failed ${askRes.status}:`, txt)
|
||||
await logAutoReply(instanceId, chatId, null, userMsg, null, 'failed')
|
||||
return
|
||||
}
|
||||
|
||||
const askJson = await askRes.json()
|
||||
const { reply, conversationId, humanApiRequest, sectorId: detectedSectorId } = askJson
|
||||
|
||||
// 3a. Sincroniza setor identificado pela IA → banco local
|
||||
if (detectedSectorId) {
|
||||
try {
|
||||
const { execute: dbExec } = require('../../database/postgres')
|
||||
await dbExec(
|
||||
`INSERT INTO nw_chat_sectors (chat_id, tenant_id, sector_id, updated_at)
|
||||
VALUES ($1,1,$2,NOW())
|
||||
ON CONFLICT (chat_id, tenant_id) DO UPDATE SET sector_id=$2, updated_at=NOW()`,
|
||||
[chatId, detectedSectorId]
|
||||
)
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
|
||||
// 3b. Human API — IA precisa de resposta de especialista humano
|
||||
if (humanApiRequest) {
|
||||
try {
|
||||
const localUrl = `http://127.0.0.1:${process.env.PORT || 4001}`
|
||||
await fetch(`${localUrl}/api/human-api/request`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-internal-secret': process.env.INTERNAL_WEBHOOK_SECRET || '' },
|
||||
body: JSON.stringify({
|
||||
chatId, conversationId, instanceId,
|
||||
question: humanApiRequest.question || humanApiRequest,
|
||||
sectorName: humanApiRequest.sectorName || null,
|
||||
}),
|
||||
})
|
||||
} catch (e) { console.warn('[nw-auto] human_api forward falhou:', e.message) }
|
||||
}
|
||||
|
||||
if (!reply?.trim()) return
|
||||
|
||||
// 2. Envia resposta via motor
|
||||
const sendRes = await fetch(`${motorUrl}/api/ext/v1/inbox/${encodeURIComponent(chatId)}/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-nw-key': integKey,
|
||||
},
|
||||
body: JSON.stringify({ text: reply }),
|
||||
})
|
||||
|
||||
if (!sendRes.ok) {
|
||||
const txt = await sendRes.text()
|
||||
console.error(`[nw-auto] send failed ${sendRes.status}:`, txt)
|
||||
await logAutoReply(instanceId, chatId, conversationId, userMsg, reply, 'failed')
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Loga sucesso + telemetria
|
||||
await logAutoReply(instanceId, chatId, conversationId, userMsg, reply, 'sent')
|
||||
const ctxChars = (systemExtra?.length || 0) + (context ? JSON.stringify(context).length : 0)
|
||||
const saved = Math.max(0, 5000 - ctxChars) // baseline de 5000 chars
|
||||
await execute(
|
||||
`INSERT INTO nw_router_metrics (chat_id, handler, intent, user_msg, reply_chars, ctx_chars, saved_chars)
|
||||
VALUES ($1, 'llm', $2, $3, $4, $5, $6)`,
|
||||
[chatId, intentTags.join(',') || null, userMsg.slice(0, 200), reply.length, ctxChars, saved]
|
||||
).catch(() => {})
|
||||
console.log(`[nw-auto] ✅ ${chatId} → "${reply.slice(0, 60)}…" (ctx=${ctxChars}c, saved=${saved}c)`)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[nw-auto] erro inesperado:', err.message)
|
||||
await logAutoReply(instanceId, chatId, null, userMsg, null, 'failed').catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function logAutoReply(instanceId, chatId, convId, userMsg, reply, status) {
|
||||
await execute(
|
||||
`INSERT INTO nw_auto_replies
|
||||
(instance_id, chat_id, conv_id, user_msg, reply, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[instanceId, chatId, convId ?? null, userMsg, reply ?? null, status],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Handler: session.status ───────────────────────────────────────────────────
|
||||
|
||||
async function handleSessionStatus(data) {
|
||||
await execute(
|
||||
`INSERT INTO nw_event_logs
|
||||
(instance_id, event, direction, payload)
|
||||
VALUES ($1, $2, 'in', $3)`,
|
||||
[
|
||||
data.instanceId ?? '',
|
||||
'session.status',
|
||||
JSON.stringify({ status: data.status }),
|
||||
],
|
||||
)
|
||||
console.log(`[nw-webhook] session.status → ${data.instanceId}: ${data.status}`)
|
||||
}
|
||||
|
||||
module.exports = { createWebhookReceiver }
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:," />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Alemão Conveniências Admin</title>
|
||||
<script type="module" crossorigin src="/admin-v2/assets/index-n43auBDg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin-v2/assets/index-7uQBmuvh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Alemão Conveniências</title>
|
||||
<meta name="description" content="Sorteios, promoções e clube de benefícios" />
|
||||
<script type="module" crossorigin src="/app/assets/index-BsQsNy1r.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-DC2j0XaC.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 615 KiB |
|
After Width: | Height: | Size: 612 KiB |
|
After Width: | Height: | Size: 713 KiB |
|
After Width: | Height: | Size: 658 KiB |
|
After Width: | Height: | Size: 686 KiB |
|
After Width: | Height: | Size: 440 KiB |