feat(agenda): isolamento por setor (opt-in) — fecha vazamento horizontal intra-clínica
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user