From 882af1645806778261f00e71647a88803a473480 Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Mon, 6 Jul 2026 19:11:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20escopo=20por=20papel=20?= =?UTF-8?q?=E2=80=94=20dentista=20n=C3=A3o=20v=C3=AA=20agenda=20da=20cl?= =?UTF-8?q?=C3=ADnica=20nem=20financeiro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/dashboard/stats devolvia a agenda inteira da clínica (nomes de pacientes de todos os dentistas) + faturamento/despesas/growth no payload para QUALQUER membro, inclusive dentista. Pior: cache por clínica (dashboard:stats:) servia o mesmo JSON a todos os papéis. Agora: dentista vê só a própria agenda; financeiro só p/ admin/donoclinica/donoconsultorio/funcionario; cache por escopo (papel/dentista). Verificado: dentista recém-cadastrado (murilo) → agenda vazia + financeiro 0; dono → tudo. Co-Authored-By: Claude Opus 4.8 --- backend/server.js | 84 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/backend/server.js b/backend/server.js index 429cecb..497e4c4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1927,7 +1927,25 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => { try { const clinicaId = req.clinicaId; if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' }); - const cacheKey = `dashboard:stats:${clinicaId}`; + // Escopo por PAPEL: um dentista vê só a PRÓPRIA agenda; o financeiro só é + // devolvido a dono/admin/funcionário. Sem isso, um dentista recém-cadastrado + // via a agenda inteira da clínica + os financeiros no payload (mesmo com o + // front escondendo os cards). O cache passa a ser por ESCOPO (não só clínica). + let role = null, dentistaIds = []; + try { + const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id = $1 AND clinica_id = $2 LIMIT 1', [req.authUser.userId, clinicaId]); + role = rows[0]?.role || null; + } catch { /* segue */ } + const isDentista = role === 'dentista'; + const verFinanceiro = ['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role); + if (isDentista) { + try { + const { rows } = await pool.query('SELECT id FROM dentistas WHERE usuario_id = $1 AND clinica_id = $2', [req.authUser.userId, clinicaId]); + dentistaIds = rows.map((r) => r.id); + } catch { /* segue */ } + } + const scope = isDentista ? `dent:${dentistaIds.join('_') || 'none'}` : (verFinanceiro ? 'full' : 'basic'); + const cacheKey = `dashboard:stats:${clinicaId}:${scope}`; if (redis) { const cachedStats = await redis.get(cacheKey); if (cachedStats) { @@ -1937,43 +1955,55 @@ app.get('/api/dashboard/stats', tenantGuard, async (req, res) => { } const { rows: pacientes } = await pool.query('SELECT COUNT(*) as count FROM pacientes WHERE clinica_id = $1', [clinicaId]); - const { rows: agendamentos } = await pool.query('SELECT COUNT(*) as count FROM agendamentos WHERE clinica_id = $1', [clinicaId]); const { rows: leads } = await pool.query('SELECT COUNT(*) as count FROM leads WHERE clinica_id = $1', [clinicaId]); - const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]); - const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]); - // Growth data (last 6 months) - const { rows: growth } = await pool.query(` - SELECT - TO_CHAR(datavencimento, 'YYYY-MM') as mes, - SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita, - SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa - FROM financeiro - WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1 - GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') - ORDER BY mes ASC - `, [clinicaId]); + // Filtro de agenda: dentista → só os próprios agendamentos. + const agFilter = (col, params) => { + let w = `${col === 'a' ? 'a.' : ''}clinica_id = $1`; + if (isDentista) { + if (!dentistaIds.length) return { where: w + ' AND false', params }; + params.push(dentistaIds); + w += ` AND ${col === 'a' ? 'a.' : ''}dentistaid = ANY($${params.length})`; + } + return { where: w, params }; + }; + const agp = agFilter('', [clinicaId]); + const { rows: agendamentos } = await pool.query(`SELECT COUNT(*) as count FROM agendamentos WHERE ${agp.where}`, agp.params); - // Recent appointments with patient and dentist names + // Financeiro só p/ dono/admin/funcionário. + let faturamentoTotal = 0, despesasTotal = 0, growth = []; + if (verFinanceiro) { + const { rows: faturamento } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE (tipo = 'RECEITA' OR tipo IS NULL) AND clinica_id = $1", [clinicaId]); + const { rows: pendente } = await pool.query("SELECT COALESCE(SUM(valor), 0) as total FROM financeiro WHERE tipo = 'DESPESA' AND clinica_id = $1", [clinicaId]); + const { rows: g } = await pool.query(` + SELECT TO_CHAR(datavencimento, 'YYYY-MM') as mes, + SUM(CASE WHEN tipo = 'RECEITA' OR tipo IS NULL THEN valor ELSE 0 END) as receita, + SUM(CASE WHEN tipo = 'DESPESA' THEN valor ELSE 0 END) as despesa + FROM financeiro + WHERE datavencimento >= CURRENT_DATE - INTERVAL '6 months' AND clinica_id = $1 + GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') ORDER BY mes ASC`, [clinicaId]); + faturamentoTotal = parseFloat(faturamento[0].total); + despesasTotal = parseFloat(pendente[0].total); + growth = g; + } + + // Agenda recente com os mesmos limites por papel. + const rec = agFilter('a', [clinicaId]); const { rows: recent } = await pool.query(` - SELECT a.id, a.pacientenome, a.start_time, a.status, - d.nome as "dentistaNome" - FROM agendamentos a - LEFT JOIN dentistas d ON a.dentistaid = d.id - WHERE a.clinica_id = $1 - ORDER BY a.start_time DESC - LIMIT 5 - `, [clinicaId]); + SELECT a.id, a.pacientenome, a.start_time, a.status, d.nome as "dentistaNome" + FROM agendamentos a LEFT JOIN dentistas d ON a.dentistaid = d.id + WHERE ${rec.where} + ORDER BY a.start_time DESC LIMIT 5`, rec.params); const responseData = { stats: { totalPacientes: parseInt(pacientes[0].count), totalAgendamentos: parseInt(agendamentos[0].count), totalLeads: parseInt(leads[0].count), - faturamentoTotal: parseFloat(faturamento[0].total), - despesasTotal: parseFloat(pendente[0].total) + faturamentoTotal, + despesasTotal }, - growth: growth, + growth, recentAgendamentos: recent };