security(rbac): autorização por cargo na operação clínica

Gateia 15 rotas (cap espelhando o frontend):
- leads (GET/POST/converter) → 'leads' (funcionário + dono)
- pacientes mutações (export, POST, PUT/:id) → 'agenda' (staff clínico; bloqueia
  paciente; INCLUI dentista — OrthoView cria/edita paciente)
- agendamentos (POST + ações /🆔 PUT, DELETE, confirmar, falta, remarcar, restaurar,
  reencaminhar, solicitar-avaliacao) → 'agenda' (via requireCapabilityRow nas /:id)

Escolha de 'agenda' como discriminador 'staff clínico vs paciente': é a única capability
presente em funcionário/dentista/biomédico/protético/dono e AUSENTE em paciente — casa com
quem realmente opera (inclui o dentista que mexe em paciente pelo Ortho).

GETs de operação (lista de agendamentos/pacientes, detalhes) seguem abertos (consumidos por
todas as telas clínicas). Validado em DEV: dono/dentista passam onde devem; leads nega dentista;
reads 200; negação por linha sem apagar dados.

Deferido (sub-pass): presença, pendências/resolver, importar-google, interesses,
detalhes de paciente (score/faltas/família) e horários de dentista.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-19 17:59:20 +02:00
parent 116200ccc6
commit 5b58a8a01c
+15 -15
View File
@@ -1378,7 +1378,7 @@ app.post('/api/clinica-sync/import-pacientes', tenantGuard, async (req, res) =>
});
// Export CSV dos pacientes (o bem mais precioso). Sem "ultimo tratamento". Não precisa de Google.
app.get('/api/pacientes/export', authGuard, async (req, res) => {
app.get('/api/pacientes/export', authGuard, requireCapability('agenda'), async (req, res) => {
try {
const clinicaId = req.query.clinicaId;
if (!clinicaId) return res.status(400).send('clinicaId obrigatório');
@@ -1897,7 +1897,7 @@ app.get('/api/pacientes', tenantGuard, async (req, res) => {
}
});
app.post('/api/pacientes', authGuard, async (req, res) => {
app.post('/api/pacientes', authGuard, requireCapability('agenda'), async (req, res) => {
try {
const clinicaId = req.body.clinica_id || req.query.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório.' });
@@ -1912,7 +1912,7 @@ app.post('/api/pacientes', authGuard, async (req, res) => {
}
});
app.put('/api/pacientes/:id', authGuard, async (req, res) => {
app.put('/api/pacientes/:id', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => {
try {
const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id = $1', [req.params.id]);
if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' });
@@ -2627,7 +2627,7 @@ async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
}
// POST with concurrency lock: prevents double-booking the same dentist+time slot
app.post('/api/agendamentos', authGuard, async (req, res) => {
app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
@@ -2715,7 +2715,7 @@ app.post('/api/agendamentos', authGuard, async (req, res) => {
});
// PUT = editar / REAGENDAR (mover pra novo slot). Lock otimista por `version`.
app.put('/api/agendamentos/:id', authGuard, async (req, res) => {
app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
@@ -2837,7 +2837,7 @@ async function assertRowScoped(req, res, sql, params) {
// DELETE = cancelamento SOFT (status 'cancelado'). NUNCA remove a linha — preserva
// histórico/autoria ("quem cancelou") e gera pendência "a reagendar".
app.delete('/api/agendamentos/:id', authGuard, async (req, res) => {
app.delete('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
@@ -2859,7 +2859,7 @@ app.delete('/api/agendamentos/:id', authGuard, async (req, res) => {
});
// Marcar FALTA (no-show) → contabiliza falta do paciente + pendência "a reagendar".
app.post('/api/agendamentos/:id/falta', authGuard, async (req, res) => {
app.post('/api/agendamentos/:id/falta', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
@@ -2878,7 +2878,7 @@ app.post('/api/agendamentos/:id/falta', authGuard, async (req, res) => {
});
// REMARCAR (precisa nova data, SEM horário definido) → pendência "a reagendar".
app.post('/api/agendamentos/:id/remarcar', authGuard, async (req, res) => {
app.post('/api/agendamentos/:id/remarcar', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
@@ -2894,7 +2894,7 @@ app.post('/api/agendamentos/:id/remarcar', authGuard, async (req, res) => {
});
// RESTAURAR (desfaz cancelamento/falta/remarcar) → volta a 'agendado' e baixa a pendência gerada.
app.post('/api/agendamentos/:id/restaurar', authGuard, async (req, res) => {
app.post('/api/agendamentos/:id/restaurar', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
@@ -2910,7 +2910,7 @@ app.post('/api/agendamentos/:id/restaurar', authGuard, async (req, res) => {
// CONFIRMAR um agendamento IMPORTADO: vincula o paciente (exige CPF+telefone) e vira 'agendado'.
// Não escreve no Google (vínculo é interno). O paciente é enriquecido se faltava CPF/telefone.
app.post('/api/agendamentos/:id/confirmar', authGuard, async (req, res) => {
app.post('/api/agendamentos/:id/confirmar', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => {
try {
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
@@ -2952,7 +2952,7 @@ async function notificarDonoClinica(clinicaId, titulo, mensagem) {
}
// Dentista REENCAMINHA o agendamento de volta à clínica, com um motivo.
app.post('/api/agendamentos/:id/reencaminhar', authGuard, async (req, res) => {
app.post('/api/agendamentos/:id/reencaminhar', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => {
try {
const actor = req.authUser?.userId || null; const actorNm = await actorNome(actor);
const { motivo } = req.body || {};
@@ -2967,7 +2967,7 @@ app.post('/api/agendamentos/:id/reencaminhar', authGuard, async (req, res) => {
});
// Dentista SOLICITA UMA AVALIAÇÃO (Fase A: registra o pedido + avisa; odontograma/fotos = Fase B).
app.post('/api/agendamentos/:id/solicitar-avaliacao', authGuard, async (req, res) => {
app.post('/api/agendamentos/:id/solicitar-avaliacao', authGuard, requireCapabilityRow('agenda', 'agendamentos'), async (req, res) => {
try {
const actor = req.authUser?.userId || null; const actorNm = await actorNome(actor);
const { motivo } = req.body || {};
@@ -4167,7 +4167,7 @@ app.delete('/api/orcamentos/:id', tenantGuard, async (req, res) => {
});
// --- LEADS ---
app.get('/api/leads', tenantGuard, async (req, res) => {
app.get('/api/leads', tenantGuard, requireCapability('leads'), async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
@@ -4176,7 +4176,7 @@ app.get('/api/leads', tenantGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/leads', tenantGuard, async (req, res) => {
app.post('/api/leads', tenantGuard, requireCapability('leads'), async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório.' });
@@ -4188,7 +4188,7 @@ app.post('/api/leads', tenantGuard, async (req, res) => {
});
// Converte um LEAD em PACIENTE (e opcionalmente cria o 1º agendamento). Escopo por clínica.
app.post('/api/leads/:id/converter', tenantGuard, async (req, res) => {
app.post('/api/leads/:id/converter', tenantGuard, requireCapability('leads'), async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });