diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 3ffa389..04af2f0 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -690,5 +690,135 @@ export function createAgendaBridge(pool) { } finally { client.release(); } }); + // ── Helper: confirma que o agendamento pertence ao telefone (segurança) ────── + // Impede a IA de cancelar/remarcar a consulta de terceiros só pelo id. + async function agendamentoDoTelefone(db, clinicaId, agendamentoId, phone) { + const tel = soDigitos(phone).slice(-8); + const { rows } = await db.query( + `SELECT a.id, a.dentistaid, a.pacientenome, a.procedimento, a.status, a.sala_id, + to_char(a.start_time,'YYYY-MM-DD') AS data, to_char(a.start_time,'HH24:MI') AS hora, + regexp_replace(coalesce(a.pacientecelular,''),'\\D','','g') AS ag_tel, + regexp_replace(coalesce(p.telefone,''),'\\D','','g') AS pac_tel, + d.nome AS dentista + FROM agendamentos a + LEFT JOIN pacientes p ON p.id = a.pacienteid + LEFT JOIN dentistas d ON d.id = a.dentistaid + WHERE a.id = $1 AND a.clinica_id = $2 LIMIT 1`, + [agendamentoId, clinicaId]); + const a = rows[0]; + if (!a) return { ok: false, motivo: 'nao_encontrado' }; + if (tel && !(String(a.ag_tel).endsWith(tel) || String(a.pac_tel).endsWith(tel))) { + return { ok: false, motivo: 'nao_pertence' }; + } + return { ok: true, ag: a }; + } + const statusInativo = (s) => ['cancelado', 'falta', 'faltou'].includes(String(s || '').toLowerCase()); + + // ── GET /consultas ──────────────────────────────────────────────────────────── + // Consultas do telefone/família. Futuras por padrão; incluir_passadas=true traz o histórico. + router.get('/consultas', async (req, res) => { + const clinicaId = String(req.query.clinica_id || ''); + const tel = soDigitos(req.query.phone).slice(-8); + if (!clinicaId || !tel) return res.status(400).json({ error: 'clinica_id e phone obrigatórios' }); + const incluirPassadas = String(req.query.incluir_passadas || '') === 'true'; + try { + const { rows } = await pool.query( + `SELECT a.id, to_char(a.start_time,'YYYY-MM-DD') AS data, to_char(a.start_time,'HH24:MI') AS hora, + to_char(a.end_time,'HH24:MI') AS hora_fim, a.status, a.procedimento, a.pacientenome, + d.nome AS dentista + FROM agendamentos a + LEFT JOIN dentistas d ON d.id = a.dentistaid + LEFT JOIN pacientes p ON p.id = a.pacienteid + WHERE a.clinica_id = $1 + AND ( regexp_replace(coalesce(a.pacientecelular,''),'\\D','','g') LIKE '%'||$2 + OR regexp_replace(coalesce(p.telefone,''),'\\D','','g') LIKE '%'||$2 ) + AND lower(coalesce(a.status,'agendado')) NOT IN ('cancelado','falta','faltou') + ${incluirPassadas ? '' : "AND a.start_time >= NOW() - interval '2 hours'"} + ORDER BY a.start_time ASC LIMIT 20`, + [clinicaId, tel]); + res.json({ + total: rows.length, + consultas: rows.map((r) => ({ + agendamento_id: r.id, data: r.data, hora: r.hora, hora_fim: r.hora_fim, + dentista: r.dentista || null, procedimento: r.procedimento || 'Consulta', + status: r.status || 'agendado', paciente: r.pacientenome || null, + })), + }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + // ── POST /confirmar ─────────────────────────────────────────────────────────── + // Paciente confirma presença numa consulta futura. Body: clinica_id, agendamento_id, phone. + router.post('/confirmar', async (req, res) => { + const b = req.body || {}; + if (!b.clinica_id || !b.agendamento_id) return res.status(400).json({ error: 'clinica_id e agendamento_id obrigatórios' }); + try { + const chk = await agendamentoDoTelefone(pool, b.clinica_id, b.agendamento_id, b.phone || ''); + if (!chk.ok) return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' }); + if (statusInativo(chk.ag.status)) return res.status(409).json({ error: 'Consulta não está ativa.' }); + await pool.query( + `UPDATE agendamentos SET status='confirmado', updated_by_nome='Secretária IA', updated_at=NOW(), version=COALESCE(version,1)+1 WHERE id=$1`, + [b.agendamento_id]); + res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'confirmado', data: chk.ag.data, hora: chk.ag.hora, dentista: chk.ag.dentista || null }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + // ── POST /cancelar ──────────────────────────────────────────────────────────── + // Body: clinica_id, agendamento_id, phone, motivo? + router.post('/cancelar', async (req, res) => { + const b = req.body || {}; + if (!b.clinica_id || !b.agendamento_id) return res.status(400).json({ error: 'clinica_id e agendamento_id obrigatórios' }); + try { + const chk = await agendamentoDoTelefone(pool, b.clinica_id, b.agendamento_id, b.phone || ''); + if (!chk.ok) return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' }); + if (statusInativo(chk.ag.status)) return res.status(409).json({ error: 'Consulta já estava cancelada/inativa.' }); + const motivo = b.motivo ? String(b.motivo).slice(0, 300) : null; + await pool.query( + `UPDATE agendamentos + SET status='cancelado', canceled_by_nome='Secretária IA', canceled_at=NOW(), + version=COALESCE(version,1)+1, + observacoes = CASE WHEN $2::text IS NULL THEN observacoes + ELSE trim(both ' ' from coalesce(observacoes,'') || ' | Cancelado via WhatsApp: ' || $2) END + WHERE id=$1`, + [b.agendamento_id, motivo]); + res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'cancelado', data: chk.ag.data, hora: chk.ag.hora, dentista: chk.ag.dentista || null }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + // ── POST /remarcar ──────────────────────────────────────────────────────────── + // Move a consulta para um novo horário livre. Body: clinica_id, agendamento_id, phone, + // dentista_id, start (ISO), end?, sala_id?. Anti-overbooking (exclui a própria consulta). + router.post('/remarcar', async (req, res) => { + const b = req.body || {}; + if (!b.clinica_id || !b.agendamento_id || !b.dentista_id || !b.start) { + return res.status(400).json({ error: 'clinica_id, agendamento_id, dentista_id e start obrigatórios' }); + } + const end = b.end || new Date(new Date(b.start).getTime() + SLOT_MIN * 60000).toISOString(); + const client = await pool.connect(); + try { + const chk = await agendamentoDoTelefone(client, b.clinica_id, b.agendamento_id, b.phone || ''); + if (!chk.ok) { client.release(); return res.status(chk.motivo === 'nao_encontrado' ? 404 : 403).json({ error: chk.motivo === 'nao_encontrado' ? 'Consulta não encontrada.' : 'Esta consulta não é deste número.' }); } + if (statusInativo(chk.ag.status)) { client.release(); return res.status(409).json({ error: 'Consulta não está ativa para remarcar.' }); } + await client.query('BEGIN'); + const conflict = await findConflict(client, b.dentista_id, b.start, end, b.agendamento_id); + if (conflict) { await client.query('ROLLBACK'); client.release(); return res.status(409).json({ error: 'Novo horário já ocupado.', conflict_id: conflict.id }); } + const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id=$1 AND clinica_id=$2', [b.dentista_id, b.clinica_id]); + const setorId = dd[0]?.setor_id ?? null; + const { rows: upd } = await client.query( + `UPDATE agendamentos + SET dentistaid=$2, start_time=$3, end_time=$4, status='agendado', setor_id=COALESCE($5, setor_id), + sala_id=COALESCE($6, sala_id), updated_by_nome='Secretária IA', updated_at=NOW(), + version=COALESCE(version,1)+1 + WHERE id=$1 RETURNING id, to_char(start_time,'YYYY-MM-DD') AS data, to_char(start_time,'HH24:MI') AS hora`, + [b.agendamento_id, b.dentista_id, b.start, end, setorId, b.sala_id || null]); + await client.query('COMMIT'); + const { rows: dn } = await pool.query('SELECT nome FROM dentistas WHERE id=$1', [b.dentista_id]); + res.json({ ok: true, agendamento_id: b.agendamento_id, status: 'agendado', data: upd[0]?.data, hora: upd[0]?.hora, dentista: dn[0]?.nome || null }); + } catch (e) { + try { await client.query('ROLLBACK'); } catch { /* ignore */ } + res.status(500).json({ error: e.message }); + } finally { try { client.release(); } catch { /* já liberado */ } } + }); + return router; }