Files
scoreodonto.com/backend/newwhats/agenda-bridge.js
T
VPS 4 Builder 0a324bf8c8 feat(secretaria): folga do dentista por data (A) + salas alugadas na agenda (B)
A — Folga/férias do dentista por DATA: tabela dentistas_folgas; o dentista (ou o
dono) marca dias em que não atende; o /slots pula o dentista nessas datas.
Endpoints /dentista/:id/folgas (GET/POST/DELETE) + UI na aba Dentistas.

B — Salas alugadas: o dentista com sala_reserva aprovada passa a ter horários
oferecidos pela Secretária no quarto (disponibilidade = janela da reserva,
casada pelo usuario_id). /slots ganha passada de salas (slot com sala_id +
local); /book grava agendamentos.sala_id (coluna aditiva). Geração passa a usar
CANDIDATE_CAP e ordena por horário (clínica e salas competem).

Testado em dev: folga pula os dias do dentista; reserva de sala gera slots com o
nome da sala.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 02:12:50 +02:00

479 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { resolverFeriado, feriadoNacionalNome } from './feriados.js';
const SLOT_MIN = 30; // duração padrão do slot (min)
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 (segsex 0818). 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 0818 ∩ dentista 1320 = 1318).
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) {
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
LIMIT 1`,
[ids, start, end || start]);
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();
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 */ }
}
slots.sort((a, b) => a.start.localeCompare(b.start));
res.json({ slots: slots.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 /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();
}
});
// ════════ 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;
}