26f39a60f3
- GET /encaixe/analisar: ocupação do horário desejado (quem está nele) + horário livre mais cedo (flex-inclusivo) p/ oferecer/encaixar. - POST /encaixe/mover: remarca um agendamento (o A aceitou adiantar), com checagem de conflito no destino (findConflict ganha excludeId p/ ignorar o próprio). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
695 lines
40 KiB
JavaScript
695 lines
40 KiB
JavaScript
// Ponte de agenda: expõe /api/nw/agenda/* para o MOTOR (tools da Secretária)
|
||
// operarem na agenda REAL do scoreodonto — em vez do calendário isolado do motor.
|
||
//
|
||
// Segurança: chamada server-to-server autenticada por segredo compartilhado
|
||
// (header x-nw-agenda-secret === webhookSecret configurado). Todo acesso é
|
||
// preso ao clinica_id enviado (o motor conhece a clínica da instância).
|
||
//
|
||
// Endpoints:
|
||
// GET /slots — horários livres (jornada dos dentistas − agendamentos)
|
||
// POST /book — cria agendamento real (anti-overbooking + vínculo de paciente)
|
||
import express from 'express';
|
||
import { getConfig } from './config.js';
|
||
import { cautelasParaIA } from './checklist-defs.js';
|
||
import { resolverFeriado, feriadoNacionalNome } from './feriados.js';
|
||
|
||
const SLOT_MIN = 60; // duração/espaçamento padrão do slot (1h entre atendimentos)
|
||
const HORIZON_DAYS = 7; // janela padrão de busca
|
||
const MAX_SLOTS = 20; // quantos slots RETORNAR (após ordenar por horário)
|
||
const CANDIDATE_CAP = 240; // teto de GERAÇÃO (clínica + salas competem por horário)
|
||
// Fallback quando a CLÍNICA não configurou horário de funcionamento: comercial
|
||
// padrão (seg–sex 08–18). Uma vez configurado (clinicas_horarios), manda ele.
|
||
const DEFAULT_INI = '08:00';
|
||
const DEFAULT_FIM = '18:00';
|
||
const DIAS_NOME = ['domingo', 'segunda-feira', 'terça-feira', 'quarta-feira', 'quinta-feira', 'sexta-feira', 'sábado'];
|
||
|
||
const pad = (n) => String(n).padStart(2, '0');
|
||
const ymd = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
const fmt = (d) => `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
|
||
const hm = (t) => String(t ?? '').slice(0, 5); // 'HH:MM:SS'|Date → 'HH:MM'
|
||
|
||
// Interseção de duas listas de janelas [ini,fim] (strings 'HH:MM'). Retorna as
|
||
// sobreposições (ex.: clínica 08–18 ∩ dentista 13–20 = 13–18).
|
||
function interseccao(a, b) {
|
||
const out = [];
|
||
for (const [ai, af] of a) for (const [bi, bf] of b) {
|
||
const ini = ai > bi ? ai : bi;
|
||
const fim = af < bf ? af : bf;
|
||
if (ini < fim) out.push([ini, fim]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Carrega o horário de FUNCIONAMENTO da clínica → { byDow: Map<dow,[[ini,fim]]>, hasConfig }.
|
||
async function carregarHorariosClinica(pool, clinicaId) {
|
||
const byDow = new Map();
|
||
let hasConfig = false;
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT dia_semana, hora_inicio, hora_fim FROM clinicas_horarios WHERE clinica_id = $1 AND ativo = 1`,
|
||
[clinicaId]);
|
||
hasConfig = rows.length > 0;
|
||
for (const r of rows) {
|
||
const arr = byDow.get(r.dia_semana) || [];
|
||
arr.push([hm(r.hora_inicio), hm(r.hora_fim)]);
|
||
byDow.set(r.dia_semana, arr);
|
||
}
|
||
} catch { /* tabela pode não existir ainda → cai no fallback */ }
|
||
return { byDow, hasConfig };
|
||
}
|
||
|
||
// Carrega as folgas (por data) dos dentistas da clínica → Map<dentista_id,[{ini,fim}]>.
|
||
async function carregarFolgas(pool, clinicaId) {
|
||
const byDent = new Map();
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT f.dentista_id, to_char(f.data_inicio,'YYYY-MM-DD') AS ini, to_char(f.data_fim,'YYYY-MM-DD') AS fim
|
||
FROM dentistas_folgas f JOIN dentistas d ON d.id = f.dentista_id
|
||
WHERE d.clinica_id = $1`, [clinicaId]);
|
||
for (const r of rows) {
|
||
const arr = byDent.get(r.dentista_id) || [];
|
||
arr.push([r.ini, r.fim]);
|
||
byDent.set(r.dentista_id, arr);
|
||
}
|
||
} catch { /* tabela pode não existir ainda */ }
|
||
return byDent;
|
||
}
|
||
const emFolga = (folgas, dateStr) => (folgas || []).some(([i, f]) => dateStr >= i && dateStr <= f);
|
||
|
||
// Janelas de funcionamento da CLÍNICA num dia específico, já considerando feriado.
|
||
// Retorna { janelas: [[ini,fim]], fechado: bool, motivo: string|null }.
|
||
async function janelasClinicaNoDia(pool, clinicaId, dateStr, dow, clinicHours) {
|
||
const fer = await resolverFeriado(pool, clinicaId, dateStr);
|
||
if (fer && fer.fecha) return { janelas: [], fechado: true, motivo: `Feriado: ${fer.nome}` };
|
||
// Feriado com horário especial (fecha=0 + horas) sobrepõe o horário normal.
|
||
if (fer && !fer.fecha && fer.hora_inicio && fer.hora_fim) {
|
||
return { janelas: [[hm(fer.hora_inicio), hm(fer.hora_fim)]], fechado: false, motivo: `Horário especial (${fer.nome})` };
|
||
}
|
||
// Horário normal do dia da semana.
|
||
let janelas;
|
||
if (clinicHours.hasConfig) {
|
||
janelas = (clinicHours.byDow.get(dow) || []).map(([i, f]) => [i, f]);
|
||
} else {
|
||
janelas = (dow >= 1 && dow <= 5) ? [[DEFAULT_INI, DEFAULT_FIM]] : [];
|
||
}
|
||
if (!janelas.length) return { janelas: [], fechado: true, motivo: DIAS_NOME[dow] === 'domingo' || DIAS_NOME[dow] === 'sábado' ? `Fechado (${DIAS_NOME[dow]})` : 'Fechado neste dia' };
|
||
return { janelas, fechado: false, motivo: null };
|
||
}
|
||
|
||
// Overbooking: MESMA regra do server.js (agrupa o dentista por usuario_id/email,
|
||
// para o mesmo profissional atuando em clínicas diferentes). Cópia da lógica —
|
||
// se a regra do scoreodonto mudar, revisar aqui também.
|
||
async function findConflict(db, dentistaId, start, end, excludeId = null) {
|
||
if (!dentistaId || !start) return null;
|
||
const { rows: dr } = await db.query('SELECT usuario_id, email FROM dentistas WHERE id = $1', [dentistaId]);
|
||
let ids = [dentistaId];
|
||
const uid = dr[0]?.usuario_id || null;
|
||
const email = (dr[0]?.email || '').trim().toLowerCase() || null;
|
||
if (uid) {
|
||
const { rows } = await db.query('SELECT id FROM dentistas WHERE usuario_id = $1', [uid]);
|
||
if (rows.length) ids = rows.map((r) => r.id);
|
||
} else if (email) {
|
||
const { rows } = await db.query('SELECT id FROM dentistas WHERE lower(trim(email)) = $1', [email]);
|
||
if (rows.length) ids = rows.map((r) => r.id);
|
||
}
|
||
const { rows } = await db.query(
|
||
`SELECT id FROM agendamentos
|
||
WHERE dentistaid = ANY($1)
|
||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))
|
||
AND start_time < $3 AND COALESCE(end_time, start_time) > $2
|
||
AND ($4::text IS NULL OR id <> $4)
|
||
LIMIT 1`,
|
||
[ids, start, end || start, excludeId]);
|
||
return rows[0] || null;
|
||
}
|
||
|
||
export function createAgendaBridge(pool) {
|
||
const router = express.Router();
|
||
|
||
// Auth server-to-server por segredo compartilhado.
|
||
router.use((req, res, next) => {
|
||
const secret = getConfig().webhookSecret;
|
||
if (!secret || req.headers['x-nw-agenda-secret'] !== secret) {
|
||
return res.status(401).json({ error: 'Segredo da ponte inválido' });
|
||
}
|
||
next();
|
||
});
|
||
|
||
// ── GET /slots ──────────────────────────────────────────────────────────────
|
||
// Query: clinica_id (obrig.), dentista_id?, date? (YYYY-MM-DD), days?, duration?
|
||
router.get('/slots', async (req, res) => {
|
||
const clinicaId = String(req.query.clinica_id || '');
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
|
||
const duration = Math.max(10, parseInt(req.query.duration, 10) || SLOT_MIN);
|
||
const days = Math.min(30, Math.max(1, parseInt(req.query.days, 10) || HORIZON_DAYS));
|
||
const from = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date();
|
||
const periodo = req.query.periodo ? String(req.query.periodo).toLowerCase() : null; // manha|tarde
|
||
|
||
try {
|
||
// Dentistas no escopo da clínica.
|
||
const dParams = [clinicaId];
|
||
let dWhere = 'clinica_id = $1';
|
||
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
|
||
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
|
||
if (!dentistas.length) return res.json({ slots: [], motivo: 'Nenhum dentista cadastrado na clínica.' });
|
||
|
||
// Jornada dos DENTISTAS (dentistas_horarios) — opcional, por dentista.
|
||
const { rows: horarios } = await pool.query(
|
||
`SELECT dentista_id, dia_semana, hora_inicio, hora_fim
|
||
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1`, [clinicaId]);
|
||
const hoursBy = new Map();
|
||
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push(h); hoursBy.set(h.dentista_id, a); }
|
||
|
||
// Horário de FUNCIONAMENTO da clínica (config do dono) — a "casca" externa.
|
||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||
// Folgas por data dos dentistas (férias/indisponibilidade).
|
||
const folgasBy = await carregarFolgas(pool, clinicaId);
|
||
|
||
const start0 = new Date(from); start0.setHours(0, 0, 0, 0);
|
||
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
|
||
const { rows: busy } = await pool.query(
|
||
`SELECT dentistaid, start_time, end_time FROM agendamentos
|
||
WHERE clinica_id = $1 AND start_time >= $2 AND start_time < $3
|
||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
|
||
[clinicaId, start0.toISOString(), end0.toISOString()]);
|
||
const busyBy = new Map();
|
||
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
|
||
|
||
const now = new Date();
|
||
const slots = [];
|
||
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
|
||
const day = new Date(start0); day.setDate(day.getDate() + i);
|
||
const dow = day.getDay();
|
||
// Janelas da CLÍNICA no dia (já considera feriado). Fechado → pula o dia.
|
||
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, ymd(day), dow, clinicHours);
|
||
if (fechado || !clinicWins.length) continue;
|
||
for (const dent of dentistas) {
|
||
if (slots.length >= CANDIDATE_CAP) break;
|
||
if (emFolga(folgasBy.get(dent.id), ymd(day))) continue; // dentista de folga nesse dia
|
||
const cfg = hoursBy.get(dent.id);
|
||
// Dentista: se configurou jornada, usa a do dia; senão, atende sempre
|
||
// que a clínica está aberta. Efetivo = CLÍNICA ∩ DENTISTA.
|
||
const dentWins = (cfg && cfg.length)
|
||
? cfg.filter((h) => h.dia_semana === dow).map((h) => [hm(h.hora_inicio), hm(h.hora_fim)])
|
||
: clinicWins;
|
||
const windows = interseccao(clinicWins, dentWins);
|
||
const bs = busyBy.get(dent.id) || [];
|
||
for (const [ini, fim] of windows) {
|
||
const [hi, mi] = String(ini).split(':').map(Number);
|
||
const [hf, mf] = String(fim).split(':').map(Number);
|
||
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
|
||
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
|
||
while (t.getTime() + duration * 60000 <= limit.getTime() && slots.length < CANDIDATE_CAP) {
|
||
const s = new Date(t);
|
||
const e = new Date(t.getTime() + duration * 60000);
|
||
t = e;
|
||
if (s <= now) continue;
|
||
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
|
||
slots.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e) });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── B (salas): slots do dentista num QUARTO alugado (sala_reservas aprovada).
|
||
// Disponibilidade = janela da reserva [inicio,fim]; casa o dentista da clínica
|
||
// pelo usuario_id. Respeita folgas e agendamentos existentes (por dentista).
|
||
if (slots.length < CANDIDATE_CAP) {
|
||
try {
|
||
const { rows: reservas } = await pool.query(
|
||
`SELECT r.sala_id, r.inicio, r.fim, s.nome AS sala_nome, d.id AS dentista_id, d.nome AS dentista_nome
|
||
FROM sala_reservas r
|
||
JOIN salas s ON s.id = r.sala_id
|
||
JOIN dentistas d ON d.usuario_id = r.profissional_usuario_id
|
||
WHERE d.clinica_id = $1
|
||
AND lower(coalesce(r.status,'')) NOT IN ('cancelada','cancelado','recusada','recusado','rejeitada','pendente','negada')
|
||
AND r.fim > $2 AND r.inicio < $3
|
||
ORDER BY r.inicio`,
|
||
[clinicaId, start0.toISOString(), end0.toISOString()]);
|
||
const step = duration * 60000;
|
||
for (const rv of reservas) {
|
||
if (slots.length >= CANDIDATE_CAP) break;
|
||
const bs = busyBy.get(rv.dentista_id) || [];
|
||
const rIni = new Date(rv.inicio), rFim = new Date(rv.fim);
|
||
let t = new Date(Math.max(rIni.getTime(), start0.getTime(), now.getTime()));
|
||
const off = (t.getTime() - rIni.getTime()) % step;
|
||
if (off !== 0) t = new Date(t.getTime() + (step - off)); // alinha ao grid da reserva
|
||
while (t.getTime() + step <= rFim.getTime() && slots.length < CANDIDATE_CAP) {
|
||
const s = new Date(t), e = new Date(t.getTime() + step); t = e;
|
||
if (s <= now) continue;
|
||
if (emFolga(folgasBy.get(rv.dentista_id), ymd(s))) continue;
|
||
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
|
||
slots.push({ dentista_id: rv.dentista_id, dentista_nome: rv.dentista_nome, start: fmt(s), end: fmt(e), sala_id: rv.sala_id, local: rv.sala_nome });
|
||
}
|
||
}
|
||
} catch { /* sala_reservas pode não existir */ }
|
||
}
|
||
|
||
let out = slots.sort((a, b) => a.start.localeCompare(b.start));
|
||
// Filtro manhã/tarde (a Secretária pergunta o período antes de oferecer).
|
||
if (periodo === 'manha' || periodo === 'manhã') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) < 12);
|
||
else if (periodo === 'tarde') out = out.filter((s) => parseInt(s.start.slice(11, 13), 10) >= 12);
|
||
res.json({ slots: out.slice(0, MAX_SLOTS) });
|
||
} catch (e) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── GET /status ──────────────────────────────────────────────────────────────
|
||
// Situação de funcionamento da clínica p/ a Secretária SABER quando responder
|
||
// "estamos fechados". Query: clinica_id (obrig.), date? (YYYY-MM-DD, def: hoje BR).
|
||
// Retorna: hoje (aberto/motivo/horários), próximo dia aberto, grade da semana e
|
||
// próximos feriados.
|
||
router.get('/status', async (req, res) => {
|
||
const clinicaId = String(req.query.clinica_id || '');
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||
try {
|
||
// "Hoje" no fuso de Brasília (o servidor pode rodar em UTC).
|
||
const hojeBR = req.query.date
|
||
? String(req.query.date)
|
||
: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Sao_Paulo' }); // YYYY-MM-DD
|
||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||
const dateAt = (str) => new Date(`${str}T12:00:00`); // meio-dia: evita borda de fuso
|
||
const addYmd = (str, k) => { const d = dateAt(str); d.setDate(d.getDate() + k); return ymd(d); };
|
||
const statusDoDia = async (str) => {
|
||
const dow = dateAt(str).getDay();
|
||
const r = await janelasClinicaNoDia(pool, clinicaId, str, dow, clinicHours);
|
||
return {
|
||
data: str, dia_semana: dow, dia_nome: DIAS_NOME[dow],
|
||
aberto: !r.fechado && r.janelas.length > 0, motivo: r.motivo,
|
||
horarios: r.janelas.map(([i, f]) => ({ inicio: i, fim: f })),
|
||
};
|
||
};
|
||
|
||
// Grade dos próximos 7 dias (inclui hoje).
|
||
const semana = [];
|
||
for (let k = 0; k < 7; k++) semana.push(await statusDoDia(addYmd(hojeBR, k)));
|
||
const hoje = semana[0];
|
||
// Próximo dia aberto: primeiro aberto na semana; se nenhum, varre +14.
|
||
let proximo = semana.find((s) => s.aberto) || null;
|
||
if (!proximo) for (let k = 7; k <= 21; k++) { const s = await statusDoDia(addYmd(hojeBR, k)); if (s.aberto) { proximo = s; break; } }
|
||
|
||
// Próximos feriados (45 dias) — clínica (1 query) + nacionais (em memória).
|
||
const feriados = [];
|
||
let excMap = new Map();
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT data, nome, fecha, recorrente FROM feriados WHERE clinica_id = $1`, [clinicaId]);
|
||
for (const r of rows) {
|
||
const ds = (r.data instanceof Date) ? ymd(r.data) : String(r.data).slice(0, 10);
|
||
excMap.set(ds, r);
|
||
if (r.recorrente === 1) excMap.set('R' + ds.slice(5), r);
|
||
}
|
||
} catch { /* tabela pode não existir */ }
|
||
for (let k = 0; k < 45; k++) {
|
||
const str = addYmd(hojeBR, k);
|
||
const exc = excMap.get(str) || excMap.get('R' + str.slice(5));
|
||
if (exc) { if (exc.fecha !== 0) feriados.push({ data: str, nome: exc.nome, origem: 'clinica' }); continue; }
|
||
const nac = feriadoNacionalNome(str);
|
||
if (nac) feriados.push({ data: str, nome: nac, origem: 'nacional' });
|
||
}
|
||
|
||
res.json({ hoje, proximo_aberto: proximo, semana, feriados_proximos: feriados.slice(0, 8) });
|
||
} catch (e) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// ── POST /pedido ──────────────────────────────────────────────────────────────
|
||
// A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza:
|
||
// mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses.
|
||
// Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?,
|
||
// nome_cliente, telefone?, procedimento? }
|
||
router.post('/pedido', async (req, res) => {
|
||
const b = req.body || {};
|
||
if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' });
|
||
try {
|
||
// Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido.
|
||
const fer = await resolverFeriado(pool, b.clinica_id, b.data);
|
||
if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` });
|
||
// Resolve o dentista pelo nome (opcional).
|
||
let dentistaId = null;
|
||
if (b.dentista_nome) {
|
||
const { rows } = await pool.query(
|
||
`SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`,
|
||
[b.clinica_id, b.dentista_nome]);
|
||
dentistaId = rows[0]?.id || null;
|
||
}
|
||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(
|
||
`INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`,
|
||
[id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null,
|
||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]);
|
||
res.json({ ok: true, pedido_id: id });
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// ── POST /duvida ──────────────────────────────────────────────────────────────
|
||
// Encaminha uma DÚVIDA/caso incerto à secretária humana (sem agendar). Cria um
|
||
// agenda_pedidos tipo='duvida' com o resumo; entra na mesma fila de pendentes.
|
||
router.post('/duvida', async (req, res) => {
|
||
const b = req.body || {};
|
||
if (!b.clinica_id || !b.resumo) return res.status(400).json({ error: 'clinica_id e resumo obrigatórios' });
|
||
try {
|
||
const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await pool.query(
|
||
`INSERT INTO agenda_pedidos (id, clinica_id, tipo, resumo, paciente_nome, paciente_telefone, status)
|
||
VALUES ($1,$2,'duvida',$3,$4,$5,'pendente')`,
|
||
[id, b.clinica_id, String(b.resumo).slice(0, 2000),
|
||
b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null]);
|
||
res.json({ ok: true, pedido_id: id });
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// ── GET /contexto ─────────────────────────────────────────────────────────────
|
||
// Contexto clínico p/ a Secretária: dentistas + especialidades + "situações"
|
||
// (regras da clínica). Query: clinica_id.
|
||
router.get('/contexto', async (req, res) => {
|
||
const clinicaId = String(req.query.clinica_id || '');
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||
try {
|
||
const { rows: dentistas } = await pool.query(
|
||
`SELECT nome, especialidade, especialidades FROM dentistas WHERE clinica_id = $1 AND ativo IS DISTINCT FROM 0 ORDER BY nome`, [clinicaId]);
|
||
let situacoes = [];
|
||
try {
|
||
const { rows } = await pool.query(`SELECT texto FROM agenda_situacoes WHERE clinica_id = $1 AND ativo = 1 ORDER BY ordem, created_at`, [clinicaId]);
|
||
situacoes = rows.map((r) => r.texto);
|
||
} catch { /* tabela pode não existir ainda */ }
|
||
// Cautelas do checklist do dono (declaração × dados reais) → a IA não chuta.
|
||
let cautelas = [];
|
||
try {
|
||
const respostas = {};
|
||
const { rows } = await pool.query(`SELECT chave, resposta FROM agenda_checklist WHERE clinica_id = $1`, [clinicaId]);
|
||
for (const r of rows) respostas[r.chave] = r.resposta == null ? null : Number(r.resposta);
|
||
const nPac = await pool.query(`SELECT COUNT(*) n FROM pacientes WHERE clinica_id = $1`, [clinicaId]).then((x) => Number(x.rows[0]?.n || 0)).catch(() => 0);
|
||
cautelas = cautelasParaIA(respostas, { dentistas: dentistas.length, pacientes: nPac });
|
||
} catch { /* checklist novo/indisponível */ }
|
||
res.json({
|
||
dentistas: dentistas.map((d) => ({
|
||
nome: d.nome,
|
||
especialidades: Array.isArray(d.especialidades) ? d.especialidades : (d.especialidade ? [d.especialidade] : []),
|
||
})),
|
||
situacoes,
|
||
cautelas,
|
||
});
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// ── GET /dentista-contato ─────────────────────────────────────────────────────
|
||
// Resolve o WhatsApp de um dentista da clínica (Parte A: a IA aciona o Dr).
|
||
// Query: clinica_id (obrig.), nome? OU dentista_id?
|
||
router.get('/dentista-contato', async (req, res) => {
|
||
const clinicaId = String(req.query.clinica_id || '');
|
||
if (!clinicaId) return res.status(400).json({ error: 'clinica_id obrigatório' });
|
||
try {
|
||
const params = [clinicaId]; let where = 'clinica_id = $1';
|
||
if (req.query.dentista_id) { params.push(String(req.query.dentista_id)); where += ` AND id = $${params.length}`; }
|
||
else if (req.query.nome) { params.push(String(req.query.nome)); where += ` AND lower(nome) LIKE '%'||lower($${params.length})||'%'`; }
|
||
const { rows } = await pool.query(
|
||
`SELECT id, nome, telefone FROM dentistas WHERE ${where} AND ativo IS DISTINCT FROM 0 ORDER BY nome LIMIT 1`, params);
|
||
if (!rows.length) return res.json({ encontrado: false });
|
||
const d = rows[0];
|
||
const tel = String(d.telefone || '').replace(/\D/g, '');
|
||
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, telefone: tel || null });
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// ── POST /book ────────────────────────────────────────────────────────────────
|
||
// Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? }
|
||
router.post('/book', async (req, res) => {
|
||
const b = req.body || {};
|
||
if (!b.clinica_id || !b.dentista_id || !b.start) {
|
||
return res.status(400).json({ error: 'clinica_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 {
|
||
await client.query('BEGIN');
|
||
const conflict = await findConflict(client, b.dentista_id, b.start, end);
|
||
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário já ocupado.', conflict_id: conflict.id }); }
|
||
|
||
// Vincula paciente pelo telefone (escopo da clínica; casa pelos últimos 8 dígitos).
|
||
let pacienteId = null;
|
||
let pacienteCriado = false;
|
||
if (b.paciente_celular) {
|
||
const tel = String(b.paciente_celular).replace(/\D/g, '').slice(-8);
|
||
if (tel) {
|
||
const { rows } = await client.query(
|
||
`SELECT id FROM pacientes
|
||
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2 LIMIT 1`,
|
||
[b.clinica_id, tel]);
|
||
pacienteId = rows[0]?.id || null;
|
||
}
|
||
}
|
||
// Não encontrado → cria um paciente mínimo (lead do WhatsApp vira paciente).
|
||
if (!pacienteId && b.paciente_nome) {
|
||
const pid = `pac_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||
await client.query(
|
||
'INSERT INTO pacientes (id, nome, telefone, clinica_id) VALUES ($1,$2,$3,$4)',
|
||
[pid, b.paciente_nome, b.paciente_celular || null, b.clinica_id]);
|
||
pacienteId = pid;
|
||
pacienteCriado = true;
|
||
}
|
||
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 id = `ag_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
|
||
const { rows: ins } = await client.query(
|
||
`INSERT INTO agendamentos
|
||
(id, clinica_id, pacientenome, pacientecelular, pacienteid, dentistaid, procedimento,
|
||
start_time, end_time, status, setor_id, created_by_nome, created_at, updated_at, sala_id)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'agendado',$10,'Secretária IA',NOW(),NOW(),$11)
|
||
RETURNING id, start_time, end_time, dentistaid`,
|
||
[id, b.clinica_id, b.paciente_nome || 'Cliente WhatsApp', b.paciente_celular || null,
|
||
pacienteId, b.dentista_id, b.procedimento || 'Consulta', b.start, end, setorId, b.sala_id || null]);
|
||
await client.query('COMMIT');
|
||
res.json({ ok: true, agendamento: ins[0], paciente_vinculado: !!pacienteId, paciente_criado: pacienteCriado });
|
||
} catch (e) {
|
||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||
res.status(500).json({ error: e.message });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
// ── GET /encaixe/analisar ─────────────────────────────────────────────────────
|
||
// Fluxo de "adiantar consulta": dado um dia (e horário desejado), diz se o horário
|
||
// está OCUPADO (por quem) e qual o horário LIVRE mais cedo que dá para oferecer/
|
||
// encaixar — incluindo a janela FLEX (zona cinza), pois na contenção a Secretária
|
||
// pode usá-la. Query: clinica_id, dentista_id?, data (YYYY-MM-DD), hora? (HH:MM).
|
||
router.get('/encaixe/analisar', async (req, res) => {
|
||
const clinicaId = String(req.query.clinica_id || '');
|
||
const data = String(req.query.data || '');
|
||
if (!clinicaId || !data) return res.status(400).json({ error: 'clinica_id e data obrigatórios' });
|
||
const dentistaId = req.query.dentista_id ? String(req.query.dentista_id) : null;
|
||
const horaDesejada = req.query.hora ? String(req.query.hora).slice(0, 5) : null;
|
||
try {
|
||
const day = new Date(`${data}T00:00:00`);
|
||
const dow = day.getDay();
|
||
const dParams = [clinicaId]; let dWhere = 'clinica_id = $1 AND ativo IS DISTINCT FROM 0';
|
||
if (dentistaId) { dParams.push(dentistaId); dWhere += ` AND id = $${dParams.length}`; }
|
||
const { rows: dentistas } = await pool.query(`SELECT id, nome FROM dentistas WHERE ${dWhere}`, dParams);
|
||
if (!dentistas.length) return res.json({ mais_cedo: null, contestado: null });
|
||
|
||
const clinicHours = await carregarHorariosClinica(pool, clinicaId);
|
||
const { janelas: clinicWins, fechado } = await janelasClinicaNoDia(pool, clinicaId, data, dow, clinicHours);
|
||
if (fechado || !clinicWins.length) return res.json({ mais_cedo: null, contestado: null, motivo: 'fechado' });
|
||
|
||
// Jornada FLEX-inclusiva (usa hora_*_flex quando houver — a zona cinza entra no encaixe).
|
||
const { rows: horarios } = await pool.query(
|
||
`SELECT dentista_id,
|
||
COALESCE(hora_inicio_flex, hora_inicio) AS hora_inicio,
|
||
COALESCE(hora_fim_flex, hora_fim) AS hora_fim
|
||
FROM dentistas_horarios WHERE clinica_id = $1 AND ativo = 1 AND dia_semana = $2`, [clinicaId, dow]);
|
||
const hoursBy = new Map();
|
||
for (const h of horarios) { const a = hoursBy.get(h.dentista_id) || []; a.push([hm(h.hora_inicio), hm(h.hora_fim)]); hoursBy.set(h.dentista_id, a); }
|
||
|
||
const start0 = new Date(day); start0.setHours(0, 0, 0, 0);
|
||
const end0 = new Date(start0); end0.setDate(end0.getDate() + 1);
|
||
const { rows: busy } = await pool.query(
|
||
`SELECT id, dentistaid, pacientenome, pacientecelular, start_time, end_time FROM agendamentos
|
||
WHERE clinica_id=$1 AND start_time>=$2 AND start_time<$3
|
||
AND (status IS NULL OR lower(status) NOT IN ('cancelado','falta','remarcar'))`,
|
||
[clinicaId, start0.toISOString(), end0.toISOString()]);
|
||
const busyBy = new Map();
|
||
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
|
||
|
||
// Quem ocupa o horário desejado (direto, sem depender do grid).
|
||
let contestado = null;
|
||
if (horaDesejada) {
|
||
const alvo = new Date(`${data}T${horaDesejada}:00`);
|
||
const occ = busy.find((b) => new Date(b.start_time) <= alvo && new Date(b.end_time || b.start_time) > alvo)
|
||
|| busy.find((b) => fmt(new Date(b.start_time)).slice(11, 16) === horaDesejada);
|
||
if (occ) {
|
||
const dn = dentistas.find((d) => d.id === occ.dentistaid);
|
||
contestado = { agendamento_id: occ.id, dentista_id: occ.dentistaid, dentista_nome: dn?.nome || null,
|
||
paciente_nome: occ.pacientenome || null, paciente_celular: occ.pacientecelular || null,
|
||
start: fmt(new Date(occ.start_time)), hora: fmt(new Date(occ.start_time)).slice(11, 16) };
|
||
}
|
||
}
|
||
|
||
// Slots LIVRES do dia (flex-inclusivo).
|
||
const now = new Date(); const dur = SLOT_MIN; const livres = [];
|
||
for (const dent of dentistas) {
|
||
const dw = (hoursBy.get(dent.id) && hoursBy.get(dent.id).length) ? hoursBy.get(dent.id) : clinicWins;
|
||
const windows = interseccao(clinicWins, dw);
|
||
const bs = busyBy.get(dent.id) || [];
|
||
for (const [ini, fim] of windows) {
|
||
const [hi, mi] = String(ini).split(':').map(Number);
|
||
const [hf, mf] = String(fim).split(':').map(Number);
|
||
let t = new Date(day); t.setHours(hi, mi || 0, 0, 0);
|
||
const limit = new Date(day); limit.setHours(hf, mf || 0, 0, 0);
|
||
while (t.getTime() + dur * 60000 <= limit.getTime()) {
|
||
const s = new Date(t), e = new Date(t.getTime() + dur * 60000); t = e;
|
||
if (s <= now) continue;
|
||
if (bs.some((b) => new Date(b.start_time) < e && new Date(b.end_time || b.start_time) > s)) continue;
|
||
livres.push({ dentista_id: dent.id, dentista_nome: dent.nome, start: fmt(s), end: fmt(e), hora: fmt(s).slice(11, 16) });
|
||
}
|
||
}
|
||
}
|
||
livres.sort((a, b) => a.start.localeCompare(b.start));
|
||
// mais_cedo = livre imediatamente ANTES do desejado (o mais perto por baixo); senão o 1º livre.
|
||
let mais_cedo = null;
|
||
if (horaDesejada) {
|
||
const antes = livres.filter((s) => s.hora < horaDesejada);
|
||
mais_cedo = antes.length ? antes[antes.length - 1] : (livres[0] || null);
|
||
} else {
|
||
mais_cedo = livres[0] || null;
|
||
}
|
||
res.json({ mais_cedo, contestado, total_livres: livres.length });
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// ── POST /encaixe/mover ───────────────────────────────────────────────────────
|
||
// Move um agendamento existente para um novo horário (o A aceitou adiantar).
|
||
// Body: { clinica_id, agendamento_id, start, end? }.
|
||
router.post('/encaixe/mover', async (req, res) => {
|
||
const b = req.body || {};
|
||
if (!b.clinica_id || !b.agendamento_id || !b.start) return res.status(400).json({ error: 'clinica_id, agendamento_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 {
|
||
await client.query('BEGIN');
|
||
const { rows } = await client.query('SELECT id, dentistaid FROM agendamentos WHERE id=$1 AND clinica_id=$2 FOR UPDATE', [b.agendamento_id, b.clinica_id]);
|
||
const ag = rows[0];
|
||
if (!ag) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Agendamento não encontrado.' }); }
|
||
const conflict = await findConflict(client, ag.dentistaid, b.start, end, b.agendamento_id);
|
||
if (conflict) { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Horário de destino já ocupado.' }); }
|
||
await client.query('UPDATE agendamentos SET start_time=$1, end_time=$2, updated_at=NOW() WHERE id=$3', [b.start, end, b.agendamento_id]);
|
||
await client.query('COMMIT');
|
||
res.json({ ok: true });
|
||
} catch (e) { try { await client.query('ROLLBACK'); } catch { /* ignore */ } res.status(500).json({ error: e.message }); }
|
||
finally { client.release(); }
|
||
});
|
||
|
||
// ════════ Cadastro de pacientes (Secretária super inteligente) ════════════════
|
||
const soDigitos = (v) => String(v || '').replace(/\D/g, '');
|
||
function idadeDe(dob) {
|
||
if (!dob) return null;
|
||
const s = String(dob).trim();
|
||
let d = null, m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||
if (m) d = new Date(+m[1], +m[2] - 1, +m[3]);
|
||
else { m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/); if (m) d = new Date(+m[3], +m[2] - 1, +m[1]); }
|
||
if (!d || isNaN(d.getTime())) return null;
|
||
const n = new Date(); let a = n.getFullYear() - d.getFullYear();
|
||
const mo = n.getMonth() - d.getMonth();
|
||
if (mo < 0 || (mo === 0 && n.getDate() < d.getDate())) a--;
|
||
return a >= 0 && a < 130 ? a : null;
|
||
}
|
||
|
||
// GET /pacientes/lookup?clinica_id=&phone= — quem está cadastrado NESTE número (a família).
|
||
router.get('/pacientes/lookup', 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' });
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, nome, cpf, telefone, datanascimento, convenio, grupofamiliarid, grupofamiliarrelacao
|
||
FROM pacientes
|
||
WHERE clinica_id = $1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
|
||
ORDER BY (grupofamiliarrelacao = 'titular' OR grupofamiliarrelacao IS NULL) DESC, nome`,
|
||
[clinicaId, tel]);
|
||
res.json({
|
||
encontrados: rows.length,
|
||
pacientes: rows.map((p) => ({
|
||
id: p.id, nome: p.nome, cpf: p.cpf || null,
|
||
data_nascimento: p.datanascimento || null, idade: idadeDe(p.datanascimento),
|
||
convenio: p.convenio || null, relacao: p.grupofamiliarrelacao || null, grupo_id: p.grupofamiliarid || null,
|
||
})),
|
||
});
|
||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||
});
|
||
|
||
// POST /pacientes — cadastra/atualiza. Dedup por CPF; idade por nascimento; grupo familiar
|
||
// (dependente vinculado ao titular via grupofamiliarid = id do titular).
|
||
router.post('/pacientes', async (req, res) => {
|
||
const b = req.body || {};
|
||
if (!b.clinica_id || !b.nome) return res.status(400).json({ error: 'clinica_id e nome obrigatórios' });
|
||
const cpf = soDigitos(b.cpf);
|
||
const telefone = b.telefone ? soDigitos(b.telefone) : null;
|
||
const idade = idadeDe(b.data_nascimento);
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
// 1) Dedup forte por CPF (identificador confiável — evita duplicar quem já existe).
|
||
let existente = null;
|
||
if (cpf) {
|
||
const { rows } = await client.query(
|
||
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, cpf]);
|
||
existente = rows[0] || null;
|
||
}
|
||
// 2) Resolve o RESPONSÁVEL (para dependentes): por id, cpf ou telefone (o titular do número).
|
||
let responsavel = null;
|
||
if (b.responsavel_id) {
|
||
const { rows } = await client.query('SELECT * FROM pacientes WHERE id=$1 AND clinica_id=$2', [b.responsavel_id, b.clinica_id]); responsavel = rows[0] || null;
|
||
} else if (b.responsavel_cpf) {
|
||
const { rows } = await client.query(`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(cpf,''),'\\D','','g')=$2 LIMIT 1`, [b.clinica_id, soDigitos(b.responsavel_cpf)]); responsavel = rows[0] || null;
|
||
} else if (b.responsavel_telefone) {
|
||
const { rows } = await client.query(
|
||
`SELECT * FROM pacientes WHERE clinica_id=$1 AND regexp_replace(coalesce(telefone,''),'\\D','','g') LIKE '%'||$2
|
||
AND (grupofamiliarrelacao='titular' OR grupofamiliarrelacao IS NULL) ORDER BY nome LIMIT 1`,
|
||
[b.clinica_id, soDigitos(b.responsavel_telefone).slice(-8)]); responsavel = rows[0] || null;
|
||
}
|
||
// Dependente = tem responsável OU menor de 18 (padrão) sem marcar eh_titular.
|
||
const ehDependente = !!responsavel || (idade != null && idade < 18 && !b.eh_titular);
|
||
let relacao = b.relacao || (ehDependente ? (idade != null && idade < 18 ? 'filho(a)' : 'dependente') : (b.eh_titular ? 'titular' : null));
|
||
// Telefone do dependente sem número próprio → herda o do responsável (compartilhado).
|
||
let telFinal = telefone;
|
||
if (ehDependente && !telFinal && responsavel && responsavel.telefone) telFinal = soDigitos(responsavel.telefone);
|
||
// 3) Grupo familiar: titular vira cabeça (grupofamiliarid = id do titular) + grupos_familiares.
|
||
let grupoId = null;
|
||
if (ehDependente && responsavel) {
|
||
grupoId = responsavel.grupofamiliarid || responsavel.id;
|
||
if (!responsavel.grupofamiliarid) {
|
||
await client.query(`UPDATE pacientes SET grupofamiliarid=$1, grupofamiliarrelacao=COALESCE(grupofamiliarrelacao,'titular') WHERE id=$1`, [responsavel.id]);
|
||
}
|
||
await client.query(`INSERT INTO grupos_familiares (id, nome, responsavel_id, created_at) VALUES ($1,$2,$3,NOW()) ON CONFLICT (id) DO NOTHING`, [grupoId, responsavel.nome, responsavel.id]);
|
||
}
|
||
// 4) Atualiza (dedup) OU cria.
|
||
if (existente) {
|
||
await client.query(
|
||
`UPDATE pacientes SET nome=$2, telefone=COALESCE($3,telefone), email=COALESCE($4,email),
|
||
convenio=COALESCE($5,convenio), datanascimento=COALESCE($6,datanascimento),
|
||
grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao) WHERE id=$1`,
|
||
[existente.id, b.nome, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
|
||
await client.query('COMMIT');
|
||
return res.json({ ok: true, atualizado: true, paciente: { id: existente.id, nome: b.nome, relacao, grupo_id: grupoId, idade } });
|
||
}
|
||
const id = `pac_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||
await client.query(
|
||
`INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||
[id, b.clinica_id, b.nome, cpf || null, telFinal, b.email || null, b.convenio || null, b.data_nascimento || null, grupoId, relacao]);
|
||
await client.query('COMMIT');
|
||
res.json({ ok: true, criado: true, paciente: { id, nome: b.nome, cpf: cpf || null, relacao, grupo_id: grupoId, idade, dependente: ehDependente } });
|
||
} catch (e) {
|
||
try { await client.query('ROLLBACK'); } catch { /* ignore */ }
|
||
res.status(500).json({ error: e.message });
|
||
} finally { client.release(); }
|
||
});
|
||
|
||
return router;
|
||
}
|