diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 3976dcf..3868d00 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -10,6 +10,7 @@ // POST /book — cria agendamento real (anti-overbooking + vínculo de paciente) import express from 'express'; import { getConfig } from './config.js'; +import { validarCpfCnpj, validarNascimento } from './validacao.js'; import { cautelasParaIA } from './checklist-defs.js'; import { resolverFeriado, feriadoNacionalNome } from './feriados.js'; @@ -818,6 +819,19 @@ export function createAgendaBridge(pool) { 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' }); + // Validação server-side (dígito verificador REAL): a Ana relata ok:false p/ corrigir. + if (b.cpf) { + const d = validarCpfCnpj(b.cpf); + if (!d.valido) return res.json({ ok: false, campo: 'cpf', mensagem: 'O CPF/CNPJ informado não é válido. Peça para a pessoa conferir e reenviar.' }); + } + if (b.data_nascimento) { + const n = validarNascimento(b.data_nascimento); + if (!n.valido) { + const msg = n.motivo === 'futura' ? 'A data de nascimento está no futuro.' : n.motivo === 'muito_antiga' ? 'A data de nascimento não parece plausível.' : 'A data de nascimento não é uma data válida.'; + return res.json({ ok: false, campo: 'data_nascimento', mensagem: `${msg} Peça para conferir e reenviar (DD/MM/AAAA).` }); + } + b.data_nascimento = n.iso; // normaliza p/ YYYY-MM-DD antes de gravar + } const cpf = soDigitos(b.cpf); const telefone = b.telefone ? soDigitos(b.telefone) : null; const idade = idadeDe(b.data_nascimento); @@ -859,19 +873,22 @@ export function createAgendaBridge(pool) { 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. + // dados_confirmados=false: cadastro veio da Secretária (IA) → a recepção CONFIRMA + // no check-in (Feature C). O modal de confirmação some quando a humana valida. 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`, + grupofamiliarid=COALESCE($7,grupofamiliarid), grupofamiliarrelacao=COALESCE($8,grupofamiliarrelacao), + dados_confirmados=false 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)`, + `INSERT INTO pacientes (id, clinica_id, nome, cpf, telefone, email, convenio, datanascimento, grupofamiliarid, grupofamiliarrelacao, dados_confirmados) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,false)`, [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 } }); @@ -1019,6 +1036,9 @@ export function createAgendaBridge(pool) { const clinicaId = String(req.query.clinica_id || ''); const pacienteId = req.query.paciente_id ? String(req.query.paciente_id) : null; const tel = soDigitos(req.query.phone).slice(-8); + // Inadimplente = parcela (RECEITA) vencida há mais de N dias e ainda em aberto. + // N vem do motor (config do dono); default 30. Falta recente = no-show nos últimos 60 dias. + const inadDias = Math.max(0, parseInt(String(req.query.inadimplente_dias || '30'), 10) || 30); if (!clinicaId || (!pacienteId && !tel)) return res.status(400).json({ error: 'clinica_id e (paciente_id ou phone) obrigatórios' }); try { let pacientes; @@ -1049,11 +1069,58 @@ export function createAgendaBridge(pool) { dentistaNome = ag[0]?.dentista || null; dentistaId = ag[0]?.dentistaid || null; if (ag[0]?.recente) emTratamento = true; } - out.push({ paciente_id: p.id, nome: p.nome, dentista_responsavel: dentistaNome, dentista_id: dentistaId, em_tratamento: emTratamento }); + // Inadimplência: parcelas RECEITA vencidas há > N dias, ainda em aberto + // (pago_em nulo e status não-pago). Resumo é INTERNO (nunca recitado ao paciente). + const { rows: inad } = await pool.query( + `SELECT COUNT(*)::int AS n, COALESCE(SUM(valor),0) AS total, MIN(datavencimento) AS mais_antiga + FROM financeiro + WHERE paciente_id=$1 AND clinica_id=$2 AND deleted_at IS NULL + AND upper(coalesce(tipo,'RECEITA'))='RECEITA' + AND pago_em IS NULL + AND lower(coalesce(status,'')) NOT IN ('pago','recebido','cancelado') + AND datavencimento < (CURRENT_DATE - ($3 || ' days')::interval)`, + [p.id, clinicaId, inadDias]).catch(() => ({ rows: [{ n: 0, total: 0, mais_antiga: null }] })); + const inadN = inad[0]?.n || 0; + const inadimplente = inadN > 0; + const inadimplencia_resumo = inadimplente + ? `${inadN} parcela(s) vencida(s) há +${inadDias}d, total R$ ${Number(inad[0].total).toFixed(2)}, mais antiga venc. ${String(inad[0].mais_antiga).slice(0, 10)}` + : null; + + // Falta recente (no-show) nos últimos 60 dias. + const { rows: falta } = await pool.query( + `SELECT COUNT(*)::int AS n FROM agendamentos + WHERE pacienteid=$1 AND lower(coalesce(status,'')) IN ('falta','faltou') + AND start_time >= NOW() - interval '60 days'`, [p.id]).catch(() => ({ rows: [{ n: 0 }] })); + const falta_recente = (falta[0]?.n || 0) > 0; + + out.push({ paciente_id: p.id, nome: p.nome, dentista_responsavel: dentistaNome, dentista_id: dentistaId, em_tratamento: emTratamento, inadimplente, inadimplencia_resumo, falta_recente }); } res.json({ pacientes: out }); } catch (e) { res.status(500).json({ error: e.message }); } }); + // ── POST /notificar ─────────────────────────────────────────────────────────── + // Cria uma notificação in-app para a EQUIPE da clínica (todos os vínculos). Usado + // pela Secretária p/ avisar a recepção de eventos que exigem humano (ex.: cliente + // insistiu num dentista específico). Server-to-server (x-nw-agenda-secret). + router.post('/notificar', async (req, res) => { + const { clinica_id, titulo, mensagem, tipo } = req.body || {}; + if (!clinica_id || !titulo) return res.status(400).json({ error: 'clinica_id e titulo obrigatórios' }); + const t = ['sucesso', 'info', 'alerta', 'erro', 'aviso'].includes(String(tipo)) ? String(tipo) : 'alerta'; + try { + const { rows } = await pool.query('SELECT DISTINCT usuario_id FROM vinculos WHERE clinica_id = $1', [clinica_id]); + let n = 0; + for (const r of rows) { + if (!r.usuario_id) continue; + await pool.query( + `INSERT INTO notificacoes (id, titulo, mensagem, tipo, lida, data, usuario_id, enviado_por) + VALUES (gen_random_uuid(), $1, $2, $3, 0, NOW(), $4, 'secretaria')`, + [String(titulo).slice(0, 120), String(mensagem || '').slice(0, 500), t, r.usuario_id]); + n++; + } + res.json({ ok: true, notificados: n }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + return router; } diff --git a/backend/newwhats/agenda-config.js b/backend/newwhats/agenda-config.js index 108e0aa..84ca00a 100644 --- a/backend/newwhats/agenda-config.js +++ b/backend/newwhats/agenda-config.js @@ -393,8 +393,18 @@ export function createAgendaConfig(pool) { const dentistaId = req.body?.dentista_id || ped.dentista_id; if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); } const dur = Math.max(10, Number(req.body?.duracao_min) || 30); - const hi = String(ped.hora_inicio).slice(0, 5); - const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`; + // Horário pode ser AJUSTADO na confirmação: a IA fixou um horário, mas a humana + // combinou outro com o paciente por telefone. Aceita HH:MM válido; senão usa o do pedido. + const hi = /^([01]\d|2[0-3]):[0-5]\d$/.test(String(req.body?.hora_inicio || '')) + ? String(req.body.hora_inicio) + : String(ped.hora_inicio).slice(0, 5); + // Dia pode ser TROCADO na confirmação: o paciente pediu outro dia. Aceita YYYY-MM-DD + // válido e não-passado; senão usa o do pedido. + const pedData = (ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10)); + const reqData = String(req.body?.data || ''); + const hojeStr = new Date().toISOString().slice(0, 10); + const dataConf = (/^\d{4}-\d{2}-\d{2}$/.test(reqData) && reqData >= hojeStr) ? reqData : pedData; + const start = `${dataConf} ${hi}:00`; const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur); const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`; const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]); @@ -444,5 +454,40 @@ export function createAgendaConfig(pool) { } catch (e) { res.status(500).json({ error: e.message }); } }); + // Nomes NOSSOS (base de pacientes) por telefone — p/ o /wa-inbox mostrar o nome que + // cadastramos no lugar do número/pushname. Casa pelos últimos 8 dígitos. Um número + // pode ter VÁRIOS pacientes (grupo familiar): devolve todos + um `principal` + // (1 paciente → o nome; N → "1º nome +N"). Titular designado virá na Feature B. + router.post('/clinica/:clinicaId/nomes-por-telefone', async (req, res) => { + const clinicaId = req.params.clinicaId; + if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' }); + const lista = Array.isArray(req.body?.telefones) ? req.body.telefones : []; + const chaves = [...new Set(lista.map((t) => String(t || '').replace(/\D/g, '').slice(-8)).filter((k) => k.length >= 8))]; + if (chaves.length === 0) return res.json({ mapa: {} }); + try { + const { rows } = await pool.query( + `SELECT id, nome, grupofamiliarrelacao, + right(regexp_replace(coalesce(telefone,''),'\\D','','g'), 8) AS chave + FROM pacientes + WHERE clinica_id = $1 + AND right(regexp_replace(coalesce(telefone,''),'\\D','','g'), 8) = ANY($2::text[]) + ORDER BY nome`, + [clinicaId, chaves]); + const mapa = {}; + for (const p of rows) { + (mapa[p.chave] ??= { pacientes: [] }).pacientes.push({ id: p.id, nome: p.nome, relacao: p.grupofamiliarrelacao || null }); + } + for (const k of Object.keys(mapa)) { + const ps = mapa[k].pacientes; + mapa[k].count = ps.length; + // Principal: se há titular no grupo, mostra o titular; senão "1º nome +N". + const titular = ps.find((x) => String(x.relacao || '').toLowerCase() === 'titular'); + if (titular) mapa[k].principal = ps.length <= 1 ? titular.nome : `${titular.nome.split(' ')[0]} +${ps.length - 1}`; + else { const primeiro = ps[0]?.nome || ''; mapa[k].principal = ps.length <= 1 ? primeiro : `${primeiro.split(' ')[0]} +${ps.length - 1}`; } + } + res.json({ mapa }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + return router; } diff --git a/backend/newwhats/proxy.js b/backend/newwhats/proxy.js index 9a22217..32abb11 100644 --- a/backend/newwhats/proxy.js +++ b/backend/newwhats/proxy.js @@ -60,18 +60,19 @@ async function resolveOwner(pool, clinicaId) { // Retorna { ok: true } ou { ok: false, status, error }. async function guardSessionAction(pool, req, tail, userId) { const method = req.method.toUpperCase(); - const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect)?)?/); + const m = tail.match(/^\/sessions(?:\/([^/?]+)(\/qr|\/connect|\/disconnect)?)?/); if (!m) return { ok: true }; // não é uma rota de sessão const instanceId = m[1] || null; const sub = m[2] || null; - const isCreate = method === 'POST' && !instanceId; // POST /sessions - const isRescan = (method === 'GET' && sub === '/qr') || - (method === 'POST' && sub === '/connect'); // parear/reconectar - const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id + const isCreate = method === 'POST' && !instanceId; // POST /sessions + const isRescan = (method === 'GET' && sub === '/qr') || + (method === 'POST' && sub === '/connect'); // parear/reconectar + const isDisconnect = method === 'POST' && sub === '/disconnect'; // desligar a sessão (gestão da conexão) + const isDelete = method === 'DELETE' && instanceId && !sub; // DELETE /sessions/:id - if (!isCreate && !isRescan && !isDelete) return { ok: true }; // GET lista / etc → livre + if (!isCreate && !isRescan && !isDisconnect && !isDelete) return { ok: true }; // GET lista / etc → livre const clinicaId = req.headers['x-nw-clinica'] ? String(req.headers['x-nw-clinica']) : null; if (!clinicaId) return { ok: false, status: 403, error: 'Workspace não identificado para esta ação.' }; @@ -92,14 +93,17 @@ async function guardSessionAction(pool, req, tail, userId) { authz = rows[0] || null; } catch { /* nega por padrão */ } - if (isRescan && authz?.can_rescan) return { ok: true }; + // Desconectar é gestão da conexão (o inverso de reconectar) → mesma permissão de re-scan. + if ((isRescan || isDisconnect) && authz?.can_rescan) return { ok: true }; if (isDelete && authz?.can_delete) return { ok: true }; return { ok: false, status: 403, error: isDelete ? 'Somente o dono pode excluir esta sessão. Peça autorização de exclusão.' - : 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.', + : isDisconnect + ? 'Somente o dono pode desconectar esta sessão. Peça autorização de re-scan.' + : 'Somente o dono pode reconectar esta sessão. Peça autorização de re-scan.', }; } @@ -211,6 +215,13 @@ export function createRestProxy(pool) { const area = await guardAreaAccess(pool, tail, userId, clinicaId); if (!area.ok) return res.status(area.status).json({ error: area.error }); + // Só o DONO liga/desliga a Secretária (global / por sessão / por chat), quando + // quiser. Fail-closed: os demais nem veem o controle na UI e aqui levam 403. + if (req.method === 'POST' && /^\/secretaria\/(power|session-power|chat-power)(\/|$|\?)/.test(tail)) { + const owner = clinicaId ? await resolveOwner(pool, clinicaId) : null; + if (!owner || owner.ownerId !== userId) return res.status(403).json({ error: 'Apenas o dono do workspace pode ligar/desligar a Secretária.' }); + } + // Gate de ações destrutivas do inbox (apagar conversa/mensagem). const destructive = await guardInboxDestructive(pool, req.method, tail, userId, clinicaId); if (!destructive.ok) return res.status(destructive.status).json({ error: destructive.error }); diff --git a/backend/newwhats/validacao.js b/backend/newwhats/validacao.js new file mode 100644 index 0000000..f3d2301 --- /dev/null +++ b/backend/newwhats/validacao.js @@ -0,0 +1,68 @@ +// Validação de documentos e data de nascimento para o cadastro de pacientes. +// CPF/CNPJ com dígito verificador REAL (não só formato); nascimento com data válida, +// não-futura e idade plausível. Usado no cadastro via Secretária (server-side). + +const soDigitos = (s) => String(s || '').replace(/\D/g, ''); + +// ── CPF ────────────────────────────────────────────────────────────────────── +function validarCPF(entrada) { + const cpf = soDigitos(entrada); + if (cpf.length !== 11) return false; + if (/^(\d)\1{10}$/.test(cpf)) return false; // 000..., 111..., etc. + const dv = (base, pesoIni) => { + let soma = 0; + for (let i = 0; i < base.length; i++) soma += Number(base[i]) * (pesoIni - i); + const r = (soma * 10) % 11; + return r === 10 ? 0 : r; + }; + const d1 = dv(cpf.slice(0, 9), 10); + const d2 = dv(cpf.slice(0, 10), 11); + return d1 === Number(cpf[9]) && d2 === Number(cpf[10]); +} + +// ── CNPJ ───────────────────────────────────────────────────────────────────── +function validarCNPJ(entrada) { + const cnpj = soDigitos(entrada); + if (cnpj.length !== 14) return false; + if (/^(\d)\1{13}$/.test(cnpj)) return false; + const dv = (len) => { + const pesos = len === 12 ? [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2] : [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + let soma = 0; + for (let i = 0; i < len; i++) soma += Number(cnpj[i]) * pesos[i]; + const r = soma % 11; + return r < 2 ? 0 : 11 - r; + }; + return dv(12) === Number(cnpj[12]) && dv(13) === Number(cnpj[13]); +} + +// CPF (11) ou CNPJ (14). Retorna { valido, tipo }. +function validarCpfCnpj(entrada) { + const d = soDigitos(entrada); + if (d.length === 11) return { valido: validarCPF(d), tipo: 'cpf' }; + if (d.length === 14) return { valido: validarCNPJ(d), tipo: 'cnpj' }; + return { valido: false, tipo: null }; +} + +// ── Data de nascimento ───────────────────────────────────────────────────── +// Aceita DD/MM/AAAA, DD-MM-AAAA ou AAAA-MM-DD. Retorna { valido, iso, motivo }. +// Rejeita data inexistente (31/02), futura, e idade fora de 0..120 anos. +function validarNascimento(entrada) { + const s = String(entrada || '').trim(); + let y, m, d; + let mt; + if ((mt = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = +mt[1]; m = +mt[2]; d = +mt[3]; } + else if ((mt = s.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/))) { d = +mt[1]; m = +mt[2]; y = +mt[3]; } + else return { valido: false, motivo: 'formato' }; + if (m < 1 || m > 12 || d < 1 || d > 31) return { valido: false, motivo: 'invalida' }; + const dt = new Date(Date.UTC(y, m - 1, d)); + // Confere que a data existe de fato (evita 31/02, 30/02, etc.). + if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== m - 1 || dt.getUTCDate() !== d) return { valido: false, motivo: 'invalida' }; + const hoje = new Date(); + if (dt.getTime() > hoje.getTime()) return { valido: false, motivo: 'futura' }; + const idade = (hoje - dt) / (365.25 * 24 * 3600 * 1000); + if (idade > 120) return { valido: false, motivo: 'muito_antiga' }; + const iso = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + return { valido: true, iso }; +} + +export { soDigitos, validarCPF, validarCNPJ, validarCpfCnpj, validarNascimento }; diff --git a/backend/server.js b/backend/server.js index 7c2e1ae..c5e1396 100644 --- a/backend/server.js +++ b/backend/server.js @@ -13,6 +13,7 @@ import bcrypt from 'bcryptjs'; import crypto from 'crypto'; import { registerNewwhats, createWsTunnel, provisionNewwhatsAccountEager } from './newwhats/index.js'; import { operatorSignature } from './newwhats/proxy.js'; +import { validarCpfCnpj, validarNascimento } from './newwhats/validacao.js'; const { Pool } = pg; const JWT_SECRET = process.env.JWT_SECRET || 'scoreodonto_secret_key_2026'; @@ -884,7 +885,7 @@ app.post('/api/clinicas/:clinicaId/membros', tenantGuard, requireCapability('ges }); app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapability('gestao-equipe'), async (req, res) => { - const { role, setor_id, funcao_id } = req.body; + const { role, setor_id, funcao_id, nome } = req.body; try { const reqNivel = await nivelNaClinica(req.authUser.userId, req.params.clinicaId); const alvoNivel = await nivelNaClinica(req.params.userId, req.params.clinicaId); @@ -893,6 +894,11 @@ app.put('/api/clinicas/:clinicaId/membros/:userId', tenantGuard, requireCapabili } await pool.query('UPDATE vinculos SET role = COALESCE($1, role), setor_id = $2, funcao_id = $3 WHERE usuario_id = $4 AND clinica_id = $5', [role || null, setor_id || null, funcao_id || null, req.params.userId, req.params.clinicaId]); + // Nome do titular da conta (usado, entre outros, na assinatura das mensagens do + // WhatsApp). Editável pelo dono p/ quando a pessoa da conta muda (ex.: troca de secretária). + if (typeof nome === 'string' && nome.trim()) { + await pool.query('UPDATE usuarios SET nome = $1 WHERE id = $2', [nome.trim().toUpperCase(), req.params.userId]); + } res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); @@ -2170,6 +2176,43 @@ app.put('/api/pacientes/:id', authGuard, requireCapabilityRow('agenda', 'pacient } }); +// Paciente único (p/ o modal de confirmação no check-in — Feature C). +app.get('/api/pacientes/:id', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => { + try { + const { rows } = await pool.query( + `SELECT id, nome, cpf, telefone, datanascimento, dados_confirmados, + grupofamiliarid, grupofamiliarrelacao, clinica_id FROM pacientes WHERE id=$1`, [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'Paciente não encontrado.' }); + if (!await userHasVinculo(req.authUser?.userId, rows[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado.' }); + res.json({ paciente: rows[0] }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// Confirma os dados do paciente no check-in (recepção): valida CPF/CNPJ + nascimento, +// atualiza nome/cpf/telefone/nascimento e marca dados_confirmados=true. O `telefone` +// pode ser o número PRÓPRIO deste paciente (dependente que ganhou WhatsApp próprio). +app.post('/api/pacientes/:id/confirmar-dados', authGuard, requireCapabilityRow('agenda', 'pacientes'), async (req, res) => { + try { + const { rows: pc } = await pool.query('SELECT clinica_id FROM pacientes WHERE id=$1', [req.params.id]); + if (!pc.length) return res.status(404).json({ error: 'Paciente não encontrado.' }); + if (!await userHasVinculo(req.authUser?.userId, pc[0].clinica_id)) return res.status(403).json({ error: 'Acesso negado.' }); + const { nome, cpf, telefone } = req.body || {}; + if (cpf) { const d = validarCpfCnpj(cpf); if (!d.valido) return res.json({ ok: false, campo: 'cpf', mensagem: 'CPF/CNPJ inválido — confira e corrija.' }); } + let nascIso = null; + if (req.body?.data_nascimento) { + const n = validarNascimento(req.body.data_nascimento); + if (!n.valido) return res.json({ ok: false, campo: 'data_nascimento', mensagem: 'Data de nascimento inválida — confira (DD/MM/AAAA).' }); + nascIso = n.iso; + } + await pool.query( + `UPDATE pacientes SET nome=COALESCE($2,nome), cpf=COALESCE($3,cpf), telefone=COALESCE($4,telefone), + datanascimento=COALESCE($5,datanascimento), dados_confirmados=true WHERE id=$1`, + [req.params.id, nome || null, cpf || null, telefone || null, nascIso]); + schedulePush('pacientes'); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + // --- DENTISTAS --- app.get('/api/dentistas', authGuard, async (req, res) => { try {