feat(biomedico): catálogo próprio de biomedicina estética (CRUD + ativar/desativar)

- reverte bloqueio anterior: biomédico GERENCIA especialidades/procedimentos do seu
  consultório (workspace pessoal já criado no cadastro)
- acesso liberado p/ o papel biomédico (menu + permissão)
- coluna 'ativo' em especialidades/procedimentos + toggle ativar/desativar na UI
- auto-seed do catálogo de biomedicina estética (botox, preenchimento, bioestimulador,
  peeling, microagulhamento, etc.) no cadastro do biomédico + endpoint idempotente
  /api/me/seed-biomedicina (restrito ao papel biomédico) p/ contas existentes
- valor por procedimento já suportado (valorparticular)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-14 06:20:42 +02:00
parent a383fc7a29
commit 44d604daff
6 changed files with 121 additions and 24 deletions
+69 -15
View File
@@ -198,15 +198,6 @@ function authGuard(req, res, next) {
}
}
// Biomédicos não gerenciam procedimentos/especialidades (são conceitos odontológicos).
// Camada de backend além do menu/permissão do front. Fail-open só em erro de DB.
async function blockBiomedico(req, res, next) {
try {
const { rows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser?.userId]);
if (rows[0]?.role === 'biomedico') return res.status(403).json({ error: 'Biomédicos não podem gerenciar procedimentos e especialidades.' });
} catch (e) { console.error('[blockBiomedico]', e.message); }
next();
}
// =============================================================================
// AUDITORIA GENÉRICA + PENDÊNCIAS DE AGENDA (Fase 1)
@@ -358,6 +349,45 @@ async function pushGoogleEvent(action, ag) {
} catch (e) { console.error('[google-push]', e.message); }
}
// Catálogo padrão de Biomedicina Estética (especialidades + procedimentos com valor sugerido).
const BIOMED_ESPECIALIDADES = [
'BIOMEDICINA ESTÉTICA', 'HARMONIZAÇÃO OROFACIAL', 'ESTÉTICA FACIAL', 'ESTÉTICA CORPORAL', 'PROCEDIMENTOS INJETÁVEIS',
];
const BIOMED_PROCEDIMENTOS = [
{ nome: 'TOXINA BOTULÍNICA (BOTOX)', esp: 'PROCEDIMENTOS INJETÁVEIS', valor: 800 },
{ nome: 'PREENCHIMENTO COM ÁCIDO HIALURÔNICO', esp: 'PROCEDIMENTOS INJETÁVEIS', valor: 1200 },
{ nome: 'BIOESTIMULADOR DE COLÁGENO', esp: 'ESTÉTICA FACIAL', valor: 1500 },
{ nome: 'SKINBOOSTER', esp: 'ESTÉTICA FACIAL', valor: 900 },
{ nome: 'PEELING QUÍMICO', esp: 'ESTÉTICA FACIAL', valor: 300 },
{ nome: 'MICROAGULHAMENTO', esp: 'ESTÉTICA FACIAL', valor: 400 },
{ nome: 'LIMPEZA DE PELE', esp: 'ESTÉTICA FACIAL', valor: 150 },
{ nome: 'FIOS DE SUSTENTAÇÃO (PDO)', esp: 'HARMONIZAÇÃO OROFACIAL', valor: 2000 },
{ nome: 'HARMONIZAÇÃO FACIAL', esp: 'HARMONIZAÇÃO OROFACIAL', valor: 2500 },
{ nome: 'ESCLEROTERAPIA (VASINHOS)', esp: 'ESTÉTICA CORPORAL', valor: 350 },
{ nome: 'INTRADERMOTERAPIA / MESOTERAPIA', esp: 'ESTÉTICA CORPORAL', valor: 400 },
{ nome: 'LIPÓLISE ENZIMÁTICA', esp: 'ESTÉTICA CORPORAL', valor: 500 },
];
// Popula o catálogo de biomedicina de uma clínica/consultório (idempotente: só se vazio).
async function seedBiomedicina(clinicaId) {
if (!clinicaId) return false;
const { rows } = await pool.query('SELECT 1 FROM especialidades WHERE clinica_id = $1 LIMIT 1', [clinicaId]);
if (rows.length) return false;
const espId = {};
for (let i = 0; i < BIOMED_ESPECIALIDADES.length; i++) {
const id = `esp_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query('INSERT INTO especialidades (id, nome, clinica_id, ordem, ativo) VALUES ($1,$2,$3,$4,true)', [id, BIOMED_ESPECIALIDADES[i], clinicaId, i]);
espId[BIOMED_ESPECIALIDADES[i]] = id;
}
for (let i = 0; i < BIOMED_PROCEDIMENTOS.length; i++) {
const pr = BIOMED_PROCEDIMENTOS[i];
const id = `proc_${Date.now()}_${i}_${Math.random().toString(36).slice(2, 6)}`;
await pool.query(
'INSERT INTO procedimentos (id, nome, especialidadeid, especialidadenome, valorparticular, clinica_id, ordem, ativo) VALUES ($1,$2,$3,$4,$5,$6,$7,true)',
[id, pr.nome, espId[pr.esp] || null, pr.esp, pr.valor, clinicaId, i]);
}
return true;
}
// --- REGISTRO DE USUÁRIOS ---
app.post('/api/register', async (req, res) => {
const { nome, email, senha, role, workspaceNome, especialidade } = req.body;
@@ -396,6 +426,8 @@ app.post('/api/register', async (req, res) => {
[`v_${userId}_${wsId}`, userId, wsId, userRole]
);
workspaces = [{ id: wsId, nome: wsNome, cor: '#2563eb', role: userRole }];
// Biomédico já nasce com o catálogo de biomedicina estética no consultório dele.
if (userRole === 'biomedico') { try { await seedBiomedicina(wsId); } catch (e) { console.error('[seedBiomedicina]', e.message); } }
}
const token = jwt.sign({ userId, email: email.toLowerCase().trim() }, JWT_SECRET, { expiresIn: '12h' });
@@ -2608,7 +2640,7 @@ app.get('/api/especialidades', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/especialidades', authGuard, blockBiomedico, async (req, res) => {
app.post('/api/especialidades', authGuard, async (req, res) => {
try {
const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
const isSuperAdmin = userRows[0]?.role === 'superadmin';
@@ -2635,7 +2667,7 @@ app.put('/api/especialidades/reorder', async (req, res) => {
}
});
app.put('/api/especialidades/:id', authGuard, blockBiomedico, async (req, res) => {
app.put('/api/especialidades/:id', async (req, res) => {
try {
const upd = buildUpdate('especialidades', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
@@ -2643,7 +2675,7 @@ app.put('/api/especialidades/:id', authGuard, blockBiomedico, async (req, res) =
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/especialidades/:id', tenantGuard, blockBiomedico, async (req, res) => {
app.delete('/api/especialidades/:id', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
@@ -2724,7 +2756,7 @@ app.get('/api/procedimentos', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/procedimentos', authGuard, blockBiomedico, async (req, res) => {
app.post('/api/procedimentos', authGuard, async (req, res) => {
try {
const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
const isSuperAdmin = userRows[0]?.role === 'superadmin';
@@ -2782,7 +2814,7 @@ app.put('/api/procedimentos/reorder', async (req, res) => {
}
});
app.put('/api/procedimentos/:id', authGuard, blockBiomedico, async (req, res) => {
app.put('/api/procedimentos/:id', async (req, res) => {
try {
const { valoresPlanos, ...proc } = req.body;
const updateData = { ...proc, exige_face: proc.exige_face ? 1 : 0 };
@@ -2804,7 +2836,7 @@ app.put('/api/procedimentos/:id', authGuard, blockBiomedico, async (req, res) =>
}
});
app.delete('/api/procedimentos/:id', tenantGuard, blockBiomedico, async (req, res) => {
app.delete('/api/procedimentos/:id', tenantGuard, async (req, res) => {
try {
const clinicaId = req.clinicaId;
if (!clinicaId) return res.status(400).json({ error: 'clinicaId obrigatório.' });
@@ -4023,6 +4055,8 @@ async function runMigrations() {
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS logradouro TEXT`,
`ALTER TABLE usuarios ADD COLUMN IF NOT EXISTS numero TEXT`,
`ALTER TABLE especialidades ADD COLUMN IF NOT EXISTS ativo BOOLEAN DEFAULT true`,
`ALTER TABLE procedimentos ADD COLUMN IF NOT EXISTS ativo BOOLEAN DEFAULT true`,
`ALTER TABLE clinicas ADD COLUMN IF NOT EXISTS geo geography(Point,4326)`,
`CREATE INDEX IF NOT EXISTS idx_salas_geo ON salas USING GIST (geo)`,
`CREATE INDEX IF NOT EXISTS idx_usuarios_geo ON usuarios USING GIST (geo)`,
@@ -4182,6 +4216,26 @@ app.post('/api/me/plugins', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Semeia o catálogo de biomedicina estética na clínica/consultório do usuário (idempotente).
// Usado no 1º acesso do biomédico ao catálogo (cobre contas criadas antes do auto-seed).
app.post('/api/me/seed-biomedicina', authGuard, async (req, res) => {
try {
const clinicaId = String(req.body?.clinicaId || req.query.clinicaId || '').trim();
if (!clinicaId) return res.status(400).json({ error: 'clinicaId é obrigatório.' });
// só BIOMÉDICO semeia (evita poluir clínica odonto vazia com catálogo de biomedicina)
const { rows: u } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
if (u[0]?.role !== 'biomedico') return res.status(403).json({ error: 'Apenas biomédicos têm o catálogo de biomedicina.' });
// e precisa ser dono/vinculado da unidade
const { rows } = await pool.query(
`SELECT 1 FROM clinicas WHERE id = $1 AND owner_id = $2
UNION SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1`,
[clinicaId, req.authUser.userId]);
if (!rows.length) return res.status(403).json({ error: 'Sem acesso a esta unidade.' });
const seeded = await seedBiomedicina(clinicaId);
res.json({ success: true, seeded });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── Geocoding de CEP: cache + rotação de provedores grátis ──────────────────
// Economia: cada CEP é geocodado UMA vez (cache cep_geo). Rotação robusta p/ o
// egress deste servidor: AwesomeAPI → Nominatim(CEP) → ViaCEP+Nominatim(endereço).