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:
VPS 4 Builder
2026-06-28 17:38:36 +02:00
parent bfba577080
commit b21fb0f159
10 changed files with 481 additions and 1 deletions
+50
View File
@@ -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/`.
+25
View File
@@ -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()
}
+21
View File
@@ -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)
}
+28
View File
@@ -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)'}`
)
}
+21
View File
@@ -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)." }
]
}
+30
View File
@@ -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}` })
}
}
}
+98
View File
@@ -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
}
+135
View File
@@ -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(),
}
}
+52
View File
@@ -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
View File
@@ -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}`);