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 path from 'path';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { registerNewwhats } from './newwhats/index.js';
|
||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026';
|
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.),
|
// 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).
|
// 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) {
|
function sanitizeDentista(d) {
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const [k, v] of Object.entries(d || {})) {
|
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);
|
const conflito = await findOverbookingConflict(client, novoDent, start || before.start_time, end || before.end_time, req.params.id);
|
||||||
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
|
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);
|
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
|
||||||
if (updateQuery) await client.query(updateQuery.text, updateQuery.values);
|
if (updateQuery) await client.query(updateQuery.text, updateQuery.values);
|
||||||
await registrarAudit(client, {
|
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 }); }
|
} 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 () => {
|
const httpServer = app.listen(PORT, '0.0.0.0', async () => {
|
||||||
console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`);
|
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}`);
|
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