From b21fb0f159680b9a289c5309847f2f31f6d466fd Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Sun, 28 Jun 2026 17:38:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(newwhats):=20sat=C3=A9lite=20WhatsApp/Secr?= =?UTF-8?q?et=C3=A1ria=20IA=20adaptado=20ao=20odonto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/newwhats/INTEGRACAO-MOTOR.md | 50 ++++++++++ backend/newwhats/auth.js | 25 +++++ backend/newwhats/config.js | 21 +++++ backend/newwhats/index.js | 28 ++++++ backend/newwhats/manifest.json | 21 +++++ backend/newwhats/proxy.js | 30 ++++++ backend/newwhats/routes.js | 98 +++++++++++++++++++ backend/newwhats/sync-knowledge.js | 135 +++++++++++++++++++++++++++ backend/newwhats/webhook-receiver.js | 52 +++++++++++ backend/server.js | 22 ++++- 10 files changed, 481 insertions(+), 1 deletion(-) create mode 100644 backend/newwhats/INTEGRACAO-MOTOR.md create mode 100644 backend/newwhats/auth.js create mode 100644 backend/newwhats/config.js create mode 100644 backend/newwhats/index.js create mode 100644 backend/newwhats/manifest.json create mode 100644 backend/newwhats/proxy.js create mode 100644 backend/newwhats/routes.js create mode 100644 backend/newwhats/sync-knowledge.js create mode 100644 backend/newwhats/webhook-receiver.js diff --git a/backend/newwhats/INTEGRACAO-MOTOR.md b/backend/newwhats/INTEGRACAO-MOTOR.md new file mode 100644 index 0000000..fdcd8cf --- /dev/null +++ b/backend/newwhats/INTEGRACAO-MOTOR.md @@ -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/`. diff --git a/backend/newwhats/auth.js b/backend/newwhats/auth.js new file mode 100644 index 0000000..4f35982 --- /dev/null +++ b/backend/newwhats/auth.js @@ -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 . +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() +} diff --git a/backend/newwhats/config.js b/backend/newwhats/config.js new file mode 100644 index 0000000..ed1d98b --- /dev/null +++ b/backend/newwhats/config.js @@ -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) +} diff --git a/backend/newwhats/index.js b/backend/newwhats/index.js new file mode 100644 index 0000000..6c528bf --- /dev/null +++ b/backend/newwhats/index.js @@ -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)'}` + ) +} diff --git a/backend/newwhats/manifest.json b/backend/newwhats/manifest.json new file mode 100644 index 0000000..d0560f5 --- /dev/null +++ b/backend/newwhats/manifest.json @@ -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)." } + ] +} diff --git a/backend/newwhats/proxy.js b/backend/newwhats/proxy.js new file mode 100644 index 0000000..a3ee0b4 --- /dev/null +++ b/backend/newwhats/proxy.js @@ -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}` }) + } + } +} diff --git a/backend/newwhats/routes.js b/backend/newwhats/routes.js new file mode 100644 index 0000000..d4e4139 --- /dev/null +++ b/backend/newwhats/routes.js @@ -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 +} diff --git a/backend/newwhats/sync-knowledge.js b/backend/newwhats/sync-knowledge.js new file mode 100644 index 0000000..965ece9 --- /dev/null +++ b/backend/newwhats/sync-knowledge.js @@ -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(), + } +} diff --git a/backend/newwhats/webhook-receiver.js b/backend/newwhats/webhook-receiver.js new file mode 100644 index 0000000..5fd2852 --- /dev/null +++ b/backend/newwhats/webhook-receiver.js @@ -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 +} diff --git a/backend/server.js b/backend/server.js index 1ccba82..a798c7a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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}`);