feat(areas): fundação multi-área (Odontologia/Biomedicina/Prótese) — Fase 1

- tabela referência 'areas' (slug, nome, cor, ícone) seedada com 3 áreas
- coluna 'area' em especialidades e procedimentos + backfill idempotente
- junções multi-área: clinica_areas e usuario_areas (backfill por papel/catálogo)
- POST especialidades/procedimentos default de área pelo papel (roleToArea) + marca clinica_areas
- seedBiomedicina grava area='biomedicina'; GET /api/areas
- Fase 2 (próxima): seletor/badge de área no catálogo, filtro por área no marketplace, UI multi-área de perfil/clínica

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-14 06:40:53 +02:00
parent 44d604daff
commit 123234895d
+51 -3
View File
@@ -349,6 +349,13 @@ async function pushGoogleEvent(action, ag) {
} catch (e) { console.error('[google-push]', e.message); }
}
// Área padrão a partir do papel do usuário (default ao criar especialidade/procedimento).
function roleToArea(role) {
if (role === 'biomedico') return 'biomedicina';
if (role === 'protetico') return 'protese';
return 'odontologia';
}
// 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',
@@ -375,16 +382,18 @@ async function seedBiomedicina(clinicaId) {
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]);
await pool.query("INSERT INTO especialidades (id, nome, clinica_id, ordem, ativo, area) VALUES ($1,$2,$3,$4,true,'biomedicina')", [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)',
"INSERT INTO procedimentos (id, nome, especialidadeid, especialidadenome, valorparticular, clinica_id, ordem, ativo, area) VALUES ($1,$2,$3,$4,$5,$6,$7,true,'biomedicina')",
[id, pr.nome, espId[pr.esp] || null, pr.esp, pr.valor, clinicaId, i]);
}
// marca a clínica como área de biomedicina
await pool.query("INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,'biomedicina') ON CONFLICT DO NOTHING", [clinicaId]);
return true;
}
@@ -2645,10 +2654,12 @@ app.post('/api/especialidades', authGuard, async (req, res) => {
const { rows: userRows } = await pool.query('SELECT role FROM usuarios WHERE id = $1', [req.authUser.userId]);
const isSuperAdmin = userRows[0]?.role === 'superadmin';
const clinicaId = isSuperAdmin ? null : (req.body.clinica_id || req.query.clinicaId || null);
const area = req.body.area || roleToArea(userRows[0]?.role);
const { rows: counts } = await pool.query('SELECT COUNT(*) as count FROM especialidades');
const ordem = parseInt(counts[0].count);
const ins = buildInsert('especialidades', { ...req.body, clinica_id: clinicaId, ordem });
const ins = buildInsert('especialidades', { ...req.body, clinica_id: clinicaId, ordem, area });
await pool.query(ins.text, ins.values);
if (clinicaId && area) await pool.query('INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,$2) ON CONFLICT DO NOTHING', [clinicaId, area]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -2779,10 +2790,12 @@ app.post('/api/procedimentos', authGuard, async (req, res) => {
clinica_id: clinicaId,
codigoInterno: codigoInternoVal,
ordem: ordemValue,
area: proc.area || roleToArea(userRows[0]?.role),
tipo_regiao: proc.tipo_regiao || 'GERAL',
exige_face: proc.exige_face ? 1 : 0
};
if (!payload.id) payload.id = Math.random().toString(36).substring(2, 10).toUpperCase();
if (clinicaId && payload.area) await pool.query('INSERT INTO clinica_areas (clinica_id, area) VALUES ($1,$2) ON CONFLICT DO NOTHING', [clinicaId, payload.area]);
await withTransaction(async (client) => {
const ins = buildInsert('procedimentos', payload);
@@ -4057,6 +4070,33 @@ async function runMigrations() {
`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`,
// ── Áreas de atuação (Odontologia / Biomedicina / Prótese) ──────────
`CREATE TABLE IF NOT EXISTS areas (
slug TEXT PRIMARY KEY, nome TEXT NOT NULL, cor TEXT, icone TEXT, ordem INT DEFAULT 0
)`,
`INSERT INTO areas (slug, nome, cor, icone, ordem) VALUES
('odontologia','Odontologia','#2563eb','Stethoscope',1),
('biomedicina','Biomedicina Estética','#db2777','Sparkles',2),
('protese','Prótese','#7c3aed','FlaskConical',3)
ON CONFLICT (slug) DO NOTHING`,
`ALTER TABLE especialidades ADD COLUMN IF NOT EXISTS area TEXT`,
`ALTER TABLE procedimentos ADD COLUMN IF NOT EXISTS area TEXT`,
// Multi-área por clínica e por usuário (uma unidade pode ter odonto + biomedicina).
`CREATE TABLE IF NOT EXISTS clinica_areas (clinica_id TEXT NOT NULL, area TEXT NOT NULL, PRIMARY KEY (clinica_id, area))`,
`CREATE TABLE IF NOT EXISTS usuario_areas (usuario_id TEXT NOT NULL, area TEXT NOT NULL, PRIMARY KEY (usuario_id, area))`,
// Backfill idempotente do catálogo (só onde area ainda é NULL).
`UPDATE especialidades SET area = 'biomedicina' WHERE area IS NULL AND nome IN ('BIOMEDICINA ESTÉTICA','ESTÉTICA FACIAL','ESTÉTICA CORPORAL','PROCEDIMENTOS INJETÁVEIS')`,
`UPDATE especialidades SET area = 'odontologia' WHERE area IS NULL`,
`UPDATE procedimentos SET area = 'biomedicina' WHERE area IS NULL AND nome IN ('TOXINA BOTULÍNICA (BOTOX)','PREENCHIMENTO COM ÁCIDO HIALURÔNICO','BIOESTIMULADOR DE COLÁGENO','SKINBOOSTER','PEELING QUÍMICO','MICROAGULHAMENTO','LIMPEZA DE PELE','FIOS DE SUSTENTAÇÃO (PDO)','HARMONIZAÇÃO FACIAL','ESCLEROTERAPIA (VASINHOS)','INTRADERMOTERAPIA / MESOTERAPIA','LIPÓLISE ENZIMÁTICA')`,
`UPDATE procedimentos SET area = 'odontologia' WHERE area IS NULL`,
// Backfill das junções: usuário pela role; clínica pelas áreas do catálogo.
`INSERT INTO usuario_areas (usuario_id, area)
SELECT id, CASE role WHEN 'biomedico' THEN 'biomedicina' WHEN 'protetico' THEN 'protese' ELSE 'odontologia' END
FROM usuarios WHERE role IN ('dentista','biomedico','protetico','donoclinica','donoconsultorio','funcionario','admin')
ON CONFLICT DO NOTHING`,
`INSERT INTO clinica_areas (clinica_id, area)
SELECT DISTINCT clinica_id, area FROM especialidades WHERE clinica_id IS NOT NULL AND area IS NOT NULL
ON CONFLICT DO NOTHING`,
`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)`,
@@ -4216,6 +4256,14 @@ app.post('/api/me/plugins', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// Lista as áreas de atuação (referência).
app.get('/api/areas', authGuard, async (req, res) => {
try {
const { rows } = await pool.query('SELECT slug, nome, cor, icone, ordem FROM areas ORDER BY ordem');
res.json(rows);
} 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) => {