From abbd9374b4a5b4f3bdf037f8fb584266fe934273 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Sun, 28 Jun 2026 18:55:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(newwhats):=20config=20DB-backed=20+=20UI?= =?UTF-8?q?=20dedicada=20(dropdown=20de=20cl=C3=ADnicas,=20testar=20conex?= =?UTF-8?q?=C3=A3o)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/newwhats/config.js | 63 +++++++++++++++--- backend/newwhats/index.js | 82 ++++++++++++++++++----- backend/server.js | 2 +- frontend/views/PluginsView.tsx | 116 ++++++++++++++++++++++++++++++++- 4 files changed, 236 insertions(+), 27 deletions(-) diff --git a/backend/newwhats/config.js b/backend/newwhats/config.js index ed1d98b..ea2d327 100644 --- a/backend/newwhats/config.js +++ b/backend/newwhats/config.js @@ -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(); } diff --git a/backend/newwhats/index.js b/backend/newwhats/index.js index 6c528bf..3fad96d 100644 --- a/backend/newwhats/index.js +++ b/backend/newwhats/index.js @@ -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)'); } diff --git a/backend/server.js b/backend/server.js index a798c7a..aa801b6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -9935,7 +9935,7 @@ app.post('/api/admin/repasses/:id/confirmar', adminGuard, async (req, res) => { }); // ── Integração NewWhats (satélite WhatsApp/Secretária IA) — odonto ────────── -registerNewwhats(app, pool); +registerNewwhats(app, pool, { superadminGuard }); const httpServer = app.listen(PORT, '0.0.0.0', async () => { console.log(`[SERVER] Running on http://0.0.0.0:${PORT}`); diff --git a/frontend/views/PluginsView.tsx b/frontend/views/PluginsView.tsx index 107e272..51e4c1f 100644 --- a/frontend/views/PluginsView.tsx +++ b/frontend/views/PluginsView.tsx @@ -196,6 +196,117 @@ const ConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plu }; // ─── Plugin Card ────────────────────────────────────────────────────────────── +// ─── Config dedicada do NewWhats (dropdown de clínicas + backend + testar) ────── +const NW_API = (import.meta as any).env?.VITE_API_URL || '/api'; +const nwAuthHeaders = () => { + const t = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); + return { 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) }; +}; + +const NewwhatsConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plugin, onClose }) => { + const toast = useToast(); + const [motorUrl, setMotorUrl] = useState(''); + const [clinicaId, setClinicaId] = useState(''); + const [integrationKey, setIntegrationKey] = useState(''); + const [webhookSecret, setWebhookSecret] = useState(''); + const [hasKey, setHasKey] = useState(false); + const [hasSecret, setHasSecret] = useState(false); + const [clinics, setClinics] = useState>([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [test, setTest] = useState<{ ok: boolean; msg: string } | null>(null); + + useEffect(() => { + (async () => { + try { + const [cfgR, clR] = await Promise.all([ + fetch(`${NW_API}/nw/config`, { headers: nwAuthHeaders() }), + fetch(`${NW_API}/nw/clinicas`, { headers: nwAuthHeaders() }), + ]); + if (cfgR.ok) { + const c = await cfgR.json(); + setMotorUrl(c.motorUrl || ''); setClinicaId(c.clinicaId || ''); + setHasKey(!!c.hasIntegrationKey); setHasSecret(!!c.hasWebhookSecret); + } + if (clR.ok) setClinics(await clR.json()); + } catch { /* noop */ } + finally { setLoading(false); } + })(); + }, []); + + const handleSave = async () => { + setSaving(true); + try { + const body: any = { motorUrl, clinicaId }; + if (integrationKey) body.integrationKey = integrationKey; + if (webhookSecret) body.webhookSecret = webhookSecret; + const r = await fetch(`${NW_API}/nw/config`, { method: 'PUT', headers: nwAuthHeaders(), body: JSON.stringify(body) }); + if (!r.ok) throw new Error(await r.text()); + toast.success('CONFIGURAÇÕES SALVAS!'); + onClose(); + } catch (e: any) { toast.error('FALHA AO SALVAR: ' + String(e.message).slice(0, 80)); } + finally { setSaving(false); } + }; + + const handleTest = async () => { + setTest(null); + try { + const r = await fetch(`${NW_API}/nw/status`, { headers: nwAuthHeaders() }); + const s = await r.json(); + setTest({ ok: !!s.motorOk, msg: s.detail || (s.motorOk ? 'OK' : 'Falha') }); + } catch (e: any) { setTest({ ok: false, msg: String(e.message).slice(0, 80) }); } + }; + + const inputCls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:border-green-400 focus:ring-2 focus:ring-green-100 bg-white"; + + return ( +
+
+
+
+

CONFIGURAR {plugin.name}

+

CONEXÃO COM O MOTOR

+
+ +
+ +
+ {loading ?
Carregando…
: <> +
+ + setMotorUrl(e.target.value)} placeholder="https://newwhats.clube67.com" /> +
+
+ + +
+
+ + setIntegrationKey(e.target.value)} placeholder={hasKey ? '•••• (deixe em branco p/ manter)' : 'nw_...'} /> +
+
+ + setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} /> +
+ + {test &&
{test.ok ? '✓ ' : '✗ '}{test.msg}
} + } +
+ +
+ + +
+
+
+ ); +}; + const PluginCard: React.FC<{ plugin: PluginDef; isActive: boolean; alwaysOn?: boolean; price?: number; onToggle: () => void; onConfigure: () => void }> = ({ plugin, isActive, alwaysOn, price, onToggle, onConfigure }) => { const [expanded, setExpanded] = useState(false); @@ -391,7 +502,10 @@ export const PluginsView: React.FC = () => { {/* Config modal */} - {configuringPlugin && ( + {configuringPlugin && configuringPlugin.id === 'newwhats' && ( + setConfiguringPlugin(null)} /> + )} + {configuringPlugin && configuringPlugin.id !== 'newwhats' && ( setConfiguringPlugin(null)} /> )}