feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker

This commit is contained in:
VPS 4 Builder
2026-05-15 10:39:35 +02:00
commit 3beac0e0f2
288 changed files with 78761 additions and 0 deletions
+40
View File
@@ -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/
+226
View File
@@ -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()
+215
View File
@@ -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
+525
View File
@@ -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)
}
}
+674
View File
@@ -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()
+528
View File
@@ -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,
}
+140
View File
@@ -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()
+96
View File
@@ -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
+478
View File
@@ -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' })
}
}
+82
View File
@@ -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 }
+147
View File
@@ -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
+408
View File
@@ -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 (1100)' })
}
if (parseInt(duration_minutes) < 1 || parseInt(duration_minutes) > 1440) {
return res.status(400).json({ error: 'Duração inválida (11440 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' })
}
},
}
+169
View File
@@ -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' })
}
}
+448
View File
@@ -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
+78
View File
@@ -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
+41
View File
@@ -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
Binary file not shown.
File diff suppressed because it is too large Load Diff
+140
View File
@@ -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()
+77
View File
@@ -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 }
+122
View File
@@ -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()
);
+24
View File
@@ -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);
+25
View File
@@ -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()
);
+25
View File
@@ -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()
);
+15
View File
@@ -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()
);
File diff suppressed because it is too large Load Diff
+162
View File
@@ -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 }
+114
View File
@@ -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 }
+123
View File
@@ -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 }
+78
View File
@@ -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 };
+26
View File
@@ -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 }
+80
View File
@@ -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 }
+88
View File
@@ -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 }
+51
View File
@@ -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
+89
View File
@@ -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
+41
View File
@@ -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
+59
View File
@@ -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
+46
View File
@@ -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
+163
View File
@@ -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
+113
View File
@@ -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
+43
View File
@@ -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
+49
View File
@@ -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
+218
View File
@@ -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
+48
View File
@@ -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
+113
View File
@@ -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
+161
View File
@@ -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
+74
View File
@@ -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
+41
View File
@@ -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
+41
View File
@@ -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
+273
View File
@@ -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
+94
View File
@@ -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
+62
View File
@@ -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
+85
View File
@@ -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
+42
View File
@@ -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
+49
View File
@@ -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
+57
View File
@@ -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
+32
View File
@@ -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
+104
View File
@@ -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
+135
View File
@@ -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
+94
View File
@@ -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
+26
View File
@@ -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
+1647
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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"
}
}
+43
View File
@@ -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
+140
View File
@@ -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
+42
View File
@@ -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 }
+842
View File
@@ -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 1011 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 ? `${ageMin}min` : `${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 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 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 pergunta pendente: instrui a IA a responder ela.
* Se não (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 }
+67
View File
@@ -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
+44
View File
@@ -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
* 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,
}
+102
View File
@@ -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.
* 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 }
+169
View File
@@ -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 }
+345
View File
@@ -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 }
+339
View File
@@ -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 }
+246
View File
@@ -0,0 +1,246 @@
'use strict'
/**
* sync-knowledge.js
* Constrói o de conhecimento da Secretária IA com dados reais do banco
* e sincroniza com o motor NewWhats via API REST.
*
* Fluxo:
* 1. 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 (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 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
* - 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 (15) 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 }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -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>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -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

+15
View File
@@ -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>
Binary file not shown.
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Some files were not shown because too many files have changed in this diff Show More