fix(dashboard): escopo por papel — dentista não vê agenda da clínica nem financeiro
/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:<clinica>) 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 <noreply@anthropic.com>
This commit is contained in:
+52
-22
@@ -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]);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Growth data (last 6 months)
|
||||
const { rows: growth } = await pool.query(`
|
||||
SELECT
|
||||
TO_CHAR(datavencimento, 'YYYY-MM') as mes,
|
||||
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]);
|
||||
GROUP BY TO_CHAR(datavencimento, 'YYYY-MM') ORDER BY mes ASC`, [clinicaId]);
|
||||
faturamentoTotal = parseFloat(faturamento[0].total);
|
||||
despesasTotal = parseFloat(pendente[0].total);
|
||||
growth = g;
|
||||
}
|
||||
|
||||
// Recent appointments with patient and dentist names
|
||||
// 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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user