feat(agenda-bridge): ciclo de vida da consulta p/ a Secretária IA
Novos endpoints server-to-server (auth x-nw-agenda-secret), presos ao clinica_id e ao telefone do contato (posse verificada — impede mexer na consulta de terceiros): - GET /consultas — consultas do telefone/família (futuras por padrão; incluir_passadas) - POST /confirmar — marca presença (status=confirmado) - POST /cancelar — cancela e libera o horário (status=cancelado + motivo em observacoes) - POST /remarcar — move para novo slot com anti-overbooking (findConflict exclui a própria) Verificado E2E: GET /consultas -> 200 com dentista/procedimento/status. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user