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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user