feat(agenda+secretaria): backend — validação, cadastro, nomes, check-in, notificar

- validacao.js: CPF/CNPJ (dígito verificador real) + data de nascimento.
- agenda-bridge: /paciente-contexto +inadimplente/em_tratamento/falta_recente;
  /pacientes valida CPF+data e grava dados_confirmados=false (revisão humana);
  /notificar (notifica a equipe da clínica).
- agenda-config: /nomes-por-telefone (nosso nome/titular p/ o inbox);
  /pedidos (fila da Secretária).
- proxy: só o dono liga/desliga a Secretária (gate owner nos *-power).
- server.js: GET /pacientes/:id + POST /pacientes/:id/confirmar-dados (check-in).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-10 06:07:02 +02:00
parent d26fd80a28
commit 56443765d4
5 changed files with 249 additions and 15 deletions
+71 -4
View File
@@ -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;
}
+47 -2
View File
@@ -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;
}
+19 -8
View File
@@ -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 });
+68
View File
@@ -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 };
+44 -1
View File
@@ -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 {