// Proxy REST: /api/nw/v1/* (frontend) → motor /api/ext/v1/*. // // Segurança: valida o JWT do scoreodonto (só usuário logado acessa). // Resolução de conta: SEMPRE via x-nw-email (chave mestra resolve o tenant pelo // email). Todo usuário da clínica tem conta própria no motor — se o motor não // conhecer o email, é erro (404 "conta não encontrada"), não há fallback. // O email vem SEMPRE do token verificado — nunca do cliente. import net from 'node:net'; import tls from 'node:tls'; import jwt from 'jsonwebtoken'; import { getConfig } from './config.js'; function authFromReq(req) { try { const tk = (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim(); if (!tk) return null; const p = jwt.verify(tk, process.env.JWT_SECRET); const email = (p.email || '').toLowerCase() || null; if (!email) return null; return { email, userId: p.userId || null }; } catch { return null; } } // Resolve o dono do workspace (clínica OU sala). Retorna o userId dono ou null. async function resolveOwnerId(pool, clinicaId) { if (!pool || !clinicaId) return null; try { const { rows: cl } = await pool.query('SELECT owner_id FROM clinicas WHERE id = $1', [clinicaId]); if (cl.length) { if (cl[0].owner_id) return cl[0].owner_id; // Clínicas antigas sem owner_id: dono = vínculo donoclinica/donoconsultorio. const { rows: v } = await pool.query( "SELECT usuario_id FROM vinculos WHERE clinica_id = $1 AND role IN ('donoclinica','donoconsultorio') ORDER BY id LIMIT 1", [clinicaId]); return v[0]?.usuario_id || null; } const { rows: sl } = await pool.query('SELECT owner_usuario_id FROM salas WHERE id = $1', [clinicaId]); return sl[0]?.owner_usuario_id || null; } catch { return null; } } // Resolve o dono do workspace COM e-mail. Retorna { ownerId, email } ou null. // Usado para a inbox compartilhada: a caixa da clínica vive na conta (tenant) do // dono no motor, então falamos com o motor usando o e-mail DELE. async function resolveOwner(pool, clinicaId) { const ownerId = await resolveOwnerId(pool, clinicaId); if (!ownerId) return null; try { const { rows } = await pool.query('SELECT lower(email) AS email FROM usuarios WHERE id = $1', [ownerId]); return { ownerId, email: rows[0]?.email || null }; } catch { return { ownerId, email: null }; } } // Autoriza (ou nega 403) uma ação sensível de sessão de WhatsApp. // Mapeamento das ações do motor: // POST /sessions → scan INICIAL (criar) → só o dono // GET /sessions/:id/qr → re-scan (parear QR) → dono OU can_rescan // POST /sessions/:id/connect → re-scan (reconectar) → dono OU can_rescan // DELETE /sessions/:id → excluir → dono OU can_delete // Retorna { ok: true } ou { ok: false, status, error }. async function guardSessionAction(pool, req, tail, userId) { const method = req.method.toUpperCase(); const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect)?)?/); if (!m) return { ok: true }; // não é uma rota de sessão const instanceId = m[1] || null; const sub = m[2] || null; const isCreate = method === 'POST' && !instanceId; // POST /sessions const isRescan = (method === 'GET' && sub === '/qr') || (method === 'POST' && sub === '/connect'); // parear/reconectar const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id if (!isCreate && !isRescan && !isDelete) return { ok: true }; // GET lista / etc → livre const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null; if (!clinicaId) return { ok: false, status: 403, error: 'Workspace não identificado para esta ação.' }; const ownerId = await resolveOwnerId(pool, clinicaId); if (ownerId && ownerId === userId) return { ok: true }; // dono: poder total // Scan inicial é exclusivo do dono — não delegável. if (isCreate) return { ok: false, status: 403, error: 'Apenas o dono pode conectar um novo WhatsApp.' }; // Re-scan / exclusão: exigem delegação por sessão. if (!instanceId) return { ok: false, status: 403, error: 'Sessão não identificada.' }; let authz = null; try { const { rows } = await pool.query( 'SELECT can_rescan, can_delete FROM wa_session_authz WHERE clinica_id = $1 AND instance_id = $2 AND usuario_id = $3', [clinicaId, instanceId, userId]); authz = rows[0] || null; } catch { /* nega por padrão */ } if (isRescan && authz?.can_rescan) return { ok: true }; if (isDelete && authz?.can_delete) return { ok: true }; return { ok: false, status: 403, error: isDelete ? 'Somente o dono pode excluir esta sessão. Peça autorização de exclusão.' : 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.', }; } // Auto-provisiona (idempotente) a conta do email no motor, via chave mestra. // Usado como self-heal quando o motor responde "Conta não encontrada". export async function provisionNewwhatsAccount(pool, cfg, email) { if (!cfg.motorUrl || !cfg.integrationKey || !email) return false; let name = email; try { if (pool) { const { rows } = await pool.query('SELECT nome FROM usuarios WHERE lower(email) = $1', [email.toLowerCase()]); if (rows[0]?.nome) name = rows[0].nome; } } catch { /* usa email como nome */ } try { const r = await fetch(`${cfg.motorUrl}/api/ext/v1/satellites/accounts`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey }, body: JSON.stringify({ email, name }), }); return r.ok; } catch { return false; } } // Monta a assinatura do operador para prefixar mensagens: primeiro nome; se houver // outro membro do workspace com o MESMO primeiro nome, desambigua com "Nome I." (inicial // do primeiro sobrenome). Retorna null se não houver nome. export async function operatorSignature(pool, userId, clinicaId) { if (!pool || !userId) return null; try { const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [userId]); const full = String(rows[0]?.nome || '').trim(); if (!full) return null; const parts = full.split(/\s+/); const first = parts[0]; let ambiguous = false; if (clinicaId) { const { rows: m } = await pool.query( `SELECT u.nome FROM vinculos v JOIN usuarios u ON u.id = v.usuario_id WHERE v.clinica_id = $1 AND u.id <> $2`, [clinicaId, userId]); ambiguous = m.some(x => String(x.nome || '').trim().split(/\s+/)[0].toLowerCase() === first.toLowerCase()); } if (ambiguous && parts.length > 1 && parts[1][0]) { return `${first} ${parts[1][0].toUpperCase()}.`; } return first; } catch { return null; } } // Gate de acesso a ÁREAS (Inbox / Secretária). Dono passa sempre; os demais só com // delegação (can_inbox / can_secretaria) do dono em alguma sessão do workspace. async function guardAreaAccess(pool, tail, userId, clinicaId) { const isSecretaria = /^\/(secretaria|sec)(\/|$|\?)/.test(tail); const isInbox = /^\/(inbox|conversations)(\/|$|\?)/.test(tail); if (!isSecretaria && !isInbox) return { ok: true }; if (!pool || !clinicaId) return { ok: true }; // sem workspace resolvido, não barra aqui const ownerId = await resolveOwnerId(pool, clinicaId); if (ownerId && ownerId === userId) return { ok: true }; const col = isSecretaria ? 'can_secretaria' : 'can_inbox'; try { const { rows } = await pool.query( `SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND ${col} = true LIMIT 1`, [clinicaId, userId]); if (rows.length) return { ok: true }; } catch { /* nega por padrão */ } return { ok: false, status: 403, error: isSecretaria ? 'Sem acesso à Secretária. Peça autorização ao dono.' : 'Sem acesso ao Inbox. Peça autorização ao dono.' }; } // Gate de ações DESTRUTIVAS no inbox (apagar conversa / apagar mensagem). // Só o dono ou quem tiver can_delete_msg. (Complementa o gate de acesso ao inbox.) async function guardInboxDestructive(pool, method, tail, userId, clinicaId) { const isDeleteChat = method === 'DELETE' && /^\/inbox\/[^/]+\/?(\?|$)/.test(tail); const isClearMsgs = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/?(\?|$)/.test(tail); // limpar conversa (todas as msgs) const isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail); if (!isDeleteChat && !isClearMsgs && !isDeleteMsg) return { ok: true }; if (!pool || !clinicaId) return { ok: true }; const ownerId = await resolveOwnerId(pool, clinicaId); if (ownerId && ownerId === userId) return { ok: true }; try { const { rows } = await pool.query( 'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_delete_msg = true LIMIT 1', [clinicaId, userId]); if (rows.length) return { ok: true }; } catch { /* nega por padrão */ } return { ok: false, status: 403, error: 'Sem permissão para apagar conversas/mensagens. Peça autorização ao dono.' }; } export function createRestProxy(pool) { 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.' }); } const auth = authFromReq(req); if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' }); const { email, userId } = auth; const tail = req.originalUrl.replace(/^\/api\/nw\/v1/, ''); const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null; // Enforcement de ownership/permissões antes de repassar ao motor. const guard = await guardSessionAction(pool, req, tail, userId); if (!guard.ok) return res.status(guard.status).json({ error: guard.error }); // Gate de acesso às áreas Inbox / Secretária. const area = await guardAreaAccess(pool, tail, userId, clinicaId); if (!area.ok) return res.status(area.status).json({ error: area.error }); // Gate de ações destrutivas do inbox (apagar conversa/mensagem). const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId); if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error }); // Assinatura do operador: prefixa o texto enviado com "*Nome:*\n" (identifica quem // respondeu numa caixa compartilhada). O ":" fica DENTRO do negrito para que TODOS // os dispositivos (contato/outros operadores) e jids vejam "Nome:" em negrito // consistente — não só a nossa UI. Só no envio de texto por humano. if (req.method === 'POST' && /^\/inbox\/[^/]+\/send$/.test(tail) && req.body && typeof req.body.text === 'string' && req.body.text.trim()) { const sig = await operatorSignature(pool, userId, clinicaId); if (sig) req.body.text = `*${sig}:*\n${req.body.text}`; } // Conta-alvo no motor. Por padrão é a do próprio usuário (e-mail do JWT). Mas a // inbox da clínica vive na conta (tenant) do DONO — então, nas rotas de inbox, // se o usuário NÃO é dono do workspace e chegou até aqui (guardAreaAccess já // exigiu can_inbox), falamos com o motor como o dono. Assim, logado como ele // mesmo, o usuário vê a caixa do número da clínica no seletor — sem trocar de // login. Só a inbox comuta a conta; demais rotas seguem com o e-mail próprio. let targetEmail = email; if (/^\/(inbox|conversations)(\/|$|\?)/.test(tail) && clinicaId) { const owner = await resolveOwner(pool, clinicaId); if (owner && owner.ownerId !== userId && owner.email) targetEmail = owner.email; } const url = `${cfg.motorUrl}/api/ext/v1${tail}`; // Upload de arquivo grande: o corpo é multipart e NÃO passa pelo body-parser JSON // (o stream chega intacto). Encaminhamos o stream cru ao motor, preservando o // Content-Type (com boundary) — sem base64, sem limite de JSON. const isMultipart = /multipart\/form-data/i.test(req.headers['content-type'] || ''); const opts = { method: req.method, headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': targetEmail, // clínica do workspace ativo (modelo de canal) — repassa ao motor se veio. ...(req.headers['x-nw-clinica'] ? { 'x-nw-clinica': String(req.headers['x-nw-clinica']) } : {}), ...(isMultipart ? { 'content-type': req.headers['content-type'] } : { 'Content-Type': 'application/json' }), } }; if (isMultipart) { opts.body = req; // stream cru do upload → motor opts.duplex = 'half'; // exigido pelo fetch (undici) ao enviar stream } else if (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) { opts.body = JSON.stringify(req.body); } try { let r = await fetch(url, opts); let text = await r.text(); // Self-heal: a conta ainda não foi espelhada no motor. Provisiona (idempotente) // e refaz o request UMA vez — assim "quando a pessoa for mexer, tudo funciona". if (r.status === 404 && /conta n[ãa]o encontrada/i.test(text) && targetEmail) { const ok = await provisionNewwhatsAccount(pool, cfg, targetEmail); if (ok) { r = await fetch(url, opts); 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}` }); } }; } // ── GET /api/nw/accounts — contas de WhatsApp que o usuário logado pode operar ── // // Alimenta o seletor de número da inbox (multi-conta, SEM trocar de login) e o // ícone (i) de papéis. Junta: (1) as sessões da PRÓPRIA conta do usuário no motor // e (2) as sessões LIBERADAS pelo dono (can_inbox por sessão em wa_session_authz), // buscadas no motor com o e-mail do dono. Cada item traz o papel (sec_numbers). export function createAccountsRoute(pool) { return async function accountsRoute(req, res) { const cfg = getConfig(); if (!cfg.motorUrl || !cfg.integrationKey) { return res.status(503).json({ error: 'Integração NewWhats não configurada.' }); } const auth = authFromReq(req); if (!auth) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' }); const { email, userId } = auth; const motorGet = async (path, asEmail) => { try { const r = await fetch(`${cfg.motorUrl}/api/ext/v1${path}`, { headers: { 'x-nw-key': cfg.integrationKey, 'x-nw-email': asEmail }, }); if (!r.ok) return []; const j = await r.json(); return Array.isArray(j) ? j : []; } catch { return []; } }; try { // Papéis: sec_numbers são globais no motor — um fetch, indexado por instance_id. const numbers = await motorGet('/secretaria/numbers', email); const roleOf = new Map(numbers.map((n) => [n.instance_id, n])); const meta = (instanceId, fallbackName) => { const n = roleOf.get(instanceId) || {}; return { role: n.role || null, label: n.label || fallbackName || null, area: n.area || null, notes: n.notes || null }; }; const out = []; const seen = new Set(); const push = (s, extra) => { if (!s || seen.has(s.id)) return; seen.add(s.id); out.push({ instanceId: s.id, phone: s.phone || null, name: s.name || null, status: s.status || null, avatar: s.avatar || null, ...meta(s.id, s.name), ...extra, }); }; // 1) Sessões PRÓPRIAS (conta do próprio usuário no motor). for (const s of await motorGet('/sessions', email)) { push(s, { clinicaId: null, ownerEmail: email, ownerNome: null, own: true, canInbox: true }); } // 2) Sessões LIBERADAS por outros donos (can_inbox por sessão). const { rows: grants } = await pool.query( `SELECT DISTINCT clinica_id, instance_id FROM wa_session_authz WHERE usuario_id = $1 AND can_inbox = true`, [userId]); const byClinica = new Map(); for (const g of grants) { if (!byClinica.has(g.clinica_id)) byClinica.set(g.clinica_id, new Set()); byClinica.get(g.clinica_id).add(g.instance_id); } for (const [clinicaId, allowed] of byClinica) { const owner = await resolveOwner(pool, clinicaId); if (!owner || !owner.email || owner.ownerId === userId) continue; // dono próprio já veio em (1) let ownerNome = null; try { const { rows } = await pool.query('SELECT nome FROM usuarios WHERE id = $1', [owner.ownerId]); ownerNome = rows[0]?.nome || null; } catch { /* opcional */ } for (const s of await motorGet('/sessions', owner.email)) { if (allowed.has(s.id)) push(s, { clinicaId, ownerEmail: owner.email, ownerNome, own: false, canInbox: true }); } } res.json(out); } catch (e) { res.status(502).json({ error: `Falha ao montar contas: ${e.message}` }); } }; } // ── Túnel WebSocket: /api/nw/v1/stream (browser) → motor /api/ext/v1/stream ── // // O inbox em tempo real do motor (QR, status da sessão, mensagens novas) roda // sobre WebSocket. Aqui abrimos um túnel TCP bruto socket↔socket e injetamos a // x-nw-key no handshake com o motor — a chave nunca chega ao browser. // // Auth do usuário: o browser NÃO envia header Authorization no upgrade WS, então // o JWT do scoreodonto vem por ?token= (mesmo esquema do /api/ws do app). Só // usuário logado abre o túnel; o token fica local — não é repassado ao motor. // // Motor: autentica o /api/ext/v1/stream pela x-nw-key + x-nw-email (a chave é a // mestra; o tenant é resolvido pelo email do usuário) e exige o path EXATO, sem // query — por isso reescrevemos um request-line limpo. O email vem do JWT. // // Conta-alvo do stream: por padrão a própria. Se o browser mandar ?clinica= (a // conta ativa do seletor) e o usuário não for o dono mas tiver can_inbox nela, // o stream passa a ser o do DONO — assim a caixa compartilhada recebe mensagens // novas em tempo real. Mesma regra do proxy REST (reescrita de x-nw-email). export function createWsTunnel(pool) { return async function wsTunnel(req, clientSocket, head) { const fail = (statusLine) => { try { clientSocket.write(`HTTP/1.1 ${statusLine}\r\n\r\n`); } catch { /* socket já morto */ } clientSocket.destroy(); }; // Auth: JWT do scoreodonto no query string (?token=); conta ativa em ?clinica=. let token, clinica; try { const u = new URL(req.url, 'http://localhost'); token = u.searchParams.get('token'); clinica = u.searchParams.get('clinica'); } catch { return fail('400 Bad Request'); } let email, userId; try { const p = jwt.verify(token, process.env.JWT_SECRET); email = (p.email || '').toLowerCase(); userId = p.userId || null; } catch { return fail('401 Unauthorized'); } if (!email) return fail('401 Unauthorized'); // Reescrita da conta-alvo p/ a caixa compartilhada (só leitura via can_inbox). let targetEmail = email; if (clinica && pool && userId) { try { const owner = await resolveOwner(pool, clinica); if (owner && owner.email && owner.ownerId !== userId) { const { rows } = await pool.query( 'SELECT 1 FROM wa_session_authz WHERE clinica_id = $1 AND usuario_id = $2 AND can_inbox = true LIMIT 1', [clinica, userId]); if (rows.length) targetEmail = owner.email; } } catch { /* mantém a própria conta */ } } const cfg = getConfig(); if (!cfg.motorUrl || !cfg.integrationKey) return fail('503 Service Unavailable'); let target; try { target = new URL(cfg.motorUrl); } catch { return fail('502 Bad Gateway'); } const isHttps = target.protocol === 'https:'; const host = target.hostname; const port = Number(target.port) || (isHttps ? 443 : 80); const onConnect = () => { // Reescreve o handshake: path EXATO + Host do motor + x-nw-key. // Preserva os headers do WS (sec-websocket-*, upgrade, connection). const headers = Object.entries(req.headers) .filter(([k]) => !['host', 'x-nw-key', 'x-nw-email'].includes(k.toLowerCase())) .map(([k, v]) => `${k}: ${v}`) .join('\r\n'); proxySocket.write( `${req.method} /api/ext/v1/stream HTTP/1.1\r\n` + `Host: ${host}\r\n` + `x-nw-key: ${cfg.integrationKey}\r\n` + `x-nw-email: ${targetEmail}\r\n` + `${headers}\r\n\r\n` ); if (head && head.length) proxySocket.write(head); proxySocket.pipe(clientSocket); clientSocket.pipe(proxySocket); }; const proxySocket = isHttps ? tls.connect({ host, port, servername: host }, onConnect) : net.connect(port, host, onConnect); proxySocket.on('error', (err) => { console.error('[newwhats] túnel WS erro:', err.message); clientSocket.destroy(); }); clientSocket.on('error', () => proxySocket.destroy()); clientSocket.on('close', () => proxySocket.destroy()); proxySocket.on('close', () => clientSocket.destroy()); }; }