259 lines
10 KiB
JavaScript
259 lines
10 KiB
JavaScript
'use strict'
|
|
|
|
/**
|
|
* apiWhatsappTools.js — WPP-05/06/07
|
|
*
|
|
* Endpoints chamados pelo motor NewWhats (via human_api ou webhook)
|
|
* para ações que requerem acesso ao banco de dados do satélite.
|
|
*
|
|
* Autenticação: header x-internal-secret (mesmo segredo do INTERNAL_WEBHOOK_SECRET)
|
|
* ou verifyToken (admin) dependendo da rota.
|
|
*/
|
|
|
|
const express = require('express')
|
|
const router = express.Router()
|
|
const { execute, queryOne, query } = require('../database/postgres')
|
|
|
|
// Middleware interno (motor → satélite)
|
|
function internalAuth(req, res, next) {
|
|
const secret = process.env.INTERNAL_WEBHOOK_SECRET
|
|
if (!secret) { next(); return } // sem segredo configurado, deixa passar (dev)
|
|
if (req.headers['x-internal-secret'] === secret) { next(); return }
|
|
res.status(401).json({ error: 'Não autorizado' })
|
|
}
|
|
|
|
// ── WPP-05 — Cotação Rápida ────────────────────────────────────────────────────
|
|
// POST /api/whatsapp/cotacao-rapida
|
|
// Body: { phone, items: [{sku, quantidade}], forma_pagto?, tipo_entrega? }
|
|
//
|
|
// Fluxo:
|
|
// 1. Encontra cliente pelo telefone
|
|
// 2. Cria cotação em rascunho
|
|
// 3. Adiciona itens (resolve SKU → produto_id)
|
|
// 4. Recalcula total
|
|
// 5. Retorna link de acompanhamento + total
|
|
|
|
router.post('/cotacao-rapida', internalAuth, async (req, res) => {
|
|
try {
|
|
const { phone, items, forma_pagto, tipo_entrega, tenant_id = 1 } = req.body
|
|
if (!phone || !Array.isArray(items) || !items.length) {
|
|
return res.status(400).json({ error: 'phone e items[] são obrigatórios' })
|
|
}
|
|
|
|
// 1. Acha cliente pelo telefone
|
|
const digits = phone.replace(/\D/g, '').replace(/^55/, '')
|
|
let cliente = null
|
|
for (const v of [digits, digits.length === 11 ? digits.slice(0,2)+digits.slice(3) : null, digits.length === 10 ? digits.slice(0,2)+'9'+digits.slice(2) : null].filter(Boolean)) {
|
|
cliente = await queryOne(
|
|
`SELECT c.id, c.nome, c.tipo 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`,
|
|
[tenant_id, `%${v}`]
|
|
)
|
|
if (cliente) break
|
|
}
|
|
|
|
if (!cliente) {
|
|
return res.status(404).json({ error: 'Cliente não encontrado para este telefone. Cadastre-o primeiro.' })
|
|
}
|
|
|
|
// 2. Cria cotação
|
|
const cotacao = await queryOne(
|
|
`INSERT INTO cotacoes (tenant_id, cliente_id, status, forma_pagto, tipo_entrega)
|
|
VALUES ($1,$2,'rascunho',$3,$4) RETURNING id`,
|
|
[tenant_id, cliente.id, forma_pagto || 'pix', tipo_entrega || 'entrega']
|
|
)
|
|
|
|
// 3. Adiciona itens (resolve por SKU)
|
|
for (const item of items) {
|
|
const produto = await queryOne(
|
|
`SELECT id, nome, preco, sku,
|
|
(SELECT preco_promocional FROM promotions WHERE produto_id = p.id AND ativo=1 LIMIT 1) AS preco_promo
|
|
FROM produtos p WHERE tenant_id=$1 AND (sku=$2 OR id=$3::int) AND ativo=true LIMIT 1`,
|
|
[tenant_id, item.sku || '', item.produto_id || 0]
|
|
)
|
|
if (!produto) continue
|
|
const qty = Number(item.quantidade) || 1
|
|
const preco = Number(produto.preco)
|
|
const promo = produto.preco_promo ? Number(produto.preco_promo) : null
|
|
await execute(
|
|
`INSERT INTO cotacao_itens
|
|
(cotacao_id,produto_id,nome_produto,sku,quantidade,preco_unitario,preco_promocional,desconto_pct,subtotal)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,0,$8)`,
|
|
[cotacao.id, produto.id, produto.nome, produto.sku, qty, preco, promo, (promo ?? preco) * qty]
|
|
)
|
|
}
|
|
|
|
// 4. Recalcula total
|
|
await execute(
|
|
`UPDATE cotacoes SET total=(SELECT COALESCE(SUM(subtotal),0) FROM cotacao_itens WHERE cotacao_id=$1), updated_at=NOW() WHERE id=$1`,
|
|
[cotacao.id]
|
|
)
|
|
const updated = await queryOne('SELECT total FROM cotacoes WHERE id=$1', [cotacao.id])
|
|
|
|
const baseUrl = process.env.APP_BASE_URL || 'https://alemaoconveniencias.com.br'
|
|
const link = `${baseUrl}/app/catalogo?cotacao=${cotacao.id}`
|
|
|
|
return res.json({
|
|
ok: true,
|
|
cotacao_id: cotacao.id,
|
|
cliente: cliente.nome,
|
|
total: `R$ ${parseFloat(updated.total).toFixed(2).replace('.',',')}`,
|
|
link,
|
|
mensagem: `🛒 Cotação criada para *${cliente.nome}*!\nTotal: *R$ ${parseFloat(updated.total).toFixed(2).replace('.',',')}*\n\nAcompanhe aqui: ${link}`,
|
|
})
|
|
} catch (err) {
|
|
console.error('[wpp.cotacao-rapida]', err.message)
|
|
return res.status(500).json({ error: 'Erro ao criar cotação rápida' })
|
|
}
|
|
})
|
|
|
|
// ── WPP-06 — Salvar contato coletado pela IA ──────────────────────────────────
|
|
// POST /api/whatsapp/salvar-contato
|
|
// Body: { phone, nome, tipo, email?, cpf_cnpj?, tenant_id? }
|
|
|
|
router.post('/salvar-contato', internalAuth, async (req, res) => {
|
|
try {
|
|
const { phone, nome, tipo = 'PF', email, cpf_cnpj, tenant_id = 1 } = req.body
|
|
if (!phone || !nome) return res.status(400).json({ error: 'phone e nome são obrigatórios' })
|
|
|
|
const digits = phone.replace(/\D/g, '').replace(/^55/, '')
|
|
const tipoVal = ['PF','PJ'].includes(tipo?.toUpperCase()) ? tipo.toUpperCase() : 'PF'
|
|
|
|
// Verifica se já existe
|
|
let cliente = null
|
|
for (const v of [digits].filter(Boolean)) {
|
|
cliente = await queryOne(
|
|
`SELECT c.id 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`,
|
|
[tenant_id, `%${v}`]
|
|
)
|
|
if (cliente) break
|
|
}
|
|
|
|
if (cliente) {
|
|
// Atualiza se necessário
|
|
await execute(
|
|
`UPDATE clientes SET nome=COALESCE(NULLIF($1,''),nome), email=COALESCE(NULLIF($2,''),email), updated_at=NOW() WHERE id=$3`,
|
|
[nome, email || null, cliente.id]
|
|
)
|
|
return res.json({ ok: true, acao: 'atualizado', cliente_id: cliente.id })
|
|
}
|
|
|
|
// Cria novo cliente
|
|
const novoCliente = await queryOne(
|
|
`INSERT INTO clientes (tenant_id, nome, tipo, email, cpf_cnpj, status)
|
|
VALUES ($1,$2,$3,$4,$5,'ativo') RETURNING id`,
|
|
[tenant_id, nome, tipoVal, email || null, cpf_cnpj?.replace(/\D/g,'') || null]
|
|
)
|
|
|
|
// Adiciona telefone
|
|
await execute(
|
|
`INSERT INTO cliente_telefones (cliente_id, tipo, numero, aceita_whatsapp, principal)
|
|
VALUES ($1,'whatsapp',$2,true,true)`,
|
|
[novoCliente.id, digits]
|
|
)
|
|
|
|
return res.json({ ok: true, acao: 'criado', cliente_id: novoCliente.id })
|
|
} catch (err) {
|
|
console.error('[wpp.salvar-contato]', err.message)
|
|
return res.status(500).json({ error: 'Erro ao salvar contato' })
|
|
}
|
|
})
|
|
|
|
// ── WPP-07 — Handoff: registra troca de contexto para atendente humano ─────────
|
|
// POST /api/whatsapp/handoff
|
|
// Body: { chat_id, tenant_id?, resumo, setor?, contato_nome?, contato_phone? }
|
|
|
|
router.post('/handoff', internalAuth, async (req, res) => {
|
|
try {
|
|
const { chat_id, tenant_id = 1, resumo, setor = 'atendimento', contato_nome, contato_phone } = req.body
|
|
if (!chat_id || !resumo) return res.status(400).json({ error: 'chat_id e resumo são obrigatórios' })
|
|
|
|
// Registra handoff (ativa pausa IA por 4h)
|
|
await execute(
|
|
`INSERT INTO nw_handoff (chat_id, tenant_id, human_until, updated_at)
|
|
VALUES ($1,$2,NOW() + INTERVAL '4 hours', NOW())
|
|
ON CONFLICT (chat_id, tenant_id) DO UPDATE SET human_until=NOW() + INTERVAL '4 hours', updated_at=NOW()`,
|
|
[chat_id, tenant_id]
|
|
)
|
|
|
|
// Gera mensagem de contexto para o inbox do setor
|
|
const remetente = 'Sistema IA'
|
|
const mensagem = [
|
|
`🤝 *Transferência para atendente humano*`,
|
|
`Chat: ${contato_nome || chat_id}${contato_phone ? ` (${contato_phone})` : ''}`,
|
|
``,
|
|
`*Resumo da conversa:*`,
|
|
resumo,
|
|
].join('\n')
|
|
|
|
await execute(
|
|
`INSERT INTO mensagens_internas (tenant_id, setor, remetente, mensagem)
|
|
VALUES ($1,$2,$3,$4)`,
|
|
[tenant_id, setor, remetente, mensagem]
|
|
)
|
|
|
|
return res.json({ ok: true, handoff_ativo: true, setor })
|
|
} catch (err) {
|
|
console.error('[wpp.handoff]', err.message)
|
|
return res.status(500).json({ error: 'Erro ao registrar handoff' })
|
|
}
|
|
})
|
|
|
|
// ── WPP-09 — Reactivação: lista clientes inativos para follow-up ──────────────
|
|
// GET /api/whatsapp/clientes-inativos?dias=60&tenant_id=1
|
|
// (chamado internamente pelo job de reativação)
|
|
|
|
router.get('/clientes-inativos', internalAuth, async (req, res) => {
|
|
try {
|
|
const dias = Math.min(Number(req.query.dias) || 60, 365)
|
|
const tenant_id = Number(req.query.tenant_id) || 1
|
|
|
|
const rows = 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 AND p.tenant_id = $1
|
|
WHERE c.tenant_id = $1 AND c.status = 'ativo'
|
|
-- Não foi reativado nos últimos 30 dias
|
|
AND c.id NOT IN (
|
|
SELECT cliente_id FROM wpp_reativacao_log
|
|
WHERE tenant_id=$1 AND enviada_em > NOW() - INTERVAL '30 days'
|
|
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 50`,
|
|
[tenant_id, dias]
|
|
)
|
|
return res.json(rows)
|
|
} catch (err) {
|
|
console.error('[wpp.clientes-inativos]', err.message)
|
|
return res.status(500).json({ error: 'Erro ao listar clientes inativos' })
|
|
}
|
|
})
|
|
|
|
// ── WPP-09 — Registra envio de reativação ─────────────────────────────────────
|
|
// POST /api/whatsapp/log-reativacao
|
|
|
|
router.post('/log-reativacao', internalAuth, async (req, res) => {
|
|
try {
|
|
const { cliente_id, chat_id, mensagem, tenant_id = 1 } = req.body
|
|
await execute(
|
|
`INSERT INTO wpp_reativacao_log (tenant_id, cliente_id, chat_id, mensagem) VALUES ($1,$2,$3,$4)`,
|
|
[tenant_id, cliente_id || null, chat_id, mensagem || null]
|
|
)
|
|
return res.json({ ok: true })
|
|
} catch (err) {
|
|
return res.status(500).json({ error: 'Erro ao logar reativação' })
|
|
}
|
|
})
|
|
|
|
module.exports = router
|