diff --git a/backend/server.js b/backend/server.js index 37648aa..597704a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6175,6 +6175,20 @@ app.post('/api/pedidos-exame', authGuard, async (req, res) => { async function runMigrations() { const migrations = [ + // Comunicação interna: perfil de contato de cada NÃO-paciente (dentista/funcionário). + // Números de WhatsApp múltiplos (cada um com descrição, situações e janelas de horário + // permitido p/ a Secretária enviar msg — evita mensagem fora do expediente), endereço e + // disponibilidade de agenda (abrir/fechar). Chave por (usuario_id, clinica_id). + `CREATE TABLE IF NOT EXISTS pessoa_comunicacao ( + usuario_id TEXT NOT NULL, + clinica_id TEXT NOT NULL, + endereco JSONB NOT NULL DEFAULT '{}'::jsonb, + numeros JSONB NOT NULL DEFAULT '[]'::jsonb, + agenda JSONB NOT NULL DEFAULT '{}'::jsonb, + completo BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (usuario_id, clinica_id) + )`, `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarid TEXT`, `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS grupofamiliarrelacao TEXT`, `ALTER TABLE pacientes ADD COLUMN IF NOT EXISTS indicadorporid TEXT`, @@ -7539,6 +7553,66 @@ async function resolveOrigem(req, cep, clinicaId) { return null; } +// ─── Comunicação interna (perfil de contato de não-pacientes) ──────────────────── +// Regra de completude que destrava o gate: endereço + ≥1 número (com descrição e ≥1 +// janela de horário) + disponibilidade de agenda decidida. +function comunicacaoCompleto(p) { + const e = p.endereco || {}; + const nums = Array.isArray(p.numeros) ? p.numeros : []; + const ag = p.agenda || {}; + const enderecoOk = !!(e.cidade && e.estado && (e.cep || e.logradouro)); + const numerosOk = nums.length >= 1 && nums.every((n) => + n && String(n.numero || '').replace(/\D/g, '').length >= 10 && String(n.descricao || '').trim() && + Array.isArray(n.janelas) && n.janelas.length >= 1); + const agendaOk = ag.aberta === false || (ag.aberta === true && Array.isArray(ag.horarios) && ag.horarios.length >= 1); + return enderecoOk && numerosOk && agendaOk; +} +// Clínica do usuário: param explícito → primeiro vínculo. +async function resolveClinicaUsuario(userId, clinicaIdParam) { + if (clinicaIdParam) return String(clinicaIdParam); + const { rows } = await pool.query('SELECT clinica_id FROM vinculos WHERE usuario_id = $1 ORDER BY id LIMIT 1', [userId]); + return rows[0]?.clinica_id || null; +} +// Só o próprio, ou um gestor (dono/admin) da mesma clínica, edita o perfil de outro. +async function podeEditarComunicacao(reqUserId, alvoUserId, clinicaId) { + if (reqUserId === alvoUserId) return true; + const { rows } = await pool.query('SELECT role FROM vinculos WHERE usuario_id=$1 AND clinica_id=$2', [reqUserId, clinicaId]); + return ['donoclinica', 'donoconsultorio', 'admin'].includes(rows[0]?.role || ''); +} + +app.get('/api/comunicacao/:usuarioId', authGuard, async (req, res) => { + try { + const alvo = req.params.usuarioId === 'me' ? req.authUser.userId : req.params.usuarioId; + const clinicaId = await resolveClinicaUsuario(alvo, req.query.clinicaId); + if (!clinicaId) return res.json({ usuario_id: alvo, clinica_id: null, endereco: {}, numeros: [], agenda: {}, completo: true, sem_clinica: true }); + if (!(await podeEditarComunicacao(req.authUser.userId, alvo, clinicaId))) return res.status(403).json({ error: 'Sem permissão.' }); + const { rows } = await pool.query('SELECT endereco, numeros, agenda, completo FROM pessoa_comunicacao WHERE usuario_id=$1 AND clinica_id=$2', [alvo, clinicaId]); + const p = rows[0] || { endereco: {}, numeros: [], agenda: {}, completo: false }; + res.json({ usuario_id: alvo, clinica_id: clinicaId, ...p }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +app.put('/api/comunicacao/:usuarioId', authGuard, async (req, res) => { + try { + const alvo = req.params.usuarioId === 'me' ? req.authUser.userId : req.params.usuarioId; + const b = req.body || {}; + const clinicaId = await resolveClinicaUsuario(alvo, b.clinicaId || req.query.clinicaId); + if (!clinicaId) return res.status(400).json({ error: 'Clínica não encontrada para o usuário.' }); + if (!(await podeEditarComunicacao(req.authUser.userId, alvo, clinicaId))) return res.status(403).json({ error: 'Sem permissão.' }); + const endereco = b.endereco && typeof b.endereco === 'object' ? b.endereco : {}; + const numeros = Array.isArray(b.numeros) ? b.numeros : []; + const agenda = b.agenda && typeof b.agenda === 'object' ? b.agenda : {}; + const completo = comunicacaoCompleto({ endereco, numeros, agenda }); + await pool.query( + `INSERT INTO pessoa_comunicacao (usuario_id, clinica_id, endereco, numeros, agenda, completo, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,NOW()) + ON CONFLICT (usuario_id, clinica_id) DO UPDATE + SET endereco=EXCLUDED.endereco, numeros=EXCLUDED.numeros, agenda=EXCLUDED.agenda, completo=EXCLUDED.completo, updated_at=NOW()`, + [alvo, clinicaId, JSON.stringify(endereco), JSON.stringify(numeros), JSON.stringify(agenda), completo]); + res.json({ ok: true, completo }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + // Minhas salas (como locador) app.get('/api/salas/minhas', authGuard, async (req, res) => { try { diff --git a/frontend/App.tsx b/frontend/App.tsx index 4eeb8d0..311d74a 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -8,6 +8,7 @@ import { } from 'lucide-react'; import { ToastContainer } from './components/Toast.tsx'; +import { ComunicacaoInternaModal } from './components/ComunicacaoInternaModal.tsx'; import { useAppVersion } from './buildInfo.ts'; import { Dashboard } from './views/Dashboard.tsx'; @@ -487,6 +488,27 @@ const App: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Gate de comunicação interna: todo NÃO-paciente com clínica ativa precisa preencher + // o perfil (endereço, números + janelas de horário, agenda) antes de usar o sistema. + // null = ainda não sei; true = ok/não-se-aplica; false = FORÇAR o modal em todas as telas. + const [comOk, setComOk] = useState(null); + useEffect(() => { + const ws: any = HybridBackend.getActiveWorkspace(); + const role = HybridBackend.getCurrentRole(); + const aplica = HybridBackend.isAuthenticated() && !HybridBackend.isPreviewMode() && + role && role !== 'paciente' && role !== 'superadmin' && + ws?.id && ws?.tipo !== 'sala' && ws?.tipo !== 'laboratorio'; + if (!aplica) { setComOk(true); return; } + let cancel = false; + fetch(`${(window as any).__API_URL__ || ''}/api/comunicacao/me?clinicaId=${encodeURIComponent(ws.id)}`, + { headers: { Authorization: `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}` } }) + .then(r => (r.ok ? r.json() : { completo: true })) + .then(d => { if (!cancel) setComOk(d?.sem_clinica ? true : !!d?.completo); }) + .catch(() => { if (!cancel) setComOk(true); }); + return () => { cancel = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeWorkspace?.id, currentRole]); + // Roteamento pós-login/cadastro. const onAuthSuccess = (result?: { noWorkspace?: boolean; userRole?: string }) => { if (result?.noWorkspace) { @@ -591,6 +613,10 @@ const App: React.FC = () => { {HybridBackend.isAuthenticated() && HybridBackend.mustChangePassword() && ( forcePwRerender(t => t + 1)} /> )} + {comOk === false && !HybridBackend.mustChangePassword() && ( + { if (c) setComOk(true); }} onClose={() => { /* forçado: sem fechar */ }} /> + )}