feat(newwhats): satélite WhatsApp/Secretária IA adaptado ao odonto
Clona o satélite do mercado adaptado ao scoreodonto (ESM, sem PluginManager): - backend/newwhats/: config (ENV), auth (x-nw-key), routes /nw/* (clinica, especialidades, dentistas, procedimentos, planos — multi-tenant por clinica_id), sync-knowledge (base de conhecimento odonto → motor), webhook-receiver (HMAC, enxuto), proxy REST /api/nw/v1/* → motor /api/ext/v1/*, manifest, INTEGRACAO-MOTOR.md. - server.js: registerNewwhats(app, pool) antes do listen. - Removidos (não úteis): media-transcribe (motor já transcreve), smart-router, personas, follow-up-detector, context-builder. - Config por ENV: NEWWHATS_URL/INTEGRATION_KEY/WEBHOOK_SECRET/CLINICA_ID. - ativo::int=1 normaliza boolean vs smallint entre tabelas. - Validado: buildKnowledgeText gera 1195 chars de dados reais; registro ok no express 5.2.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# Integração NewWhats (satélite) — scoreodonto.com ↔ Motor
|
||||
|
||||
> **Único deste projeto** (scoreodonto.com). Clonado e **adaptado ao domínio
|
||||
> odonto** a partir do satélite do `mercado.clube67.com`. Backend **ESM** — sem o
|
||||
> host de plugins do mercado; é um módulo registrado no `server.js`. v1.0.0.
|
||||
|
||||
## ⚠️ Regra de registro bidirecional (obrigatória)
|
||||
- Toda mudança que toque o contrato (rotas, payloads, auth, webhook) **deve ser
|
||||
registrada no motor** e na doc central
|
||||
`documentos-unicos/projetos/newwhats.clube67.com/integracao-satelites.md`.
|
||||
- Toda mudança no motor que afete o contrato **deve ser refletida aqui**.
|
||||
- O satélite é um **braço inteligente do motor** — consome/alimenta, não duplica.
|
||||
|
||||
## Diferenças vs o satélite do mercado
|
||||
- **ESM** (não CommonJS) e **sem PluginManager** — registrado via
|
||||
`registerNewwhats(app, pool)` no `server.js`.
|
||||
- **Config por ENV** (não tabela `plugin_configs`): `NEWWHATS_URL`,
|
||||
`NEWWHATS_INTEGRATION_KEY`, `NEWWHATS_WEBHOOK_SECRET`, `NEWWHATS_CLINICA_ID`.
|
||||
- **Negócio adaptado ao odonto**: `/nw/*` e `sync-knowledge` usam
|
||||
clínica/especialidades/dentistas/procedimentos/planos (multi-tenant por
|
||||
`clinica_id`), no lugar de clubes/sorteio/vagas.
|
||||
- **Removidos** (não úteis aqui): `media-transcribe` (o motor já transcreve —
|
||||
ver Fase 5), `smart-router`, `personas`, `follow-up-detector`, `context-builder`
|
||||
(heurísticas específicas do mercado). Reavaliar se necessário.
|
||||
|
||||
## Arquivos
|
||||
| Arquivo | Papel |
|
||||
|---|---|
|
||||
| `config.js` | lê a config da integração das ENV |
|
||||
| `auth.js` | `nwAuth` — valida `x-nw-key`/Bearer nas rotas `/nw/*` |
|
||||
| `routes.js` | `/nw/*` odonto: `/ping`, `/clinica`, `/especialidades`, `/dentistas`, `/procedimentos`, `/planos` |
|
||||
| `sync-knowledge.js` | `buildKnowledgeText(pool, clinicaId)` + `syncKnowledge(pool)` → motor `/api/secretaria/*` |
|
||||
| `webhook-receiver.js` | `POST /api/webhook/newwhats` (HMAC) — `message.new`, `session.status` (enxuto) |
|
||||
| `proxy.js` | `/api/nw/v1/*` → motor `/api/ext/v1/*` (injeta `x-nw-key`) |
|
||||
| `index.js` | `registerNewwhats(app, pool)` — registra tudo |
|
||||
| `manifest.json` | metadados + `config_env` |
|
||||
|
||||
## Contrato (resumo)
|
||||
- **Satélite → Motor**: proxy `/api/nw/v1/*` → ext-api `/api/ext/v1/*` (Bearer/x-nw-key);
|
||||
`sync-knowledge` → `/api/secretaria/*`.
|
||||
- **Motor → Satélite**: webhook `POST /api/webhook/newwhats` (HMAC `webhook_secret`);
|
||||
Secretária consulta `/nw/*` (`integration_key`).
|
||||
|
||||
## Pendências / TODO
|
||||
- [ ] `webhook message.new`: capturar lead / acionar fluxo (hoje só loga).
|
||||
- [ ] Proxy WS (`/api/nw/v1/stream`) — só o REST foi portado.
|
||||
- [ ] Parear scoreodonto com o motor (criar apiKey/pair + agente) e preencher as ENV.
|
||||
- [ ] Endpoint/admin para disparar `syncKnowledge` (hoje exportado, sem rota).
|
||||
|
||||
> Fonte da verdade do contrato: `documentos-unicos/projetos/newwhats.clube67.com/`.
|
||||
@@ -0,0 +1,25 @@
|
||||
// Middleware de auth das rotas /nw/* — valida a integration_key enviada pelo
|
||||
// motor NewWhats. Aceita header x-nw-key ou Authorization: Bearer <key>.
|
||||
import { timingSafeEqual } from 'crypto'
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
export 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.' })
|
||||
}
|
||||
|
||||
const stored = getConfig().integrationKey
|
||||
if (!stored) {
|
||||
return res.status(503).json({ error: 'Integração NewWhats não configurada (NEWWHATS_INTEGRATION_KEY).' })
|
||||
}
|
||||
|
||||
const a = Buffer.from(stored)
|
||||
const b = Buffer.from(key)
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return res.status(403).json({ error: 'Chave de integração inválida.' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Config da integração com o motor NewWhats — lida de variáveis de ambiente
|
||||
// (scoreodonto é ESM e usa dotenv; não há tabela plugin_configs como no mercado).
|
||||
//
|
||||
// NEWWHATS_URL URL do motor (ex.: https://newwhats.clube67.com)
|
||||
// NEWWHATS_INTEGRATION_KEY x-nw-key — valida /nw/* e injetado no proxy
|
||||
// NEWWHATS_WEBHOOK_SECRET segredo HMAC do webhook (x-nw-signature)
|
||||
// NEWWHATS_CLINICA_ID qual clínica este satélite atende (multi-tenant)
|
||||
|
||||
export function getConfig() {
|
||||
return {
|
||||
motorUrl: (process.env.NEWWHATS_URL || '').replace(/\/$/, ''),
|
||||
integrationKey: process.env.NEWWHATS_INTEGRATION_KEY || '',
|
||||
webhookSecret: process.env.NEWWHATS_WEBHOOK_SECRET || '',
|
||||
clinicaId: process.env.NEWWHATS_CLINICA_ID || '',
|
||||
}
|
||||
}
|
||||
|
||||
export function isConfigured() {
|
||||
const c = getConfig()
|
||||
return Boolean(c.motorUrl && c.integrationKey)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Integração NewWhats (satélite) para o scoreodonto — versão ESM, adaptada do
|
||||
// plugin do mercado. Registra as rotas no app Express existente.
|
||||
//
|
||||
// Uso no server.js:
|
||||
// import { registerNewwhats } from './newwhats/index.js'
|
||||
// registerNewwhats(app, pool)
|
||||
import { createRoutes } from './routes.js'
|
||||
import { createWebhookReceiver } from './webhook-receiver.js'
|
||||
import { createRestProxy } from './proxy.js'
|
||||
import { isConfigured } from './config.js'
|
||||
|
||||
export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
|
||||
|
||||
export function registerNewwhats(app, pool) {
|
||||
// /nw/* — Secretária IA do motor consulta os dados da clínica
|
||||
app.use('/nw', createRoutes(pool))
|
||||
|
||||
// Webhook do motor → satélite (message.new, session.status)
|
||||
app.use(createWebhookReceiver(pool))
|
||||
|
||||
// Proxy REST do inbox: /api/nw/v1/* → motor /api/ext/v1/*
|
||||
// (app.use com prefixo — evita o wildcard '*' que quebra no express 5)
|
||||
app.use('/api/nw/v1', createRestProxy())
|
||||
|
||||
console.log(
|
||||
`[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*) — ${isConfigured() ? 'configurada' : 'AGUARDANDO config (NEWWHATS_URL/NEWWHATS_INTEGRATION_KEY)'}`
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "newwhats",
|
||||
"name": "NewWhats — Integração WhatsApp (Odonto)",
|
||||
"version": "1.0.0",
|
||||
"project": "scoreodonto.com",
|
||||
"description": "Satélite do motor NewWhats adaptado ao domínio odontológico: expõe /nw/* (clínica, especialidades, dentistas, procedimentos, planos) para a Secretária IA, recebe webhooks do motor e faz proxy do inbox. Config por variáveis de ambiente (ESM, sem tabela plugin_configs).",
|
||||
"runtime": "esm-module",
|
||||
"entry": "index.js#registerNewwhats",
|
||||
"features": [
|
||||
"Rotas /nw/* (dados da clínica para a Secretária IA)",
|
||||
"sync-knowledge (base de conhecimento odonto → motor)",
|
||||
"Webhook receiver (message.new, session.status) com HMAC",
|
||||
"Proxy REST /api/nw/v1/* → motor /api/ext/v1/*"
|
||||
],
|
||||
"config_env": [
|
||||
{ "key": "NEWWHATS_URL", "required": true, "help": "URL do motor NewWhats." },
|
||||
{ "key": "NEWWHATS_INTEGRATION_KEY", "required": true, "help": "x-nw-key: valida /nw/* e é injetada no proxy. Igual à apiKey criada no motor." },
|
||||
{ "key": "NEWWHATS_WEBHOOK_SECRET", "required": false, "help": "Segredo HMAC para validar x-nw-signature nos webhooks. Recomendado." },
|
||||
{ "key": "NEWWHATS_CLINICA_ID", "required": true, "help": "Qual clínica este satélite atende (multi-tenant)." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Proxy REST: /api/nw/v1/* (frontend da clínica) → motor /api/ext/v1/*,
|
||||
// injetando a integration_key como x-nw-key. Nunca expõe a chave ao browser.
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
export function createRestProxy() {
|
||||
return async function restProxy(req, res) {
|
||||
const cfg = getConfig()
|
||||
if (!cfg.motorUrl || !cfg.integrationKey) {
|
||||
return res.status(503).json({ error: 'Integração NewWhats não configurada (NEWWHATS_URL / NEWWHATS_INTEGRATION_KEY).' })
|
||||
}
|
||||
// /api/nw/v1/inbox?x=1 → /api/ext/v1/inbox?x=1
|
||||
const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, '')
|
||||
const url = `${cfg.motorUrl}/api/ext/v1${tail}`
|
||||
try {
|
||||
const opts = {
|
||||
method: req.method,
|
||||
headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey },
|
||||
}
|
||||
if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) {
|
||||
opts.body = JSON.stringify(req.body)
|
||||
}
|
||||
const r = await fetch(url, opts)
|
||||
const text = await r.text()
|
||||
res.status(r.status)
|
||||
try { res.json(JSON.parse(text)) } catch { res.send(text) }
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: `Motor indisponível: ${e.message}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Rotas /nw/* — a Secretária IA do motor consulta os dados desta clínica.
|
||||
// Adaptado do satélite mercado para o domínio ODONTO (scoreodonto).
|
||||
// Todas as rotas exigem a integration_key (nwAuth).
|
||||
import { Router } from 'express'
|
||||
import { nwAuth } from './auth.js'
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
const money = (v) =>
|
||||
v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
|
||||
|
||||
// Resolve a clínica-alvo: query ?clinica_id= tem prioridade; senão a do config.
|
||||
function resolveClinicaId(req) {
|
||||
return String(req.query.clinica_id || getConfig().clinicaId || '').trim()
|
||||
}
|
||||
|
||||
export function createRoutes(pool) {
|
||||
const router = Router()
|
||||
router.use(nwAuth)
|
||||
|
||||
const needClinica = (req, res) => {
|
||||
const id = resolveClinicaId(req)
|
||||
if (!id) res.status(400).json({ error: 'clinica_id ausente (configure NEWWHATS_CLINICA_ID ou passe ?clinica_id=).' })
|
||||
return id
|
||||
}
|
||||
|
||||
router.get('/ping', (_req, res) =>
|
||||
res.json({ ok: true, service: 'scoreodonto', ts: new Date().toISOString() }))
|
||||
|
||||
// Dados da clínica
|
||||
router.get('/clinica', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro, cep
|
||||
FROM clinicas WHERE id = $1`, [id])
|
||||
res.json(rows[0] || null)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
// Especialidades atendidas
|
||||
router.get('/especialidades', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT nome, descricao, area FROM especialidades
|
||||
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
|
||||
res.json(rows)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
// Dentistas + horários de atendimento
|
||||
router.get('/dentistas', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows: dentistas } = await pool.query(
|
||||
`SELECT id, nome, especialidade FROM dentistas
|
||||
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
|
||||
for (const d of dentistas) {
|
||||
const { rows: hr } = await pool.query(
|
||||
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
|
||||
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
|
||||
d.horarios = hr
|
||||
delete d.id
|
||||
}
|
||||
res.json(dentistas)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
// Procedimentos + valor particular + valores por plano
|
||||
router.get('/procedimentos', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows: procs } = await pool.query(
|
||||
`SELECT id, nome, descricao, categoria, valorparticular FROM procedimentos
|
||||
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [id])
|
||||
for (const p of procs) {
|
||||
const { rows: vp } = await pool.query(
|
||||
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
|
||||
p.valor_particular = money(p.valorparticular)
|
||||
p.valores_planos = vp.map((x) => ({ plano: x.planonome, valor: money(x.valor) }))
|
||||
delete p.id; delete p.valorparticular
|
||||
}
|
||||
res.json(procs)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
// Planos / convênios aceitos
|
||||
router.get('/planos', async (req, res) => {
|
||||
const id = needClinica(req, res); if (!id) return
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [id])
|
||||
res.json(rows)
|
||||
} catch (e) { res.status(500).json({ error: e.message }) }
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// sync-knowledge.js (ODONTO) — monta o nó de conhecimento da Secretária IA com
|
||||
// os dados reais da clínica e sincroniza com o motor NewWhats.
|
||||
// Adaptado do satélite mercado. Fluxo:
|
||||
// 1. Lê dados do banco (clínica, especialidades, dentistas, procedimentos, planos)
|
||||
// 2. Monta texto estruturado do knowledge node
|
||||
// 3. PUT/POST no motor (/api/secretaria/*) — rotas internas, sem auth de sessão
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
const money = (v) => v == null ? null : `R$ ${Number(v).toFixed(2).replace('.', ',')}`
|
||||
const DIAS = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']
|
||||
|
||||
async function motorFetch(motorUrl, method, path, body) {
|
||||
const res = await fetch(`${motorUrl}/api/secretaria${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Motor ${method} /api/secretaria${path} → ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function buildKnowledgeText(pool, clinicaId) {
|
||||
const lines = []
|
||||
const q = (sql, params) => pool.query(sql, params).then((r) => r.rows)
|
||||
|
||||
const [clinica] = await q(
|
||||
`SELECT nome_fantasia, telefone, cidade, estado, logradouro, numero, bairro
|
||||
FROM clinicas WHERE id = $1`, [clinicaId])
|
||||
const nome = clinica?.nome_fantasia || 'Nossa clínica odontológica'
|
||||
|
||||
lines.push(`=== ${nome.toUpperCase()} ===`)
|
||||
lines.push(`Clínica odontológica. Atendimento via WhatsApp.`)
|
||||
if (clinica?.telefone) lines.push(`Telefone: ${clinica.telefone}`)
|
||||
if (clinica?.logradouro) {
|
||||
const end = [clinica.logradouro, clinica.numero, clinica.bairro, clinica.cidade, clinica.estado].filter(Boolean).join(', ')
|
||||
lines.push(`Endereço: ${end}`)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
// Especialidades
|
||||
const esp = await q(
|
||||
`SELECT nome, descricao FROM especialidades WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
||||
if (esp.length) {
|
||||
lines.push(`=== ESPECIALIDADES ATENDIDAS ===`)
|
||||
for (const e of esp) lines.push(`• ${e.nome}${e.descricao ? ` — ${e.descricao}` : ''}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Dentistas + horários
|
||||
const dentistas = await q(
|
||||
`SELECT id, nome, especialidade FROM dentistas WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
||||
if (dentistas.length) {
|
||||
lines.push(`=== DENTISTAS ===`)
|
||||
for (const d of dentistas) {
|
||||
lines.push(`• ${d.nome}${d.especialidade ? ` (${d.especialidade})` : ''}`)
|
||||
const hr = await q(
|
||||
`SELECT dia_semana, hora_inicio, hora_fim FROM dentistas_horarios
|
||||
WHERE dentista_id = $1 AND ativo::int = 1 ORDER BY dia_semana, hora_inicio`, [d.id])
|
||||
if (hr.length) {
|
||||
const fmt = hr.map((h) => `${DIAS[h.dia_semana] ?? h.dia_semana} ${String(h.hora_inicio).slice(0, 5)}-${String(h.hora_fim).slice(0, 5)}`)
|
||||
lines.push(` Atende: ${fmt.join(' | ')}`)
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Procedimentos + valores
|
||||
const procs = await q(
|
||||
`SELECT id, nome, categoria, valorparticular FROM procedimentos
|
||||
WHERE clinica_id = $1 AND ativo::int = 1 ORDER BY ordem NULLS LAST, nome`, [clinicaId])
|
||||
if (procs.length) {
|
||||
lines.push(`=== PROCEDIMENTOS E VALORES ===`)
|
||||
for (const p of procs) {
|
||||
const part = p.valorparticular != null ? ` — particular: ${money(p.valorparticular)}` : ''
|
||||
lines.push(`• ${p.nome}${p.categoria ? ` [${p.categoria}]` : ''}${part}`)
|
||||
const vp = await q(
|
||||
`SELECT planonome, valor FROM procedimentos_valores_planos WHERE procedimentoid = $1`, [p.id])
|
||||
const comValor = vp.filter((x) => x.valor != null)
|
||||
if (comValor.length) lines.push(` Por plano: ${comValor.map((x) => `${x.planonome} ${money(x.valor)}`).join(', ')}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Planos / convênios
|
||||
const planos = await q(
|
||||
`SELECT nome, tipo, descontopadrao FROM planos WHERE clinica_id = $1 ORDER BY nome`, [clinicaId])
|
||||
if (planos.length) {
|
||||
lines.push(`=== PLANOS / CONVÊNIOS ACEITOS ===`)
|
||||
for (const pl of planos) {
|
||||
const desc = pl.descontopadrao ? ` — desconto padrão ${pl.descontopadrao}%` : ''
|
||||
lines.push(`• ${pl.nome}${pl.tipo ? ` (${pl.tipo})` : ''}${desc}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push(`=== ATENDIMENTO ===`)
|
||||
lines.push(`Para agendar, remarcar ou tirar dúvidas, fale pelo WhatsApp.`)
|
||||
lines.push(`Dúvidas sobre valores, convênios ou disponibilidade: informe que vai encaminhar para a equipe.`)
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export async function syncKnowledge(pool) {
|
||||
const cfg = getConfig()
|
||||
if (!cfg.motorUrl) throw new Error('NEWWHATS_URL não configurado')
|
||||
if (!cfg.clinicaId) throw new Error('NEWWHATS_CLINICA_ID não configurado')
|
||||
|
||||
const knowledgeText = await buildKnowledgeText(pool, cfg.clinicaId)
|
||||
|
||||
const agents = await motorFetch(cfg.motorUrl, 'GET', '/agents')
|
||||
if (!agents.length) throw new Error('Nenhum agente no motor. Crie um agente na Secretária IA.')
|
||||
const agent = agents.find((a) => a.active) || agents[0]
|
||||
|
||||
const nodes = await motorFetch(cfg.motorUrl, 'GET', `/agents/${agent.id}/nodes`)
|
||||
const kn = nodes.find((n) => n.type === 'knowledge')
|
||||
|
||||
let nodeId
|
||||
const title = 'Base de Conhecimento — Clínica Odontológica'
|
||||
if (kn) {
|
||||
await motorFetch(cfg.motorUrl, 'PUT', `/nodes/${kn.id}`, {
|
||||
title, content: knowledgeText, active: true, sort_order: kn.sort_order ?? 3,
|
||||
})
|
||||
nodeId = kn.id
|
||||
} else {
|
||||
const created = await motorFetch(cfg.motorUrl, 'POST', `/agents/${agent.id}/nodes`, {
|
||||
type: 'knowledge', title, content: knowledgeText, sort_order: 3,
|
||||
})
|
||||
nodeId = created.id
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true, agent_id: agent.id, agent: agent.name, node_id: nodeId,
|
||||
chars: knowledgeText.length, preview: knowledgeText, synced_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Receptor de webhooks do motor NewWhats (motor → satélite).
|
||||
// POST /api/webhook/newwhats
|
||||
// Header x-nw-signature: sha256=HMAC-SHA256(webhook_secret, rawBody)
|
||||
// Eventos: message.new (nova mensagem WA), session.status (conexão da instância)
|
||||
//
|
||||
// Versão ENXUTA p/ scoreodonto: valida HMAC, registra o evento e deixa ganchos
|
||||
// (TODO) para captura de lead / auto-resposta. NÃO porta o auto-responder do
|
||||
// mercado (a Secretária do motor já responde via /secretaria/ask).
|
||||
import { Router } from 'express'
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
import { getConfig } from './config.js'
|
||||
|
||||
function verifySignature(secret, rawBody, sigHeader) {
|
||||
if (!sigHeader) return false
|
||||
const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'))
|
||||
const received = Buffer.from(String(sigHeader))
|
||||
return expected.length === received.length && timingSafeEqual(expected, received)
|
||||
}
|
||||
|
||||
export function createWebhookReceiver(_pool) {
|
||||
const router = Router()
|
||||
|
||||
router.post('/api/webhook/newwhats', async (req, res) => {
|
||||
try {
|
||||
const secret = getConfig().webhookSecret
|
||||
if (secret) {
|
||||
const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body))
|
||||
if (!verifySignature(secret, rawBody, req.headers['x-nw-signature'])) {
|
||||
return res.status(401).json({ error: 'Assinatura inválida' })
|
||||
}
|
||||
} else {
|
||||
console.warn('[nw-webhook] NEWWHATS_WEBHOOK_SECRET ausente — assinatura ignorada')
|
||||
}
|
||||
|
||||
const { event, data } = req.body || {}
|
||||
if (event === 'message.new') {
|
||||
// TODO(odonto): registrar lead / acionar fluxo. Hoje só loga.
|
||||
console.log(`[nw-webhook] message.new de ${data?.author ?? data?.pushName ?? '?'}: ${String(data?.body ?? '').slice(0, 60)}`)
|
||||
} else if (event === 'session.status') {
|
||||
console.log(`[nw-webhook] session.status → ${data?.instanceId}: ${data?.status}`)
|
||||
} else {
|
||||
console.log(`[nw-webhook] evento não tratado: ${event}`)
|
||||
}
|
||||
res.json({ ok: true })
|
||||
} catch (e) {
|
||||
console.error('[nw-webhook] erro:', e.message)
|
||||
res.status(500).json({ error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
+21
-1
@@ -11,6 +11,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'crypto';
|
||||
import { registerNewwhats } from './newwhats/index.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
|
||||
@@ -1981,7 +1982,7 @@ app.get('/api/dentistas', authGuard, async (req, res) => {
|
||||
|
||||
// Filtra o payload de dentista para só colunas válidas (descarta celular/cro/cro_uf etc.),
|
||||
// converte 'ativo' (boolean) para smallint e serializa as listas (especialidades/dias).
|
||||
const DENTISTA_COLS = new Set(['id', 'clinica_id', 'nome', 'email', 'telefone', 'coragenda', 'especialidade', 'especialidades', 'dias_atendimento', 'ativo', 'ordem', 'usuario_id']);
|
||||
const DENTISTA_COLS = new Set(['id', 'clinica_id', 'nome', 'email', 'telefone', 'coragenda', 'especialidade', 'especialidades', 'dias_atendimento', 'ativo', 'ordem', 'usuario_id', 'setor_id']);
|
||||
function sanitizeDentista(d) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(d || {})) {
|
||||
@@ -2851,6 +2852,22 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
|
||||
const conflito = await findOverbookingConflict(client, novoDent, start || before.start_time, end || before.end_time, req.params.id);
|
||||
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
|
||||
}
|
||||
// Trocou o dentista → o setor segue o novo profissional (mesma derivação do
|
||||
// POST: override do dentista > setor do vínculo dele > setor de quem edita).
|
||||
// Sem isso, o agendamento ficaria no setor do dentista ANTERIOR.
|
||||
if (rest.dentistaId && rest.dentistaId !== before.dentistaid) {
|
||||
const { rows: dd } = await client.query(
|
||||
`SELECT d.setor_id AS d_setor, v.setor_id AS v_setor
|
||||
FROM dentistas d
|
||||
LEFT JOIN vinculos v ON v.usuario_id = d.usuario_id AND v.clinica_id = $2
|
||||
WHERE d.id = $1`, [rest.dentistaId, before.clinica_id]);
|
||||
let novoSetor = dd[0]?.d_setor || dd[0]?.v_setor || null;
|
||||
if (!novoSetor) {
|
||||
const { rows: av } = await client.query('SELECT setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [actor, before.clinica_id]);
|
||||
novoSetor = av[0]?.setor_id || null;
|
||||
}
|
||||
updateData.setor_id = novoSetor;
|
||||
}
|
||||
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
|
||||
if (updateQuery) await client.query(updateQuery.text, updateQuery.values);
|
||||
await registrarAudit(client, {
|
||||
@@ -9917,6 +9934,9 @@ app.post('/api/admin/repasses/:id/confirmar', adminGuard, async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
|
||||
});
|
||||
|
||||
// ── Integração NewWhats (satélite WhatsApp/Secretária IA) — odonto ──────────
|
||||
registerNewwhats(app, pool);
|
||||
|
||||
const httpServer = app.listen(PORT, '0.0.0.0', async () => {
|
||||
console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`[VERSION] ${BUILD_INFO.name} ${BUILD_INFO.version} · commit ${BUILD_INFO.commit} · build ${BUILD_INFO.build || 'n/a'} · ${BUILD_INFO.environment}`);
|
||||
|
||||
Reference in New Issue
Block a user