4dea9ab28a
Os papéis (sec_numbers) são resolvidos no motor pela conta (x-nw-email). Um
usuário que só existe no scoreodonto (ex.: recepcionista) não tem conta no motor,
então buscar os papéis com o e-mail DELE retorna "conta não encontrada" → todos os
números liberados apareciam como "Sem papel definido". Agora o papel de cada sessão
liberada é buscado com o e-mail do DONO da sessão (cache por e-mail). Verificado:
recep passa a ver o número como role=clinic ("Clínica / Recepção · Rui Cesar").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
467 lines
22 KiB
JavaScript
467 lines
22 KiB
JavaScript
// 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 resolvidos no motor pela CONTA (x-nw-email). Um
|
|
// usuário sem conta no motor (ex.: recepcionista que só existe no scoreodonto)
|
|
// não tem números próprios — então o papel de cada número LIBERADO precisa ser
|
|
// buscado com o e-mail do DONO da sessão, não do usuário logado. Cache por e-mail.
|
|
const numbersCache = new Map();
|
|
const numbersFor = async (asEmail) => {
|
|
if (!numbersCache.has(asEmail)) {
|
|
const nums = await motorGet('/secretaria/numbers', asEmail);
|
|
numbersCache.set(asEmail, new Map(nums.map((n) => [n.instance_id, n])));
|
|
}
|
|
return numbersCache.get(asEmail);
|
|
};
|
|
const metaFrom = (roleMap, instanceId, fallbackName) => {
|
|
const n = roleMap.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 pushWith = (s, roleMap, 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,
|
|
...metaFrom(roleMap, s.id, s.name), ...extra,
|
|
});
|
|
};
|
|
|
|
// 1) Sessões PRÓPRIAS (conta do próprio usuário no motor) — papel da própria conta.
|
|
const ownRoles = await numbersFor(email);
|
|
for (const s of await motorGet('/sessions', email)) {
|
|
pushWith(s, ownRoles, { 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 */ }
|
|
const ownerRoles = await numbersFor(owner.email); // papéis dos números vêm da conta do DONO
|
|
for (const s of await motorGet('/sessions', owner.email)) {
|
|
if (allowed.has(s.id)) pushWith(s, ownerRoles, { 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());
|
|
};
|
|
}
|