// 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 = 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, 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. 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(); 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 /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; }