fix(agenda): blindagem multi-tenant — overbooking sem conta, race no PUT, privacidade e índices
Auditoria da agenda (clínicas/setores, profissional multi-local): - anti-overbooking: agrupa registros do mesmo profissional por EMAIL quando usuario_id é NULL (dentista sem conta que atende fixo numa clínica e avulso em outras) — antes só agrupava por usuario_id e deixava passar duplo-agendamento. - PUT (reagendar): checagem de conflito + UPDATE + auditoria agora numa única transação (FOR UPDATE efetivo) — fecha a race de dois reagendamentos no mesmo slot. - privacidade entre tenants: resposta 409 deixa de expor id/clinica_id do compromisso alheio (helper slotOcupadoPublico) — só o horário. - performance: índices (clinica_id,start_time) e (dentistaid,start_time) + janela ?from&to opcional no GET (retrocompatível). - cadastro de dentista: e-mail obrigatório no backend (todo dentista precisa de conta/identidade global; protético interno sem conta usa outro fluxo). Validado em dev.scoreodonto.com (pgdev). Produção intocada. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+66
-19
@@ -1998,6 +1998,14 @@ app.post('/api/dentistas', tenantGuard, requireCapability('dentistas'), async (r
|
|||||||
try {
|
try {
|
||||||
const d = req.body;
|
const d = req.body;
|
||||||
let tempPassword = null, contaCriada = false;
|
let tempPassword = null, contaCriada = false;
|
||||||
|
// E-mail OBRIGATÓRIO: todo dentista precisa de conta para ter agenda e identidade
|
||||||
|
// global — sem ela o anti-overbooking entre clínicas não reconhece a mesma pessoa.
|
||||||
|
// (O protético interno "sem conta" usa outro fluxo e NÃO passa por aqui.)
|
||||||
|
const emailLimpo = (d.email || '').trim();
|
||||||
|
if (!emailLimpo || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(emailLimpo)) {
|
||||||
|
return res.status(400).json({ success: false, message: 'E-MAIL VÁLIDO É OBRIGATÓRIO PARA CADASTRAR UM DENTISTA (ele precisa de conta para ter agenda).' });
|
||||||
|
}
|
||||||
|
d.email = emailLimpo;
|
||||||
// Dentista com e-mail: provisiona a conta (se não existir) e vincula à clínica,
|
// Dentista com e-mail: provisiona a conta (se não existir) e vincula à clínica,
|
||||||
// pra ele ter acesso à agenda. Conta "free" — só vínculo com a clínica.
|
// pra ele ter acesso à agenda. Conta "free" — só vínculo com a clínica.
|
||||||
if (d.email && !d.usuario_id) {
|
if (d.email && !d.usuario_id) {
|
||||||
@@ -2614,7 +2622,16 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const clinicaId = req.clinicaId;
|
const clinicaId = req.clinicaId;
|
||||||
if (!clinicaId) return res.json([]); // sem clínica escopada → nada (isolamento entre unidades)
|
if (!clinicaId) return res.json([]); // sem clínica escopada → nada (isolamento entre unidades)
|
||||||
const { rows } = await pool.query('SELECT * FROM agendamentos WHERE clinica_id = $1', [clinicaId]);
|
// Janela de datas OPCIONAL (?from&to): quando enviada, limita a varredura à janela
|
||||||
|
// visível do calendário em vez de carregar a agenda histórica inteira. Compatível
|
||||||
|
// com clientes antigos (sem from/to → comportamento anterior).
|
||||||
|
const { from, to } = req.query;
|
||||||
|
const params = [clinicaId];
|
||||||
|
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}`; }
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT * FROM agendamentos WHERE clinica_id = $1${janela} ORDER BY start_time`, params);
|
||||||
res.json(rows.map(r => ({
|
res.json(rows.map(r => ({
|
||||||
...r,
|
...r,
|
||||||
dentistaId: r.dentistaid,
|
dentistaId: r.dentistaid,
|
||||||
@@ -2634,11 +2651,19 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => {
|
|||||||
async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
|
async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
|
||||||
if (!dentistaId || !start) return null;
|
if (!dentistaId || !start) return null;
|
||||||
let dentistaIds = [dentistaId];
|
let dentistaIds = [dentistaId];
|
||||||
const { rows: dr } = await db.query('SELECT usuario_id FROM dentistas WHERE id = $1', [dentistaId]);
|
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
|
||||||
const profUid = dr[0]?.usuario_id || null;
|
const profUid = dr[0]?.usuario_id || null;
|
||||||
|
const profEmail = (dr[0]?.email || '').trim().toLowerCase() || null;
|
||||||
if (profUid) {
|
if (profUid) {
|
||||||
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [profUid]);
|
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [profUid]);
|
||||||
if (ids.length) dentistaIds = ids.map(r => r.id);
|
if (ids.length) dentistaIds = ids.map(r => r.id);
|
||||||
|
} else if (profEmail) {
|
||||||
|
// Profissional SEM conta (usuario_id NULL): agrupa os registros da MESMA pessoa
|
||||||
|
// pelo email — a mesma chave usada no backfill de identidade (Fase 2). Garante o
|
||||||
|
// anti-overbooking para o dentista que atende fixo numa clínica e avulso em outras,
|
||||||
|
// mesmo sem login. Sem email não há como vincular → cai no próprio dentistaId.
|
||||||
|
const { rows: ids } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [profEmail]);
|
||||||
|
if (ids.length) dentistaIds = ids.map(r => r.id);
|
||||||
}
|
}
|
||||||
const fim = end || start;
|
const fim = end || start;
|
||||||
const params = [dentistaIds, start, fim];
|
const params = [dentistaIds, start, fim];
|
||||||
@@ -2656,6 +2681,14 @@ async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) {
|
|||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Privacidade entre tenants: NUNCA devolver id/clinica_id do compromisso em conflito
|
||||||
|
// (revelaria a clínica concorrente e o agendamento alheio a quem está agendando aqui).
|
||||||
|
// Só o intervalo de tempo, que é o necessário para "escolha outro horário".
|
||||||
|
function slotOcupadoPublico(conflito) {
|
||||||
|
if (!conflito) return null;
|
||||||
|
return { start: conflito.start_time, end: conflito.end_time };
|
||||||
|
}
|
||||||
|
|
||||||
// POST with concurrency lock: prevents double-booking the same dentist+time slot
|
// POST with concurrency lock: prevents double-booking the same dentist+time slot
|
||||||
app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req, res) => {
|
app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -2727,7 +2760,7 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req
|
|||||||
success: false,
|
success: false,
|
||||||
conflito: true,
|
conflito: true,
|
||||||
podeRegistrarInteresse: true, // gancho p/ Fase 4 (interesse/fila)
|
podeRegistrarInteresse: true, // gancho p/ Fase 4 (interesse/fila)
|
||||||
ocupado: err.conflito, // { id, clinica_id, start_time, end_time }
|
ocupado: slotOcupadoPublico(err.conflito), // só o horário — sem vazar a clínica alheia
|
||||||
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). REGISTRE INTERESSE OU ESCOLHA OUTRO HORÁRIO.'
|
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). REGISTRE INTERESSE OU ESCOLHA OUTRO HORÁRIO.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2763,13 +2796,6 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
|
|||||||
const mudouTempoOuDent = (start && String(start) !== String(before.start_time))
|
const mudouTempoOuDent = (start && String(start) !== String(before.start_time))
|
||||||
|| (end && String(end) !== String(before.end_time))
|
|| (end && String(end) !== String(before.end_time))
|
||||||
|| (rest.dentistaId && rest.dentistaId !== before.dentistaid);
|
|| (rest.dentistaId && rest.dentistaId !== before.dentistaid);
|
||||||
if (mudouTempoOuDent) {
|
|
||||||
const novoDent = rest.dentistaId || before.dentistaid;
|
|
||||||
const conflito = await findOverbookingConflict(pool, novoDent, start || before.start_time, end || before.end_time, req.params.id);
|
|
||||||
if (conflito) {
|
|
||||||
return res.status(409).json({ success: false, conflito: true, podeRegistrarInteresse: true, ocupado: conflito, message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). ESCOLHA OUTRO HORÁRIO.' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (start) updateData.start_time = start;
|
if (start) updateData.start_time = start;
|
||||||
@@ -2785,17 +2811,27 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
|
|||||||
updateData.updated_at = new Date().toISOString();
|
updateData.updated_at = new Date().toISOString();
|
||||||
updateData.version = Number(before.version || 1) + 1;
|
updateData.version = Number(before.version || 1) + 1;
|
||||||
|
|
||||||
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
|
|
||||||
if (updateQuery) await pool.query(updateQuery.text, updateQuery.values);
|
|
||||||
|
|
||||||
// remarcou (mudou horário) vs editou (outros campos)
|
// remarcou (mudou horário) vs editou (outros campos)
|
||||||
const movido = (start && String(start) !== String(before.start_time)) || (end && String(end) !== String(before.end_time));
|
const movido = (start && String(start) !== String(before.start_time)) || (end && String(end) !== String(before.end_time));
|
||||||
await registrarAudit(pool, {
|
|
||||||
clinicaId: before.clinica_id, entidade: 'agendamento', entidadeId: req.params.id,
|
// Tudo numa única transação: a checagem de overbooking (FOR UPDATE) e o UPDATE
|
||||||
acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm,
|
// ficam atômicos — sem isso, dois reagendamentos simultâneos para o mesmo slot
|
||||||
detalhes: movido
|
// passariam os dois pela verificação (o lock só vale dentro da transação).
|
||||||
? { de: { start: before.start_time, end: before.end_time }, para: { start: start || before.start_time, end: end || before.end_time } }
|
await withTransaction(async (client) => {
|
||||||
: { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) }
|
if (mudouTempoOuDent) {
|
||||||
|
const novoDent = rest.dentistaId || before.dentistaid;
|
||||||
|
const conflito = await findOverbookingConflict(client, novoDent, start || before.start_time, end || before.end_time, req.params.id);
|
||||||
|
if (conflito) throw { code: 'CONF_OVERLAP', conflito };
|
||||||
|
}
|
||||||
|
const updateQuery = buildUpdate('agendamentos', updateData, 'id', req.params.id);
|
||||||
|
if (updateQuery) await client.query(updateQuery.text, updateQuery.values);
|
||||||
|
await registrarAudit(client, {
|
||||||
|
clinicaId: before.clinica_id, entidade: 'agendamento', entidadeId: req.params.id,
|
||||||
|
acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm,
|
||||||
|
detalhes: movido
|
||||||
|
? { de: { start: before.start_time, end: before.end_time }, para: { start: start || before.start_time, end: end || before.end_time } }
|
||||||
|
: { campos: Object.keys(updateData).filter(k => !['updated_by', 'updated_by_nome', 'updated_at', 'version'].includes(k)) }
|
||||||
|
});
|
||||||
});
|
});
|
||||||
schedulePush('agendamentos');
|
schedulePush('agendamentos');
|
||||||
wsBroadcast(before.clinica_id, 'agenda', { acao: 'editou', id: req.params.id }); // pós-commit
|
wsBroadcast(before.clinica_id, 'agenda', { acao: 'editou', id: req.params.id }); // pós-commit
|
||||||
@@ -2810,6 +2846,13 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen
|
|||||||
});
|
});
|
||||||
res.json({ success: true, version: updateData.version });
|
res.json({ success: true, version: updateData.version });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.code === 'CONF_OVERLAP') {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false, conflito: true, podeRegistrarInteresse: true,
|
||||||
|
ocupado: slotOcupadoPublico(err.conflito), // só o horário — sem vazar a clínica alheia
|
||||||
|
message: 'O PROFISSIONAL JÁ TEM COMPROMISSO NESSE HORÁRIO (EM QUALQUER CLÍNICA). ESCOLHA OUTRO HORÁRIO.'
|
||||||
|
});
|
||||||
|
}
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -6238,6 +6281,10 @@ async function runMigrations() {
|
|||||||
// Slot único só vale para status ATIVOS — assim cancelar/faltar libera o slot p/ reagendar.
|
// Slot único só vale para status ATIVOS — assim cancelar/faltar libera o slot p/ reagendar.
|
||||||
`ALTER TABLE agendamentos DROP CONSTRAINT IF EXISTS unique_dentista_slot`,
|
`ALTER TABLE agendamentos DROP CONSTRAINT IF EXISTS unique_dentista_slot`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS unique_dentista_slot_active ON agendamentos (dentistaid, start_time) WHERE lower(status) NOT IN ('cancelado','falta','remarcar')`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS unique_dentista_slot_active ON agendamentos (dentistaid, start_time) WHERE lower(status) NOT IN ('cancelado','falta','remarcar')`,
|
||||||
|
// Leitura da agenda por clínica + janela de datas (GET /api/agendamentos?from&to).
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_agendamentos_clinica_start ON agendamentos (clinica_id, start_time)`,
|
||||||
|
// Anti-overbooking entre clínicas p/ dentista sem conta: busca por dentistaid + janela.
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_agendamentos_dentista_start ON agendamentos (dentistaid, start_time)`,
|
||||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacienteid TEXT`,
|
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacienteid TEXT`,
|
||||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacientecelular TEXT`,
|
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS pacientecelular TEXT`,
|
||||||
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS created_by TEXT`,
|
`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS created_by TEXT`,
|
||||||
|
|||||||
Reference in New Issue
Block a user