fix(newwhats): commita proxy.js/server.js do auto-provisionamento (main inconsistente)
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 <noreply@anthropic.com>
This commit is contained in:
+160
-18
@@ -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);
|
||||
|
||||
+10
-2
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user