Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,173 +3,124 @@
|
||||
// AgenciaPsi — Dashboard Generator
|
||||
// =============================================================================
|
||||
// Uso:
|
||||
// node generate-dashboard.js → usa backup mais recente
|
||||
// node generate-dashboard.js 2026-03-27 → usa backup de data específica
|
||||
// node generate-dashboard.cjs → usa backup mais recente
|
||||
// node generate-dashboard.cjs 2026-04-17 → usa backup de data específica
|
||||
//
|
||||
// Lê de: ./database-novo/backups/YYYY-MM-DD/schema.sql
|
||||
// Gera: ./dashboard.html (na mesma pasta do script)
|
||||
// Lê de: ./backups/YYYY-MM-DD/schema.sql
|
||||
// Lê de: ./db.config.json (domínios, cores e infraestrutura)
|
||||
// Gera: ./agenciapsi-db-dashboard.html (na mesma pasta do script)
|
||||
// =============================================================================
|
||||
|
||||
const fs = require('fs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const BACKUPS_DIR = path.join(__dirname, 'backups');
|
||||
const OUTPUT_FILE = path.join(__dirname, 'dashboard.html');
|
||||
const ROOT = __dirname;
|
||||
const BACKUPS_DIR = path.join(ROOT, 'backups');
|
||||
const OUTPUT_FILE = path.join(ROOT, 'agenciapsi-db-dashboard.html');
|
||||
const CONFIG_FILE = path.join(ROOT, 'db.config.json');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cores por domínio
|
||||
// Carrega config (domínios, cores e infraestrutura)
|
||||
// ---------------------------------------------------------------------------
|
||||
const DOMAIN_COLORS = {
|
||||
'SaaS / Planos': '#4f8cff',
|
||||
'Tenants / Multi-tenant': '#6ee7b7',
|
||||
'Pacientes': '#f472b6',
|
||||
'Agenda': '#fb923c',
|
||||
'Financeiro': '#a78bfa',
|
||||
'Serviços / Commitments': '#34d399',
|
||||
'Notificações': '#38bdf8',
|
||||
'Email / Comunicação': '#fbbf24',
|
||||
'SaaS Admin': '#94a3b8',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mapeamento domínio → tabelas
|
||||
// Adicione novas tabelas aqui quando criar migrations
|
||||
// ---------------------------------------------------------------------------
|
||||
const DOMAIN_TABLES = {
|
||||
'SaaS / Planos': [
|
||||
'plans','plan_features','plan_prices','plan_public','plan_public_bullets',
|
||||
'features','modules','module_features','subscriptions','subscription_events',
|
||||
'subscription_intents_personal','subscription_intents_tenant','subscription_intents_legacy',
|
||||
'addon_products','addon_credits','addon_transactions',
|
||||
'tenant_features','tenant_modules','entitlements_invalidation','tenant_feature_exceptions_log',
|
||||
],
|
||||
'Tenants / Multi-tenant': [
|
||||
'tenants','tenant_members','tenant_invites','owner_users',
|
||||
'profiles','company_profiles','billing_contracts','payment_settings','support_sessions',
|
||||
],
|
||||
'Pacientes': [
|
||||
'patients','patient_groups','patient_group_patient','patient_tags',
|
||||
'patient_patient_tag','patient_discounts','patient_invites','patient_intake_requests',
|
||||
],
|
||||
'Agenda': [
|
||||
'agenda_eventos','agenda_configuracoes','agenda_bloqueios','agenda_excecoes',
|
||||
'agenda_online_slots','agenda_regras_semanais','agenda_slots_bloqueados_semanais',
|
||||
'agenda_slots_regras','agendador_configuracoes','agendador_solicitacoes',
|
||||
'feriados','recurrence_rules','recurrence_exceptions','recurrence_rule_services',
|
||||
],
|
||||
'Financeiro': [
|
||||
'financial_records','financial_categories','financial_exceptions',
|
||||
'therapist_payouts','therapist_payout_records',
|
||||
'insurance_plans','insurance_plan_services','professional_pricing',
|
||||
],
|
||||
'Serviços / Commitments': [
|
||||
'services','determined_commitments','determined_commitment_fields',
|
||||
'commitment_services','commitment_time_logs',
|
||||
],
|
||||
'Notificações': [
|
||||
'notifications','notification_channels','notification_templates',
|
||||
'notification_queue','notification_logs','notification_preferences',
|
||||
'notification_schedules','twilio_subaccount_usage',
|
||||
],
|
||||
'Email / Comunicação': [
|
||||
'email_templates_global','email_templates_tenant','email_layout_config',
|
||||
'global_notices','notice_dismissals','login_carousel_slides',
|
||||
],
|
||||
'SaaS Admin': [
|
||||
'saas_admins','saas_docs','saas_doc_votos','saas_faq','saas_faq_itens',
|
||||
'user_settings','dev_user_credentials','_db_migrations',
|
||||
],
|
||||
};
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
console.error(`✖ Config não encontrada: ${CONFIG_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const CONFIG = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
const DOMAIN_TABLES = CONFIG.domains || {};
|
||||
const DOMAIN_COLORS = CONFIG.domainColors || {};
|
||||
const INFRASTRUCTURE = CONFIG.infrastructure || {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Resolve qual schema.sql usar
|
||||
// ---------------------------------------------------------------------------
|
||||
function resolveSchema() {
|
||||
const arg = process.argv[2];
|
||||
const arg = process.argv[2];
|
||||
|
||||
if (!fs.existsSync(BACKUPS_DIR)) {
|
||||
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
|
||||
console.error(` Certifique-se que o script está na raiz do projeto.`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(BACKUPS_DIR)) {
|
||||
console.error(`✖ Pasta não encontrada: ${BACKUPS_DIR}`);
|
||||
console.error(` Rode primeiro: node db.cjs backup`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const available = fs.readdirSync(BACKUPS_DIR)
|
||||
.filter(f => /^\d{4}-\d{2}-\d{2}$/.test(f))
|
||||
.sort()
|
||||
.reverse();
|
||||
const available = fs
|
||||
.readdirSync(BACKUPS_DIR)
|
||||
.filter((f) => /^\d{4}-\d{2}-\d{2}$/.test(f))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
if (available.length === 0) {
|
||||
console.error('✖ Nenhum backup encontrado em database-novo/backups/');
|
||||
console.error(' Rode primeiro: node db.cjs backup');
|
||||
process.exit(1);
|
||||
}
|
||||
if (available.length === 0) {
|
||||
console.error('✖ Nenhum backup encontrado em database-novo/backups/');
|
||||
console.error(' Rode primeiro: node db.cjs backup');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const date = (arg && /^\d{4}-\d{2}-\d{2}$/.test(arg)) ? arg : available[0];
|
||||
const date = arg && /^\d{4}-\d{2}-\d{2}$/.test(arg) ? arg : available[0];
|
||||
|
||||
if (!available.includes(date)) {
|
||||
console.error(`✖ Backup não encontrado para: ${date}`);
|
||||
console.error(` Disponíveis: ${available.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!available.includes(date)) {
|
||||
console.error(`✖ Backup não encontrado para: ${date}`);
|
||||
console.error(` Disponíveis: ${available.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const schemaPath = path.join(BACKUPS_DIR, date, 'schema.sql');
|
||||
if (!fs.existsSync(schemaPath)) {
|
||||
console.error(`✖ schema.sql não encontrado em database-novo/backups/${date}/`);
|
||||
process.exit(1);
|
||||
}
|
||||
const schemaPath = path.join(BACKUPS_DIR, date, 'schema.sql');
|
||||
if (!fs.existsSync(schemaPath)) {
|
||||
console.error(`✖ schema.sql não encontrado em backups/${date}/`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { schemaPath, date, available };
|
||||
return { schemaPath, date, available };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Parse do schema.sql — extrai tabelas, colunas e FKs
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseSchema(content) {
|
||||
const tables = {};
|
||||
const tables = {};
|
||||
|
||||
// Tabelas public.*
|
||||
const tableRe = /CREATE TABLE (public\.\S+)\s*\(([\s\S]*?)\);/gm;
|
||||
let m;
|
||||
while ((m = tableRe.exec(content)) !== null) {
|
||||
const name = m[1].replace('public.', '');
|
||||
const body = m[2];
|
||||
const columns = [];
|
||||
// Tabelas public.*
|
||||
const tableRe = /CREATE TABLE (public\.\S+)\s*\(([\s\S]*?)\);/gm;
|
||||
let m;
|
||||
while ((m = tableRe.exec(content)) !== null) {
|
||||
const name = m[1].replace('public.', '');
|
||||
const body = m[2];
|
||||
const columns = [];
|
||||
|
||||
for (let line of body.split('\n')) {
|
||||
line = line.trim().replace(/,$/, '');
|
||||
if (!line || line.startsWith('--')) continue;
|
||||
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY|EXCLUDE)/i.test(line)) continue;
|
||||
for (let line of body.split('\n')) {
|
||||
line = line.trim().replace(/,$/, '');
|
||||
if (!line || line.startsWith('--')) continue;
|
||||
if (/^(CONSTRAINT|PRIMARY KEY|UNIQUE|CHECK|FOREIGN KEY|EXCLUDE)/i.test(line)) continue;
|
||||
|
||||
const col = line.match(
|
||||
/^(\w+)\s+([\w\[\]"()\s,]+?)(?:\s+DEFAULT\s+|\s+NOT NULL|\s+NULL|\s+GENERATED|\s+REFERENCES\s|$)/
|
||||
);
|
||||
if (col) {
|
||||
columns.push({
|
||||
name: col[1],
|
||||
type: col[2].trim().split('(')[0].trim(),
|
||||
pk: col[1] === 'id',
|
||||
});
|
||||
}
|
||||
const col = line.match(
|
||||
/^(\w+)\s+([\w\[\]"()\s,]+?)(?:\s+DEFAULT\s+|\s+NOT NULL|\s+NULL|\s+GENERATED|\s+REFERENCES\s|$)/
|
||||
);
|
||||
if (col) {
|
||||
columns.push({
|
||||
name: col[1],
|
||||
type: col[2].trim().split('(')[0].trim(),
|
||||
pk: col[1] === 'id'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tables[name] = { columns, fks: [] };
|
||||
}
|
||||
|
||||
tables[name] = { columns, fks: [] };
|
||||
}
|
||||
|
||||
// FKs via ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY
|
||||
const fkRe = /ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \S+ FOREIGN KEY \((\w+)\) REFERENCES public\.(\w+)\((\w+)\)/gm;
|
||||
while ((m = fkRe.exec(content)) !== null) {
|
||||
const [, fromTable, fromCol, toTable, toCol] = m;
|
||||
if (tables[fromTable]) {
|
||||
tables[fromTable].fks.push({ from_col: fromCol, to_table: toTable, to_col: toCol });
|
||||
// FKs via ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY
|
||||
const fkRe = /ALTER TABLE ONLY public\.(\w+)\s+ADD CONSTRAINT \S+ FOREIGN KEY \((\w+)\) REFERENCES public\.(\w+)\((\w+)\)/gm;
|
||||
while ((m = fkRe.exec(content)) !== null) {
|
||||
const [, fromTable, fromCol, toTable, toCol] = m;
|
||||
if (tables[fromTable]) {
|
||||
tables[fromTable].fks.push({ from_col: fromCol, to_table: toTable, to_col: toCol });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Views
|
||||
const viewRe = /CREATE(?:\s+OR REPLACE)?\s+VIEW\s+public\.(\S+)\s+AS/gm;
|
||||
const views = [];
|
||||
while ((m = viewRe.exec(content)) !== null) views.push(m[1]);
|
||||
// Views
|
||||
const viewRe = /CREATE(?:\s+OR REPLACE)?\s+VIEW\s+public\.(\S+)\s+AS/gm;
|
||||
const views = [];
|
||||
while ((m = viewRe.exec(content)) !== null) views.push(m[1]);
|
||||
|
||||
return { tables, views };
|
||||
return { tables, views };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -177,35 +128,43 @@ function parseSchema(content) {
|
||||
// Tabelas novas que ainda não estão mapeadas vão para "Outros"
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildDomains(tables) {
|
||||
const mapped = new Set(Object.values(DOMAIN_TABLES).flat());
|
||||
const others = Object.keys(tables).filter(t => !mapped.has(t));
|
||||
const mapped = new Set(Object.values(DOMAIN_TABLES).flat());
|
||||
const others = Object.keys(tables).filter((t) => !mapped.has(t) && t !== '_db_migrations');
|
||||
|
||||
const domains = {};
|
||||
for (const [domain, list] of Object.entries(DOMAIN_TABLES)) {
|
||||
const present = list.filter(t => tables[t]);
|
||||
if (present.length > 0) domains[domain] = present;
|
||||
}
|
||||
if (others.length > 0) {
|
||||
domains['Outros'] = others;
|
||||
DOMAIN_COLORS['Outros'] = '#6b7280';
|
||||
}
|
||||
const domains = {};
|
||||
for (const [domain, list] of Object.entries(DOMAIN_TABLES)) {
|
||||
const present = list.filter((t) => tables[t]);
|
||||
if (present.length > 0) domains[domain] = present;
|
||||
}
|
||||
if (others.length > 0) {
|
||||
domains['Outros'] = others;
|
||||
DOMAIN_COLORS['Outros'] = '#6b7280';
|
||||
}
|
||||
|
||||
return domains;
|
||||
return domains;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Gera o HTML final (standalone, sem dependências externas de JS)
|
||||
// ---------------------------------------------------------------------------
|
||||
function generateHTML(tables, views, domains, date, available) {
|
||||
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
|
||||
const totalCols = Object.values(tables).reduce((a, t) => a + t.columns.length, 0);
|
||||
const generated = new Date().toLocaleString('pt-BR');
|
||||
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
|
||||
const totalCols = Object.values(tables).reduce((a, t) => a + t.columns.length, 0);
|
||||
const infraGroups = Object.keys(INFRASTRUCTURE).length;
|
||||
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
|
||||
const generated = new Date().toLocaleString('pt-BR');
|
||||
|
||||
// Serializa dados para embutir no HTML
|
||||
const jsonData = JSON.stringify({ tables, views, domains });
|
||||
const jsonColors = JSON.stringify(DOMAIN_COLORS);
|
||||
// Slug por domínio — usado como id para scroll (ex: "SaaS / Planos" → "saas-planos")
|
||||
const slugify = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const domainSlugs = {};
|
||||
for (const d of Object.keys(domains)) domainSlugs[d] = slugify(d);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
// Serializa dados para embutir no HTML
|
||||
const jsonData = JSON.stringify({ tables, views, domains, slugs: domainSlugs });
|
||||
const jsonColors = JSON.stringify(DOMAIN_COLORS);
|
||||
const jsonInfra = JSON.stringify(INFRASTRUCTURE);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -213,7 +172,7 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
<title>AgenciaPsi DB · ${date}</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
|
||||
:root{--bg:#0b0d12;--bg2:#111520;--bg3:#181e2d;--border:#1e2740;--border2:#263050;--text:#e2e8f8;--text2:#7d8fb3;--text3:#4a5a80;--accent:#4f8cff;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6}
|
||||
:root{--bg:#0b0d12;--bg2:#111520;--bg3:#181e2d;--border:#1e2740;--border2:#263050;--text:#e2e8f8;--text2:#7d8fb3;--text3:#4a5a80;--accent:#6366f1;--accent2:#6ee7b7;--pk:#fbbf24;--fk:#f472b6;--ok:#34d399;--warn:#fbbf24;--pend:#f87171;--leg:#94a3b8}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:var(--bg);color:var(--text);font-family:'Space Grotesk',sans-serif;min-height:100vh;overflow-x:hidden}
|
||||
|
||||
@@ -229,12 +188,12 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
|
||||
.layout{display:flex;height:calc(100vh - 56px)}
|
||||
|
||||
.sidebar{width:240px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
|
||||
.sidebar{width:260px;flex-shrink:0;background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:16px 0}
|
||||
.sidebar::-webkit-scrollbar{width:4px}.sidebar::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
|
||||
.sb-h{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:8px 20px 4px}
|
||||
.sb-i{display:flex;align-items:center;gap:10px;padding:7px 20px;cursor:pointer;font-size:13px;color:var(--text2);border-left:2px solid transparent;transition:all .15s;user-select:none}
|
||||
.sb-i:hover{color:var(--text);background:var(--bg3)}
|
||||
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(79,140,255,.08)}
|
||||
.sb-i.active{color:var(--text);border-left-color:var(--accent);background:rgba(99,102,241,.08)}
|
||||
.sb-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||||
.sb-c{margin-left:auto;font-size:11px;color:var(--text3);font-family:'IBM Plex Mono',monospace}
|
||||
|
||||
@@ -282,7 +241,27 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
.vgrid{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
||||
.vc{background:rgba(110,231,183,.08);border:1px solid rgba(110,231,183,.2);border-radius:6px;padding:5px 12px;font-size:12px;font-family:'IBM Plex Mono',monospace;color:var(--accent2)}
|
||||
.empty{padding:40px;text-align:center;color:var(--text3);font-size:14px}
|
||||
mark{background:rgba(79,140,255,.3);color:#fff;border-radius:2px}
|
||||
mark{background:rgba(99,102,241,.3);color:#fff;border-radius:2px}
|
||||
|
||||
/* Infraestrutura */
|
||||
.igroup{margin-bottom:28px}
|
||||
.igroup-h{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
||||
.igroup-t{font-size:15px;font-weight:600;letter-spacing:-.2px}
|
||||
.igroup-c{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
||||
.igrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
|
||||
.ic{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:16px 18px;transition:border-color .15s;position:relative;overflow:hidden}
|
||||
.ic::before{content:'';position:absolute;top:0;left:0;bottom:0;width:3px;background:var(--c)}
|
||||
.ic:hover{border-color:var(--border2)}
|
||||
.ic-h{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
||||
.ic-n{font-size:14px;font-weight:600;flex:1;min-width:0}
|
||||
.ic-st{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;padding:2px 7px;border-radius:10px;flex-shrink:0;white-space:nowrap}
|
||||
.ic-st.ativo{background:rgba(52,211,153,.15);color:var(--ok)}
|
||||
.ic-st.pendente{background:rgba(248,113,113,.15);color:var(--pend)}
|
||||
.ic-st.planejado{background:rgba(251,191,36,.15);color:var(--warn)}
|
||||
.ic-st.legado{background:rgba(148,163,184,.2);color:var(--leg)}
|
||||
.ic-r{font-size:12px;color:var(--text2);margin-bottom:8px;line-height:1.5}
|
||||
.ic-e{font-size:10px;color:var(--text3);font-family:'IBM Plex Mono',monospace;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}
|
||||
.ic-nt{font-size:11px;color:var(--text3);line-height:1.55;border-top:1px solid var(--border);padding-top:8px;margin-top:8px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -295,6 +274,7 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
<div class="pill"><strong>${totalFKs}</strong> FKs</div>
|
||||
<div class="pill"><strong>${views.length}</strong> views</div>
|
||||
<div class="pill"><strong>${totalCols}</strong> colunas</div>
|
||||
<div class="pill"><strong>${infraItems}</strong> infra</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout">
|
||||
@@ -304,24 +284,36 @@ function generateHTML(tables, views, domains, date, available) {
|
||||
<script>
|
||||
const D=${jsonData};
|
||||
const C=${jsonColors};
|
||||
const INFRA=${jsonInfra};
|
||||
const INFRA_GROUPS=${infraGroups};
|
||||
const INFRA_ITEMS=${infraItems};
|
||||
const T2D={};
|
||||
Object.entries(D.domains).forEach(([d,ts])=>ts.forEach(t=>T2D[t]=d));
|
||||
let dom=null,q='';
|
||||
let dom=null,view='overview',q='';
|
||||
function gc(d){return C[d]||'#6b7280';}
|
||||
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));}
|
||||
|
||||
function buildSB(){
|
||||
let h=\`<div class="sb-h">Visão Geral</div>
|
||||
<div class="sb-i \${!dom?'active':''}" onclick="sel(null)">
|
||||
<div class="sb-dot" style="background:#4f8cff"></div>Todos
|
||||
<div class="sb-i \${view==='overview'&&!dom?'active':''}" onclick="selOverview()">
|
||||
<div class="sb-dot" style="background:#6366f1"></div>Todos (tabelas)
|
||||
<span class="sb-c">\${Object.keys(D.tables).length}</span>
|
||||
</div>
|
||||
<div class="sb-i \${view==='infra'?'active':''}" onclick="selInfra()">
|
||||
<div class="sb-dot" style="background:#fbbf24"></div>Infraestrutura
|
||||
<span class="sb-c">\${INFRA_ITEMS}</span>
|
||||
</div>
|
||||
<div class="sb-h" style="margin-top:8px">Domínios</div>\`;
|
||||
for(const[d,ts]of Object.entries(D.domains)){
|
||||
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="sel(\`+JSON.stringify(d)+\`)">
|
||||
<div class="sb-dot" style="background:\${gc(d)}"></div>\${d}
|
||||
h+=\`<div class="sb-i \${dom===d?'active':''}" onclick="scrollToDomain(\`+JSON.stringify(d)+\`)">
|
||||
<div class="sb-dot" style="background:\${gc(d)}"></div>\${escapeHtml(d)}
|
||||
<span class="sb-c">\${ts.length}</span>
|
||||
</div>\`;
|
||||
}
|
||||
h+=\`<div class="sb-i" onclick="scrollToViews()">
|
||||
<div class="sb-dot" style="background:#6ee7b7"></div>Views
|
||||
<span class="sb-c">\${D.views.length}</span>
|
||||
</div>\`;
|
||||
document.getElementById('sb').innerHTML=h;
|
||||
}
|
||||
|
||||
@@ -330,9 +322,25 @@ function buildMN(){
|
||||
let h='';
|
||||
if(q){
|
||||
const matches=Object.entries(D.tables).filter(([n,t])=>n.includes(q)||t.columns.some(c=>c.name.includes(q)));
|
||||
h+=\`<div class="section"><div class="sec-h"><div class="sec-t">"\${q}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
|
||||
h+=\`<div class="section"><div class="sec-h"><div class="sec-t">"\${escapeHtml(q)}"</div><div class="sec-b">\${matches.length} tabelas</div></div><div class="tgrid">\`;
|
||||
h+=matches.length?matches.map(([n,t])=>card(n,t,q)).join(''):'<div class="empty">Nenhum resultado.</div>';
|
||||
h+='</div></div>';
|
||||
} else if(view==='infra'){
|
||||
h+=\`<div class="overview"><div class="ov-t">Infraestrutura</div>
|
||||
<div class="ov-s">Serviços, bibliotecas e ferramentas que o sistema usa · \${INFRA_GROUPS} grupos · \${INFRA_ITEMS} itens</div></div>
|
||||
<div class="section">\`;
|
||||
for(const[grupo,info]of Object.entries(INFRA)){
|
||||
const color=info.color||'#6b7280';
|
||||
h+=\`<div class="igroup">
|
||||
<div class="igroup-h">
|
||||
<div class="igroup-c" style="background:\${color}"></div>
|
||||
<div class="igroup-t" style="color:\${color}">\${escapeHtml(grupo)}</div>
|
||||
<div class="sec-b">\${info.items.length} itens</div>
|
||||
</div>
|
||||
<div class="igrid">\${info.items.map(item=>infraCard(item,color)).join('')}</div>
|
||||
</div>\`;
|
||||
}
|
||||
h+='</div>';
|
||||
} else {
|
||||
const ds=dom?{[dom]:D.domains[dom]}:D.domains;
|
||||
if(!dom){
|
||||
@@ -341,23 +349,23 @@ function buildMN(){
|
||||
<div class="dgrid">\`;
|
||||
for(const[d,ts]of Object.entries(D.domains)){
|
||||
const fks=ts.reduce((a,t)=>a+(D.tables[t]?.fks?.length||0),0);
|
||||
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="sel(\`+JSON.stringify(d)+\`)">
|
||||
<div class="dc-n">\${d}</div>
|
||||
h+=\`<div class="dc" style="--c:\${gc(d)}" onclick="scrollToDomain(\`+JSON.stringify(d)+\`)">
|
||||
<div class="dc-n">\${escapeHtml(d)}</div>
|
||||
<div class="dc-m"><span style="color:\${gc(d)}">\${ts.length}</span> tabelas · \${fks} FKs</div>
|
||||
</div>\`;
|
||||
}
|
||||
h+='</div></div>';
|
||||
}
|
||||
for(const[d,ts]of Object.entries(ds)){
|
||||
h+=\`<div class="section"><div class="sec-h">
|
||||
<div class="sec-t" style="color:\${gc(d)}">\${d}</div>
|
||||
h+=\`<div class="section" id="dom-\${D.slugs[d]||''}"><div class="sec-h">
|
||||
<div class="sec-t" style="color:\${gc(d)}">\${escapeHtml(d)}</div>
|
||||
<div class="sec-b">\${ts.length} tabelas</div>
|
||||
</div><div class="tgrid">\`;
|
||||
ts.forEach(n=>{if(D.tables[n])h+=card(n,D.tables[n],'');});
|
||||
h+='</div></div>';
|
||||
}
|
||||
if(!dom){
|
||||
h+=\`<div class="vsec"><div class="sec-h">
|
||||
h+=\`<div class="vsec" id="dom-views"><div class="sec-h">
|
||||
<div class="sec-t" style="color:#6ee7b7">Views</div>
|
||||
<div class="sec-b">\${D.views.length}</div>
|
||||
</div><div class="vgrid">\${D.views.map(v=>\`<div class="vc">\${v}</div>\`).join('')}</div></div>\`;
|
||||
@@ -366,6 +374,19 @@ function buildMN(){
|
||||
mn.innerHTML=h;
|
||||
}
|
||||
|
||||
function infraCard(item,color){
|
||||
const status=(item.status||'ativo').toLowerCase();
|
||||
return \`<div class="ic" style="--c:\${color}">
|
||||
<div class="ic-h">
|
||||
<div class="ic-n">\${escapeHtml(item.name)}</div>
|
||||
<div class="ic-st \${status}">\${escapeHtml(item.status||'ativo')}</div>
|
||||
</div>
|
||||
<div class="ic-r">\${escapeHtml(item.role||'')}</div>
|
||||
\${item.env?\`<div class="ic-e">\${escapeHtml(item.env)}</div>\`:''}
|
||||
\${item.notes?\`<div class="ic-nt">\${escapeHtml(item.notes)}</div>\`:''}
|
||||
</div>\`;
|
||||
}
|
||||
|
||||
function card(name,t,hl){
|
||||
const fkCols=new Set(t.fks.map(f=>f.from_col));
|
||||
const c=gc(T2D[name]);
|
||||
@@ -396,11 +417,44 @@ function tog(n){
|
||||
document.getElementById('tg-'+n)?.classList.toggle('open');
|
||||
}
|
||||
function sel(d){
|
||||
dom=d;q='';document.getElementById('si').value='';
|
||||
dom=d;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||
}
|
||||
function scrollToDomain(d){
|
||||
// Sempre ir pra overview (com todos os domínios visíveis) antes de scrollar
|
||||
const needRebuild=view!=='overview'||dom!==null||q;
|
||||
if(needRebuild){
|
||||
dom=null;view='overview';q='';
|
||||
document.getElementById('si').value='';
|
||||
buildSB();buildMN();
|
||||
}
|
||||
setTimeout(()=>{
|
||||
const el=document.getElementById('dom-'+(D.slugs[d]||''));
|
||||
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}, needRebuild?80:0);
|
||||
}
|
||||
function scrollToViews(){
|
||||
const needRebuild=view!=='overview'||dom!==null||q;
|
||||
if(needRebuild){
|
||||
dom=null;view='overview';q='';
|
||||
document.getElementById('si').value='';
|
||||
buildSB();buildMN();
|
||||
}
|
||||
setTimeout(()=>{
|
||||
const el=document.getElementById('dom-views');
|
||||
if(el) el.scrollIntoView({behavior:'smooth',block:'start'});
|
||||
}, needRebuild?80:0);
|
||||
}
|
||||
function selOverview(){
|
||||
dom=null;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||
}
|
||||
function selInfra(){
|
||||
dom=null;view='infra';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();document.getElementById('mn').scrollTop=0;
|
||||
}
|
||||
function jump(name){
|
||||
dom=T2D[name]||null;q='';document.getElementById('si').value='';
|
||||
dom=T2D[name]||null;view='overview';q='';document.getElementById('si').value='';
|
||||
buildSB();buildMN();
|
||||
setTimeout(()=>{
|
||||
const el=document.getElementById('tc-'+name);
|
||||
@@ -409,14 +463,14 @@ function jump(name){
|
||||
const bd=document.getElementById('bd-'+name);
|
||||
const tg=document.getElementById('tg-'+name);
|
||||
if(bd&&!bd.classList.contains('open')){bd.classList.add('open');tg?.classList.add('open');}
|
||||
el.style.borderColor='#4f8cff';
|
||||
el.style.borderColor='#6366f1';
|
||||
setTimeout(()=>el.style.borderColor='',2000);
|
||||
},80);
|
||||
}
|
||||
let st;
|
||||
function search(v){
|
||||
clearTimeout(st);q=v.trim();
|
||||
st=setTimeout(()=>{dom=null;buildSB();buildMN();},200);
|
||||
st=setTimeout(()=>{dom=null;view='overview';buildSB();buildMN();},200);
|
||||
}
|
||||
buildSB();buildMN();
|
||||
</script>
|
||||
@@ -437,21 +491,26 @@ const content = fs.readFileSync(schemaPath, 'utf8');
|
||||
console.log(` → Lendo schema... (${(content.length / 1024).toFixed(0)} KB)`);
|
||||
|
||||
const { tables, views } = parseSchema(content);
|
||||
const domains = buildDomains(tables);
|
||||
const domains = buildDomains(tables);
|
||||
const totalFKs = Object.values(tables).reduce((a, t) => a + t.fks.length, 0);
|
||||
|
||||
console.log(` → ${Object.keys(tables).length} tabelas · ${totalFKs} FKs · ${views.length} views`);
|
||||
|
||||
// Avisa sobre tabelas novas não mapeadas
|
||||
if (domains['Outros']) {
|
||||
console.log(`\n ⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):`);
|
||||
domains['Outros'].forEach(t => console.log(` - ${t}`));
|
||||
console.log(` → Edite DOMAIN_TABLES no script para mapeá-las.\n`);
|
||||
console.log(`\n ⚠ Tabelas novas sem domínio definido (aparecerão em "Outros"):`);
|
||||
domains['Outros'].forEach((t) => console.log(` - ${t}`));
|
||||
console.log(` → Edite "domains" em db.config.json para mapeá-las.\n`);
|
||||
}
|
||||
|
||||
// Infra stats
|
||||
const infraGroups = Object.keys(INFRASTRUCTURE).length;
|
||||
const infraItems = Object.values(INFRASTRUCTURE).reduce((a, g) => a + (g.items?.length || 0), 0);
|
||||
console.log(` → Infraestrutura: ${infraGroups} grupos, ${infraItems} itens`);
|
||||
|
||||
const html = generateHTML(tables, views, domains, date, available);
|
||||
fs.writeFileSync(OUTPUT_FILE, html, 'utf8');
|
||||
|
||||
console.log(`\n✔ Gerado: ${OUTPUT_FILE}`);
|
||||
console.log(`\n✔ Gerado: ${OUTPUT_FILE}`);
|
||||
console.log(` Tamanho: ${(fs.statSync(OUTPUT_FILE).size / 1024).toFixed(0)} KB`);
|
||||
console.log(` Abra no browser: file://${OUTPUT_FILE}\n`);
|
||||
console.log(` Abra no browser: file://${OUTPUT_FILE.replace(/\\/g, '/')}\n`);
|
||||
|
||||
Reference in New Issue
Block a user