From f806835ae3e59c2f240a83d2accfe672ccc19639 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 16:34:07 +0200 Subject: [PATCH] fix(newwhats): commita proxy.js/server.js do auto-provisionamento (main inconsistente) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O index.js (já no main) importa createAccountsRoute/provisionNewwhatsAccount do proxy.js, mas esses exports nunca foram commitados — só existiam no working tree. Isso quebrava o boot do backend na imagem de produção (SyntaxError: proxy.js does not provide an export named 'createAccountsRoute') → 502. Commita proxy.js (exports createAccountsRoute/provisionNewwhatsAccount/operatorSignature/createWsTunnel) e server.js (usa operatorSignature + parser 12mb em /api/nw/v1), deixando o main consistente com o backend que já roda em dev. Co-Authored-By: Claude Opus 4.8 --- backend/newwhats/proxy.js | 178 ++++++++++++++++++++++++++++++++++---- backend/server.js | 12 ++- 2 files changed, 170 insertions(+), 20 deletions(-) diff --git a/backend/newwhats/proxy.js b/backend/newwhats/proxy.js index be1fff2..5f333c0 100644 --- a/backend/newwhats/proxy.js +++ b/backend/newwhats/proxy.js @@ -39,6 +39,18 @@ async function resolveOwnerId(pool, clinicaId) { } 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 @@ -115,7 +127,7 @@ export async function provisionNewwhatsAccount(pool, cfg, email) { // 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. -async function operatorSignature(pool, userId, clinicaId) { +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]); @@ -160,8 +172,9 @@ async function guardAreaAccess(pool, tail, userId, clinicaId) { // 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 && !isDeleteMsg) return { ok: true }; + 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 }; @@ -199,23 +212,47 @@ export function createRestProxy(pool) { 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). Só no envio de texto por humano. + // 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}`; + 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: { - 'Content-Type': 'application/json', 'x-nw-key': cfg.integrationKey, - 'x-nw-email': email, + '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 (!['GET', 'HEAD'].includes(req.method) && req.body && Object.keys(req.body).length) { + 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); } @@ -224,8 +261,8 @@ export function createRestProxy(pool) { 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) && email) { - const ok = await provisionNewwhatsAccount(pool, cfg, email); + 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); @@ -236,6 +273,88 @@ export function createRestProxy(pool) { }; } +// ── 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 @@ -249,24 +368,47 @@ export function createRestProxy(pool) { // 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. -export function createWsTunnel() { - return function wsTunnel(req, clientSocket, head) { +// +// 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=). - let token; - try { token = new URL(req.url, 'http://localhost').searchParams.get('token'); } - catch { return fail('400 Bad Request'); } - let email; + // 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'); @@ -289,7 +431,7 @@ export function createWsTunnel() { `${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: ${email}\r\n` + + `x-nw-email: ${targetEmail}\r\n` + `${headers}\r\n\r\n` ); if (head && head.length) proxySocket.write(head); diff --git a/backend/server.js b/backend/server.js index b67586c..429cecb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -12,6 +12,7 @@ import path from 'path'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { registerNewwhats, createWsTunnel, provisionNewwhatsAccountEager } from './newwhats/index.js'; +import { operatorSignature } from './newwhats/proxy.js'; const { Pool } = pg; const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026'; @@ -165,6 +166,9 @@ try { // EXPRESS // ============================================================================= const app = express(); +// Envio de mídia pelo plugin WhatsApp vai em base64 (JSON) — precisa de mais folga +// que o limite global. Este parser roda ANTES do global (2mb) só para /api/nw/v1. +app.use('/api/nw/v1', express.json({ limit: '12mb' })); app.use(express.json({ limit: '2mb' })); app.use(cors({ origin: process.env.CORS_ORIGIN || '*', @@ -839,9 +843,12 @@ app.get('/api/nw/access', tenantGuard, async (req, res) => { const clinicaId = req.clinicaId; const meId = req.authUser.userId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' }); + // Assinatura do operador (mesma do proxy) — o frontend usa para exibir "Nome:" na + // mensagem otimista NA HORA, sem esperar o eco do servidor (evita o flicker). + const signature = await operatorSignature(pool, meId, clinicaId); const owner = await resolveWorkspaceOwner(clinicaId); if (owner && owner.ownerId === meId) { - return res.json({ isOwner: true, inbox: true, secretaria: true, delete_msg: true }); + return res.json({ isOwner: true, inbox: true, secretaria: true, delete_msg: true, signature }); } const { rows } = await pool.query( `SELECT bool_or(can_inbox) AS inbox, bool_or(can_secretaria) AS secretaria, bool_or(can_delete_msg) AS delete_msg @@ -851,6 +858,7 @@ app.get('/api/nw/access', tenantGuard, async (req, res) => { inbox: !!rows[0]?.inbox, secretaria: !!rows[0]?.secretaria, delete_msg: !!rows[0]?.delete_msg, + signature, }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -10125,7 +10133,7 @@ const wss = new WebSocketServer({ noServer: true }); // Dispatcher único de upgrade WS: roteia por path. O `ws` em modo {server,path} // aborta com 400 qualquer upgrade de outro path, então centralizamos aqui para o // /api/ws (app) coexistir com o túnel do NewWhats (/api/nw/v1/stream → motor). -const nwWsTunnel = createWsTunnel(); +const nwWsTunnel = createWsTunnel(pool); httpServer.on('upgrade', (req, socket, head) => { let pathname; try { pathname = new URL(req.url, 'http://localhost').pathname; }