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>
This commit is contained in:
+190
-7
@@ -10,25 +10,202 @@ import tls from 'node:tls';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { getConfig } from './config.js';
|
||||
|
||||
function emailFromReq(req) {
|
||||
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);
|
||||
return (p.email || '').toLowerCase() || null;
|
||||
const email = (p.email || '').toLowerCase() || null;
|
||||
if (!email) return null;
|
||||
return { email, userId: p.userId || null };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function createRestProxy() {
|
||||
// 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; }
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 isDeleteMsg = method === 'DELETE' && /^\/inbox\/[^/]+\/messages\/[^/]+/.test(tail);
|
||||
if (!isDeleteChat && !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 email = emailFromReq(req);
|
||||
if (!email) return res.status(401).json({ error: 'Sessão inválida (faça login no scoreodonto).' });
|
||||
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). 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}`;
|
||||
}
|
||||
|
||||
const url = `${cfg.motorUrl}/api/ext/v1${tail}`;
|
||||
|
||||
const opts = { method: req.method, headers: {
|
||||
@@ -43,8 +220,14 @@ export function createRestProxy() {
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(url, opts);
|
||||
const text = await r.text();
|
||||
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) && email) {
|
||||
const ok = await provisionNewwhatsAccount(pool, cfg, email);
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user