feat(newwhats): config DB-backed + UI dedicada (dropdown de clínicas, testar conexão)

Backend:
- config.js: config lida da tabela settings (id=newwhats_config) com fallback ENV;
  loadConfigFromDb/saveConfigToDb + cache.
- index.js: endpoints superadmin GET/PUT /api/nw/config (secrets nunca em claro;
  branco mantém valor), GET /api/nw/clinicas (dropdown), GET /api/nw/status (testar
  conexão). server.js passa { superadminGuard }.
Frontend:
- PluginsView: NewwhatsConfigModal dedicado (carrega/salva no backend, dropdown de
  clínicas em vez de ID cru, botão Testar conexão). Substitui o form genérico p/ newwhats.

Resolve UX ruim: nada de digitar ID de clínica; config passa a funcionar de fato.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-28 18:55:54 +02:00
parent 3751d84da8
commit abbd9374b4
4 changed files with 236 additions and 27 deletions
+53 -10
View File
@@ -1,21 +1,64 @@
// 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).
// Config da integração com o motor NewWhats.
// Fonte: tabela `settings` (id='newwhats_config') — editável pelo super-admin na UI.
// Fallback: variáveis de ambiente (NEWWHATS_*), úteis para bootstrap/deploy.
//
// 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)
// NEWWHATS_URL / motorUrl URL do motor
// NEWWHATS_INTEGRATION_KEY / integrationKey x-nw-key
// NEWWHATS_WEBHOOK_SECRET / webhookSecret HMAC do webhook
// NEWWHATS_CLINICA_ID / clinicaId clínica que este satélite atende
export function getConfig() {
let cache = null; // { motorUrl, integrationKey, webhookSecret, clinicaId } | null
function fromEnv() {
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 || '',
}
};
}
// DB (se houver valor) tem prioridade; ENV preenche o que faltar.
export function getConfig() {
const env = fromEnv();
if (!cache) return env;
return {
motorUrl: (cache.motorUrl || env.motorUrl || '').replace(/\/$/, ''),
integrationKey: cache.integrationKey || env.integrationKey || '',
webhookSecret: cache.webhookSecret || env.webhookSecret || '',
clinicaId: cache.clinicaId || env.clinicaId || '',
};
}
export function isConfigured() {
const c = getConfig()
return Boolean(c.motorUrl && c.integrationKey)
const c = getConfig();
return Boolean(c.motorUrl && c.integrationKey);
}
// Carrega a config do banco para o cache (chamado no boot e após salvar).
export async function loadConfigFromDb(pool) {
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'newwhats_config'");
cache = rows[0]?.data || {};
} catch {
if (!cache) cache = {};
}
return getConfig();
}
// Persiste no banco e atualiza o cache.
export async function saveConfigToDb(pool, cfg) {
const data = {
motorUrl: (cfg.motorUrl || '').replace(/\/$/, ''),
integrationKey: cfg.integrationKey || '',
webhookSecret: cfg.webhookSecret || '',
clinicaId: cfg.clinicaId || '',
};
await pool.query(
`INSERT INTO settings (id, category, data) VALUES ('newwhats_config', 'integration', $1)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`,
[JSON.stringify(data)]
);
cache = data;
return getConfig();
}
+67 -15
View File
@@ -1,28 +1,80 @@
// Integração NewWhats (satélite) para o scoreodonto — versão ESM, adaptada do
// plugin do mercado. Registra as rotas no app Express existente.
// Integração NewWhats (satélite) para o scoreodonto — ESM, adaptada do mercado.
// Registra rotas no app Express existente.
//
// Uso no server.js:
// import { registerNewwhats } from './newwhats/index.js'
// registerNewwhats(app, pool)
// registerNewwhats(app, pool, { superadminGuard })
import { createRoutes } from './routes.js'
import { createWebhookReceiver } from './webhook-receiver.js'
import { createRestProxy } from './proxy.js'
import { isConfigured } from './config.js'
import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } 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))
export function registerNewwhats(app, pool, opts = {}) {
// passthrough middleware caso o guard não seja fornecido (dev)
const superadminGuard = opts.superadminGuard || ((_req, _res, next) => next());
// Webhook do motor → satélite (message.new, session.status)
app.use(createWebhookReceiver(pool))
// Carrega a config do banco para o cache (não bloqueia o boot).
loadConfigFromDb(pool).then((c) =>
console.log(`[newwhats] config carregada — ${isConfigured() ? 'configurada' : 'AGUARDANDO config'} (clinica=${c.clinicaId || '—'})`)
).catch(() => {});
// 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())
// ── Config (super-admin) ───────────────────────────────────────────────────
// GET: nunca devolve os segredos em claro — só se estão definidos.
app.get('/api/nw/config', superadminGuard, (_req, res) => {
const c = getConfig();
res.json({
motorUrl: c.motorUrl,
clinicaId: c.clinicaId,
hasIntegrationKey: Boolean(c.integrationKey),
hasWebhookSecret: Boolean(c.webhookSecret),
configured: isConfigured(),
});
});
console.log(
`[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*) — ${isConfigured() ? 'configurada' : 'AGUARDANDO config (NEWWHATS_URL/NEWWHATS_INTEGRATION_KEY)'}`
)
// PUT: secrets em branco MANTÊM o valor atual (não precisa redigitar).
app.put('/api/nw/config', superadminGuard, async (req, res) => {
try {
const cur = getConfig();
const b = req.body || {};
const next = {
motorUrl: (b.motorUrl ?? cur.motorUrl) || '',
clinicaId: (b.clinicaId ?? cur.clinicaId) || '',
integrationKey: b.integrationKey ? String(b.integrationKey) : cur.integrationKey,
webhookSecret: b.webhookSecret ? String(b.webhookSecret) : cur.webhookSecret,
};
await saveConfigToDb(pool, next);
res.json({ ok: true, configured: isConfigured() });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Lista de clínicas para o dropdown.
app.get('/api/nw/clinicas', superadminGuard, async (_req, res) => {
try {
const { rows } = await pool.query('SELECT id, nome_fantasia FROM clinicas ORDER BY nome_fantasia');
res.json(rows);
} catch (e) { res.status(500).json({ error: e.message }); }
});
// Testa a conexão com o motor (usa a config atual).
app.get('/api/nw/status', superadminGuard, async (_req, res) => {
const c = getConfig();
if (!c.motorUrl || !c.integrationKey) return res.json({ configured: false, motorOk: false, detail: 'URL ou chave ausente' });
try {
const r = await fetch(`${c.motorUrl}/api/ext/v1/sessions`, { headers: { 'x-nw-key': c.integrationKey } });
res.json({ configured: true, motorOk: r.ok, status: r.status, detail: r.ok ? 'Conexão OK' : `Motor respondeu ${r.status}` });
} catch (e) {
res.json({ configured: true, motorOk: false, detail: `Motor inacessível: ${e.message}` });
}
});
// ── Operação ────────────────────────────────────────────────────────────────
app.use('/nw', createRoutes(pool)); // Secretária do motor consulta a clínica
app.use(createWebhookReceiver(pool)); // webhook motor → satélite
app.use('/api/nw/v1', createRestProxy()); // proxy do inbox → motor (express 5: use, não wildcard)
console.log('[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*, /api/nw/config)');
}