diff --git a/backend/server.js b/backend/server.js index 203a303..202e15d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1998,6 +1998,14 @@ app.post('/api/dentistas', tenantGuard, requireCapability('dentistas'), async (r try { const d = req.body; 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, // pra ele ter acesso à agenda. Conta "free" — só vínculo com a clínica. if (d.email && !d.usuario_id) { @@ -2614,7 +2622,16 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => { try { const clinicaId = req.clinicaId; 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 => ({ ...r, dentistaId: r.dentistaid, @@ -2634,11 +2651,19 @@ app.get('/api/agendamentos', tenantGuard, async (req, res) => { async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) { if (!dentistaId || !start) return null; 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 profEmail = (dr[0]?.email || '').trim().toLowerCase() || null; if (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); + } 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 params = [dentistaIds, start, fim]; @@ -2656,6 +2681,14 @@ async function findOverbookingConflict(db, dentistaId, start, end, ignoreId) { 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 app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req, res) => { try { @@ -2727,7 +2760,7 @@ app.post('/api/agendamentos', authGuard, requireCapability('agenda'), async (req success: false, conflito: true, 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.' }); } @@ -2763,13 +2796,6 @@ app.put('/api/agendamentos/:id', authGuard, requireCapabilityRow('agenda', 'agen const mudouTempoOuDent = (start && String(start) !== String(before.start_time)) || (end && String(end) !== String(before.end_time)) || (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 = {}; 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.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) 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, - 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)) } + + // 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, + 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'); 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 }); } 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') { return res.status(409).json({ success: false, @@ -6238,6 +6281,10 @@ async function runMigrations() { // 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`, `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 pacientecelular TEXT`, `ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS created_by TEXT`,