From bfba57708034bb5237706e1fd39cdcf031179f52 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Thu, 25 Jun 2026 20:51:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(agenda):=20isolamento=20por=20setor=20(opt?= =?UTF-8?q?-in)=20=E2=80=94=20fecha=20vazamento=20horizontal=20intra-cl?= =?UTF-8?q?=C3=ADnica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A agenda só isolava entre clínicas; dentro da mesma clínica, qualquer membro via TUDO (todos os setores/equipes). Agora o setor passa a valer: - agendamentos.setor_id + dentistas.setor_id (migration) + índice (clinica_id, setor_id, start_time). - POST carimba o setor: override em dentistas.setor_id, senão o setor do VÍNCULO do próprio dentista (Gestão de Equipe, UI existente), senão o setor de quem agenda. NULL = "geral". - GET filtra (resolverSetorUsuario): dono/admin e membro SEM setor veem tudo (compat — clínica que não usa setores não muda); membro COM setor vê só o seu setor + os gerais (NULL). Salas não filtram. Equipe de protético/biomédico = setor próprio, herda a mesma regra. O anti-overbooking continua GLOBAL e acima do isolamento (isola a visão, mantém a integridade). Validado em dev: dono/sem-setor veem 3/3; setor A vê só A+geral; setor B vê só B+geral. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/backend/server.js b/backend/server.js index 202e15d..1ccba82 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2630,6 +2630,16 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => { let janela = ''; if (from) { params.push(from); janela += ` AND COALESCE(end_time, start_time) >= $${params.length}`; } if (to) { params.push(to); janela += ` AND start_time <= $${params.length}`; } + // Isolamento por setor (opt-in). Dono/admin e membro SEM setor → vê tudo + // (compat). Membro COM setor → só o próprio setor + os "gerais" (setor_id + // NULL). Não se aplica a salas (req.isSala não tem vínculo/setor → vê tudo). + if (!req.isSala) { + const { ehDono, setorId } = await resolverSetorUsuario(pool, req.authUser?.userId, clinicaId); + if (!ehDono && setorId) { + params.push(setorId); + janela += ` AND (setor_id = $${params.length} OR setor_id IS NULL)`; + } + } const { rows } = await pool.query( `SELECT * FROM agendamentos WHERE clinica_id = $1${janela} ORDER BY start_time`, params); res.json(rows.map(r => ({ @@ -2708,6 +2718,23 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req const conflito = await findOverbookingConflict(client, dentistaId, start, end); if (conflito) throw { code: 'CONF_OVERLAP', conflito }; + // Setor do agendamento (isolamento opt-in): ancora no setor do DENTISTA — + // primeiro um override explícito (dentistas.setor_id), senão o setor do + // vínculo do próprio profissional na clínica (definido em Gestão de Equipe, + // UI já existente). Sem isso, herda o setor de quem está agendando (recepção + // do setor). NULL = geral, visível a todos. + let setorAg = null; + const { rows: ds } = await client.query( + `SELECT d.setor_id AS d_setor, v.setor_id AS v_setor + FROM dentistas d + LEFT JOIN vinculos v ON v.usuario_id = d.usuario_id AND v.clinica_id = $2 + WHERE d.id = $1`, [dentistaId, rest.clinica_id]); + setorAg = ds[0]?.d_setor || ds[0]?.v_setor || null; + if (!setorAg) { + const { rows: vs } = await client.query('SELECT setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [actor, rest.clinica_id]); + setorAg = vs[0]?.setor_id || null; + } + // Map keys from camelCase/old attributes to correct lowercase PG columns if necessary const payload = { id, @@ -2721,6 +2748,7 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req observacoes: rest.observacoes, status: rest.status || 'agendado', clinica_id: rest.clinica_id, + setor_id: setorAg, created_by: actor, created_by_nome: actorNm, version: 1 @@ -2882,6 +2910,20 @@ async function userHasVinculo(userId, clinicaId) { return rows.length > 0; } +// Contexto de setor do usuário NA clínica (para isolamento opt-in da agenda): +// { ehDono, setorId }. ehDono (dono/admin/superadmin) → vê tudo. setorId NULL +// (membro sem setor) → vê tudo (compat: clínica que não usa setores). setorId +// preenchido → vê só o próprio setor + os "gerais" (setor_id NULL). +const DONO_ROLES = ['donoclinica', 'donoconsultorio', 'admin']; +async function resolverSetorUsuario(db, userId, clinicaId) { + if (!userId || !clinicaId) return { ehDono: false, setorId: null }; + const { rows: u } = await db.query('SELECT role FROM usuarios WHERE id = $1', [userId]); + if (u[0]?.role === 'superadmin') return { ehDono: true, setorId: null }; + const { rows } = await db.query('SELECT role, setor_id FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2', [userId, clinicaId]); + if (!rows.length) return { ehDono: false, setorId: null }; + return { ehDono: DONO_ROLES.includes(rows[0].role), setorId: rows[0].setor_id || null }; +} + // Carrega um tratamento orto e valida o vínculo do usuário com a clínica dele (403 se não). async function assertOrtoScoped(req, res) { const { rows } = await pool.query('SELECT clinica_id FROM ortho_tratamento WHERE id = $1', [req.params.id]); @@ -6299,6 +6341,13 @@ async function runMigrations() { `ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1`, `ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS google_event_id TEXT`, `ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS cor TEXT`, + // ── Isolamento por setor (opt-in) ────────────────────────────────────── + // O agendamento carimba o setor a que pertence (do dentista, ou de quem o + // criou). NULL = "geral" (visível a todos → retrocompat). dentistas.setor_id + // é o reforço opcional para ancorar a agenda do profissional num setor. + `ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS setor_id TEXT`, + `ALTER TABLE dentistas ADD COLUMN IF NOT EXISTS setor_id TEXT`, + `CREATE INDEX IF NOT EXISTS idx_agendamentos_clinica_setor_start ON agendamentos (clinica_id, setor_id, start_time)`, `CREATE TABLE IF NOT EXISTS audit_log ( id TEXT PRIMARY KEY, clinica_id TEXT,