fix(agenda): blindagem multi-tenant — overbooking sem conta, race no PUT, privacidade e índices
build-and-promote / build (push) Successful in 1m2s
build-and-promote / promote (push) Has been skipped

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:
VPS 4 Builder
2026-06-25 16:45:01 +02:00
parent d83e0c82b2
commit facf7b48c2
+61 -14
View File
@@ -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,18 +2811,28 @@ 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, {
// Tudo numa única transação: a checagem de overbooking (FOR UPDATE) e o UPDATE
// ficam atômicos — sem isso, dois reagendamentos simultâneos para o mesmo slot
// passariam os dois pela verificação (o lock só vale dentro da transação).
await withTransaction(async (client) => {
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, clinicaId: before.clinica_id, entidade: 'agendamento', entidadeId: req.params.id,
acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm, acao: movido ? 'reagendou' : 'editou', actorId: actor, actorNome: actorNm,
detalhes: movido detalhes: movido
? { de: { start: before.start_time, end: before.end_time }, para: { start: start || before.start_time, end: end || before.end_time } } ? { 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)) } : { 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
// Sincroniza a alteração no Google (patch se já existe evento, senão cria). // Sincroniza a alteração no Google (patch se já existe evento, senão cria).
@@ -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`,