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:
+53
-10
@@ -1,21 +1,64 @@
|
|||||||
// Config da integração com o motor NewWhats — lida de variáveis de ambiente
|
// Config da integração com o motor NewWhats.
|
||||||
// (scoreodonto é ESM e usa dotenv; não há tabela plugin_configs como no mercado).
|
// 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_URL / motorUrl URL do motor
|
||||||
// NEWWHATS_INTEGRATION_KEY x-nw-key — valida /nw/* e injetado no proxy
|
// NEWWHATS_INTEGRATION_KEY / integrationKey x-nw-key
|
||||||
// NEWWHATS_WEBHOOK_SECRET segredo HMAC do webhook (x-nw-signature)
|
// NEWWHATS_WEBHOOK_SECRET / webhookSecret HMAC do webhook
|
||||||
// NEWWHATS_CLINICA_ID qual clínica este satélite atende (multi-tenant)
|
// 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 {
|
return {
|
||||||
motorUrl: (process.env.NEWWHATS_URL || '').replace(/\/$/, ''),
|
motorUrl: (process.env.NEWWHATS_URL || '').replace(/\/$/, ''),
|
||||||
integrationKey: process.env.NEWWHATS_INTEGRATION_KEY || '',
|
integrationKey: process.env.NEWWHATS_INTEGRATION_KEY || '',
|
||||||
webhookSecret: process.env.NEWWHATS_WEBHOOK_SECRET || '',
|
webhookSecret: process.env.NEWWHATS_WEBHOOK_SECRET || '',
|
||||||
clinicaId: process.env.NEWWHATS_CLINICA_ID || '',
|
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() {
|
export function isConfigured() {
|
||||||
const c = getConfig()
|
const c = getConfig();
|
||||||
return Boolean(c.motorUrl && c.integrationKey)
|
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
@@ -1,28 +1,80 @@
|
|||||||
// Integração NewWhats (satélite) para o scoreodonto — versão ESM, adaptada do
|
// Integração NewWhats (satélite) para o scoreodonto — ESM, adaptada do mercado.
|
||||||
// plugin do mercado. Registra as rotas no app Express existente.
|
// Registra rotas no app Express existente.
|
||||||
//
|
//
|
||||||
// Uso no server.js:
|
// Uso no server.js:
|
||||||
// import { registerNewwhats } from './newwhats/index.js'
|
// import { registerNewwhats } from './newwhats/index.js'
|
||||||
// registerNewwhats(app, pool)
|
// registerNewwhats(app, pool, { superadminGuard })
|
||||||
import { createRoutes } from './routes.js'
|
import { createRoutes } from './routes.js'
|
||||||
import { createWebhookReceiver } from './webhook-receiver.js'
|
import { createWebhookReceiver } from './webhook-receiver.js'
|
||||||
import { createRestProxy } from './proxy.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 { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
|
||||||
|
|
||||||
export function registerNewwhats(app, pool) {
|
export function registerNewwhats(app, pool, opts = {}) {
|
||||||
// /nw/* — Secretária IA do motor consulta os dados da clínica
|
// passthrough middleware caso o guard não seja fornecido (dev)
|
||||||
app.use('/nw', createRoutes(pool))
|
const superadminGuard = opts.superadminGuard || ((_req, _res, next) => next());
|
||||||
|
|
||||||
// Webhook do motor → satélite (message.new, session.status)
|
// Carrega a config do banco para o cache (não bloqueia o boot).
|
||||||
app.use(createWebhookReceiver(pool))
|
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/*
|
// ── Config (super-admin) ───────────────────────────────────────────────────
|
||||||
// (app.use com prefixo — evita o wildcard '*' que quebra no express 5)
|
// GET: nunca devolve os segredos em claro — só se estão definidos.
|
||||||
app.use('/api/nw/v1', createRestProxy())
|
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(
|
// PUT: secrets em branco MANTÊM o valor atual (não precisa redigitar).
|
||||||
`[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*) — ${isConfigured() ? 'configurada' : 'AGUARDANDO config (NEWWHATS_URL/NEWWHATS_INTEGRATION_KEY)'}`
|
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)');
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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 ──────────
|
// ── 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 () => {
|
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}`);
|
||||||
|
|||||||
@@ -196,6 +196,117 @@ const ConfigModal: React.FC<{ plugin: PluginDef; onClose: () => void }> = ({ plu
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─── Plugin Card ──────────────────────────────────────────────────────────────
|
// ─── 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<Array<{ id: string; nome_fantasia: string }>>([]);
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center backdrop-blur-sm p-4">
|
||||||
|
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between" style={{ background: `linear-gradient(to right, ${plugin.color}10, transparent)` }}>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-black text-gray-900 uppercase">CONFIGURAR {plugin.name}</h3>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest" style={{ color: plugin.color }}>CONEXÃO COM O MOTOR</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors"><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{loading ? <div className="text-sm text-gray-500">Carregando…</div> : <>
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">URL DO MOTOR</label>
|
||||||
|
<input className={inputCls} value={motorUrl} onChange={e => setMotorUrl(e.target.value)} placeholder="https://newwhats.clube67.com" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CLÍNICA</label>
|
||||||
|
<select className={inputCls} value={clinicaId} onChange={e => setClinicaId(e.target.value)}>
|
||||||
|
<option value="">— selecione a clínica —</option>
|
||||||
|
{clinics.map(c => <option key={c.id} value={c.id}>{c.nome_fantasia || c.id}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">CHAVE DE INTEGRAÇÃO {hasKey && <span className="text-green-600">(definida)</span>}</label>
|
||||||
|
<input className={inputCls} type="password" value={integrationKey} onChange={e => setIntegrationKey(e.target.value)} placeholder={hasKey ? '•••• (deixe em branco p/ manter)' : 'nw_...'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest block mb-1.5">SEGREDO DO WEBHOOK {hasSecret && <span className="text-green-600">(definido)</span>}</label>
|
||||||
|
<input className={inputCls} type="password" value={webhookSecret} onChange={e => setWebhookSecret(e.target.value)} placeholder={hasSecret ? '•••• (deixe em branco p/ manter)' : '(HMAC)'} />
|
||||||
|
</div>
|
||||||
|
<button onClick={handleTest} className="text-xs font-black uppercase text-gray-600 border border-gray-200 rounded-xl px-4 py-2 hover:bg-gray-50">Testar conexão</button>
|
||||||
|
{test && <div className={`text-xs font-bold ${test.ok ? 'text-green-600' : 'text-red-600'}`}>{test.ok ? '✓ ' : '✗ '}{test.msg}</div>}
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 pb-6 flex gap-3">
|
||||||
|
<button onClick={onClose} className="flex-1 py-3 border border-gray-200 rounded-xl text-xs font-black uppercase text-gray-500 hover:bg-gray-50 transition-colors">CANCELAR</button>
|
||||||
|
<button onClick={handleSave} disabled={saving} className="flex-1 py-3 text-white rounded-xl text-xs font-black uppercase transition-colors flex items-center justify-center gap-2 disabled:opacity-60" style={{ backgroundColor: plugin.color }}>
|
||||||
|
<Save size={14} /> {saving ? 'SALVANDO…' : 'SALVAR'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const PluginCard: React.FC<{ plugin: PluginDef; isActive: boolean; alwaysOn?: boolean; price?: number; onToggle: () => void; onConfigure: () => void }> = ({ plugin, isActive, alwaysOn, price, onToggle, onConfigure }) => {
|
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);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
@@ -391,7 +502,10 @@ export const PluginsView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config modal */}
|
{/* Config modal */}
|
||||||
{configuringPlugin && (
|
{configuringPlugin && configuringPlugin.id === 'newwhats' && (
|
||||||
|
<NewwhatsConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
|
||||||
|
)}
|
||||||
|
{configuringPlugin && configuringPlugin.id !== 'newwhats' && (
|
||||||
<ConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
|
<ConfigModal plugin={configuringPlugin} onClose={() => setConfiguringPlugin(null)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user