Files
scoreodonto.com/backend/newwhats/index.js
T
VPS 4 Builder 21bebd3256 feat(newwhats): ownership de sessões, auto-provisionamento, assinatura e drawer na agenda
- Drawer de WhatsApp focado (WhatsChatDrawer) aberto do slot do agendamento, com
  dropdown de números do paciente + grupo familiar e fluxo de iniciar conversa
- Auto-provisionamento de conta no motor: eager no cadastro/convite + self-heal no
  proxy (404 "conta não encontrada" → provisiona e refaz o request)
- Ownership por sessão (wa_session_authz): dono do workspace (owner_id ou vínculo
  donoclinica/donoconsultorio) tem poder total; delega re-scan, excluir sessão,
  apagar conversa/mensagem, acesso ao Inbox e à Secretária — por conta
- Enforcement no proxy: guardSessionAction (scan/rescan/delete sessão),
  guardAreaAccess (inbox/secretaria), guardInboxDestructive (apagar conversa/msg)
- Endpoints /api/nw/authz (dono gerencia) e /api/nw/access (acesso agregado)
- Assinatura do operador nas mensagens (*Nome*, desambiguação de homônimos → *Rui C.*)
- UI: painel "Gerenciar acesso" com 5 toggles; menu e botões de apagar condicionados
- Proxy de mídia /api/nw/media + re-download sob demanda

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:26:31 +02:00

167 lines
8.1 KiB
JavaScript

// 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, { superadminGuard })
import { createRoutes } from './routes.js'
import { createWebhookReceiver } from './webhook-receiver.js'
import { createRestProxy, provisionNewwhatsAccount } from './proxy.js'
import { getConfig, isConfigured, loadConfigFromDb, saveConfigToDb } from './config.js'
import { Readable } from 'node:stream'
export { syncKnowledge, buildKnowledgeText } from './sync-knowledge.js'
export { createWsTunnel } from './proxy.js'
// Provisiona (eager) a conta do email no motor NewWhats, best-effort e idempotente.
// Chamado no cadastro de usuário/clínica do scoreodonto: "assim que a conta nasce
// aqui, já nasce lá — pronta esperando quando a pessoa for mexer com WhatsApp".
export async function provisionNewwhatsAccountEager(pool, email) {
try {
const cfg = getConfig()
if (!cfg.motorUrl || !cfg.integrationKey || !email) return false
return await provisionNewwhatsAccount(pool, cfg, email)
} catch { return false }
}
import { createAgendaBridge } from './agenda-bridge.js'
// Valida uma chave contra o motor. Envia um email-sonda (a chave mestra exige
// x-nw-email). 200 = ok com dados; 404 = chave válida mas email-sonda inexistente
// (= chave OK); 401 = chave inválida.
async function testMotorKey(motorUrl, key) {
try {
const r = await fetch(`${motorUrl}/api/ext/v1/sessions`, {
headers: { 'x-nw-key': key, 'x-nw-email': 'probe@nw.local' },
});
if (r.ok || r.status === 404) return { ok: true, detail: 'Conexão OK' };
return { ok: false, detail: `Motor respondeu ${r.status}` };
} catch (e) {
return { ok: false, detail: `Motor inacessível: ${e.message}` };
}
}
export function registerNewwhats(app, pool, opts = {}) {
// passthrough middleware caso o guard não seja fornecido (dev)
const superadminGuard = opts.superadminGuard || ((_req, _res, next) => next());
// ── Ponte de agenda (motor → agenda real do scoreodonto) ───────────────────
// Auth própria por segredo compartilhado (x-nw-agenda-secret). Server-to-server.
app.use('/api/nw/agenda', createAgendaBridge(pool));
// ── Proxy de MÍDIA: /api/nw/media/* → motor /media/* ───────────────────────
// A mídia do WhatsApp vive no motor (servida do Wasabi/local, pública). O
// satélite a repassa mantendo same-origin no <img>/<video> (sem exigir header
// de auth no browser), com suporte a Range para áudio/vídeo.
app.use('/api/nw/media', async (req, res) => {
if (req.method !== 'GET') { res.status(405).end(); return; }
const cfg = getConfig();
if (!cfg.motorUrl) { res.status(503).end(); return; }
const rest = req.path.replace(/^\//, '');
const url = `${cfg.motorUrl.replace(/\/$/, '')}/media/${rest}`;
try {
const headers = {};
if (req.headers.range) headers.range = req.headers.range;
const r = await fetch(url, { headers });
res.status(r.status);
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control', 'content-disposition']) {
const v = r.headers.get(h);
if (v) res.setHeader(h, v);
}
if (!res.getHeader('cache-control')) res.setHeader('cache-control', 'private, max-age=3600');
if (r.body) Readable.fromWeb(r.body).pipe(res);
else res.end();
} catch (e) {
res.status(502).json({ error: `Mídia indisponível: ${e.message}` });
}
});
// 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(() => {});
// ── 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(),
});
});
// 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 });
}
});
// Aplica um TOKEN de pareamento — auto-configura URL + chave + segredo do webhook.
// Token = "nwpair_" + base64url(JSON({ u: motorUrl, k: integrationKey, s: webhookSecret })).
// A clínica continua sendo escolhida localmente (o motor não conhece os IDs daqui).
app.post('/api/nw/config/token', superadminGuard, async (req, res) => {
try {
let raw = String((req.body || {}).token || '').trim();
raw = raw.replace(/^nwpair[_:]\s*/i, '');
let p;
try { p = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')); }
catch { return res.status(400).json({ error: 'Token inválido (não foi possível decodificar).' }); }
const motorUrl = (p.u || p.url || '').replace(/\/$/, '');
const integrationKey = p.k || p.key || p.integrationKey || '';
const webhookSecret = p.s || p.secret || p.webhookSecret || '';
if (!motorUrl || !integrationKey) {
return res.status(400).json({ error: 'Token incompleto (faltam URL ou chave).' });
}
// Valida a chave contra o motor ANTES de salvar.
const { ok: motorOk, detail } = await testMotorKey(motorUrl, integrationKey);
if (!motorOk) return res.status(400).json({ error: `Token não validou no motor: ${detail}` });
const cur = getConfig();
await saveConfigToDb(pool, { motorUrl, integrationKey, webhookSecret: webhookSecret || cur.webhookSecret, clinicaId: cur.clinicaId });
res.json({ ok: true, configured: isConfigured(), motorUrl, hasWebhookSecret: Boolean(webhookSecret || cur.webhookSecret), detail });
} 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' });
const { ok, detail } = await testMotorKey(c.motorUrl, c.integrationKey);
res.json({ configured: true, motorOk: ok, detail });
});
// ── 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(pool)); // proxy do inbox → motor (express 5: use, não wildcard) + enforcement de ownership
console.log('[newwhats] integração registrada (/nw, /api/webhook/newwhats, /api/nw/v1/*, /api/nw/config)');
}