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:
Leonardo
2026-04-19 15:42:46 -03:00
parent d088a89fb7
commit 7c20b518d4
175 changed files with 37325 additions and 37968 deletions
+469 -138
View File
@@ -5,15 +5,17 @@
// Uso: node db.cjs <comando> [opcoes]
//
// Comandos:
// setup Instalação do zero (schema + seeds)
// backup Exporta backup com data atual
// restore [data] Restaura de um backup (ex: 2026-03-23)
// setup Instalação do zero (schema + fixes + seeds + migrations)
// backup Exporta backup com data atual (+ supabase_restore.sql)
// restore [data] Restaura de um backup (ex: 2026-04-17)
// migrate Aplica migrations pendentes
// seed [grupo] Roda seeds (all|users|system|test_data)
// status Mostra estado atual do banco
// diff Compara schema atual vs último backup
// reset Reseta o banco e reinstala tudo
// verify Verifica integridade dos dados essenciais
// schema-export Exporta schema granular para schema/00_full..10_grants
// dashboard Gera dashboard HTML interativo (tabelas + infra)
// help Mostra ajuda
// =============================================================================
@@ -30,6 +32,9 @@ const CONFIG = JSON.parse(fs.readFileSync(path.join(ROOT, 'db.config.json'), 'ut
const CONTAINER = CONFIG.container;
const DB = CONFIG.database;
const USER = CONFIG.user;
const MIGRATIONS_DIR = path.resolve(ROOT, CONFIG.migrationsDir || 'migrations');
const SEEDS_DIR = path.resolve(ROOT, CONFIG.seedsDir || 'seeds');
const FIXES_DIR = path.resolve(ROOT, CONFIG.fixesDir || 'fixes');
// ---------------------------------------------------------------------------
// Colors (sem dependências externas)
@@ -45,27 +50,13 @@ const c = {
gray: '\x1b[90m'
};
function log(msg) {
console.log(msg);
}
function info(msg) {
log(`${c.cyan}${c.reset} ${msg}`);
}
function ok(msg) {
log(`${c.green}${c.reset} ${msg}`);
}
function warn(msg) {
log(`${c.yellow}${c.reset} ${msg}`);
}
function err(msg) {
log(`${c.red}${c.reset} ${msg}`);
}
function title(msg) {
log(`\n${c.bold}${c.blue}═══ ${msg} ═══${c.reset}\n`);
}
function step(msg) {
log(`${c.gray}${c.reset} ${msg}`);
}
function log(msg) { console.log(msg); }
function info(msg) { log(`${c.cyan}${c.reset} ${msg}`); }
function ok(msg) { log(`${c.green}${c.reset} ${msg}`); }
function warn(msg) { log(`${c.yellow}${c.reset} ${msg}`); }
function err(msg) { log(`${c.red}${c.reset} ${msg}`); }
function title(msg){ log(`\n${c.bold}${c.blue}═══ ${msg} ═══${c.reset}\n`); }
function step(msg) { log(`${c.gray}${c.reset} ${msg}`); }
// ---------------------------------------------------------------------------
// Helpers
@@ -86,21 +77,31 @@ function dockerRunning() {
function psql(sql, opts = {}) {
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} ${opts.tuples ? '-t' : ''} ${opts.quiet ? '-q' : ''} -c "${sql.replace(/"/g, '\\"')}"`;
return execSync(cmd, { encoding: 'utf8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' } }).trim();
return execSync(cmd, {
encoding: 'utf8',
timeout: 30000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
}).trim();
}
function psqlFile(filePath) {
const absPath = path.resolve(filePath);
const content = fs.readFileSync(absPath, 'utf8');
// Prepend SET client_encoding to ensure UTF-8 inside the session
const utf8Content = "SET client_encoding TO 'UTF8';\n" + content;
const cmd = `docker exec -i -e PGCLIENTENCODING=UTF8 ${CONTAINER} psql -U ${USER} -d ${DB} -q`;
return execSync(cmd, { input: utf8Content, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' } });
return execSync(cmd, {
input: utf8Content,
encoding: 'utf8',
timeout: 120000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
});
}
function pgDump(args) {
const cmd = `docker exec -e PGCLIENTENCODING=UTF8 ${CONTAINER} pg_dump -U ${USER} -d ${DB} ${args}`;
return execSync(cmd, { encoding: 'utf8', timeout: 120000, maxBuffer: 50 * 1024 * 1024 });
return execSync(cmd, { encoding: 'utf8', timeout: 180000, maxBuffer: 100 * 1024 * 1024 });
}
function today() {
@@ -168,6 +169,89 @@ function recordMigration(filename, hash, category) {
psql(`INSERT INTO _db_migrations (filename, hash, category) VALUES ('${filename}', '${hash}', '${category}') ON CONFLICT (filename) DO UPDATE SET hash = EXCLUDED.hash, applied_at = now();`);
}
// ---------------------------------------------------------------------------
// Supabase-compatible dump builder
// ---------------------------------------------------------------------------
function buildSupabaseDump(backupDir) {
step('Gerando dump compatível com Supabase (supabase_restore.sql)...');
const parts = [];
parts.push('-- ==========================================================');
parts.push('-- AgenciaPsi — Supabase-compatible full restore dump');
parts.push(`-- Gerado em: ${new Date().toISOString()}`);
parts.push('-- ');
parts.push('-- USO: Para restaurar este dump em um Supabase limpo:');
parts.push('-- 1. npx supabase db reset (limpa tudo)');
parts.push('-- 2. Rode este arquivo:');
parts.push(`-- docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB} \\`);
parts.push('-- < backups/YYYY-MM-DD/supabase_restore.sql');
parts.push('--');
parts.push('-- OU via pipe:');
parts.push('-- cat backups/YYYY-MM-DD/supabase_restore.sql | \\');
parts.push(`-- docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB}`);
parts.push('-- ==========================================================');
parts.push('');
parts.push("SET client_encoding TO 'UTF8';");
parts.push('SET statement_timeout = 0;');
parts.push('SET lock_timeout = 0;');
parts.push("SET standard_conforming_strings = on;");
parts.push('');
// 1. Schema public: drop + recreate
parts.push('-- [1/5] Limpar schema public');
parts.push('DROP SCHEMA IF EXISTS public CASCADE;');
parts.push('CREATE SCHEMA public;');
parts.push('GRANT ALL ON SCHEMA public TO postgres;');
parts.push('GRANT ALL ON SCHEMA public TO public;');
parts.push('');
// 2. Schema-only dump (public schema only, no infra)
parts.push('-- [2/5] Estrutura (tabelas, índices, constraints, triggers, functions)');
const schemaDump = pgDump('--schema-only --no-owner --no-privileges --schema=public');
parts.push(schemaDump);
parts.push('');
// 3. Auth users data (crucial for Supabase)
parts.push('-- [3/5] Dados auth.users (essencial para autenticação)');
try {
const authData = pgDump('--data-only --no-owner --no-privileges --table=auth.users --table=auth.identities --table=auth.sessions --table=auth.refresh_tokens --table=auth.mfa_factors --table=auth.mfa_challenges');
parts.push(authData);
} catch {
parts.push('-- AVISO: Não foi possível exportar dados de auth (pode estar vazio)');
}
parts.push('');
// 4. All public data
parts.push('-- [4/5] Dados das tabelas public');
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions',
'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics',
'supabase_migrations', 'auth'];
const excludeFlags = infraSchemas.map((s) => `--exclude-schema=${s}`).join(' ');
const publicData = pgDump(`--data-only --no-owner --no-privileges ${excludeFlags}`);
parts.push(publicData);
parts.push('');
// 5. Storage buckets/objects metadata (if any)
parts.push('-- [5/5] Storage buckets (metadados)');
try {
const storageBuckets = pgDump('--data-only --no-owner --no-privileges --table=storage.buckets');
parts.push(storageBuckets);
} catch {
parts.push('-- AVISO: Nenhum bucket de storage encontrado');
}
parts.push('');
parts.push('-- Restore finalizado.');
const dumpContent = parts.join('\n');
const dumpPath = path.join(backupDir, 'supabase_restore.sql');
fs.writeFileSync(dumpPath, dumpContent);
return dumpPath;
}
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
@@ -179,57 +263,80 @@ commands.setup = function () {
title('Setup — Instalação do zero');
requireDocker();
// 1. Schema
// 1. Schema base
const schemaFile = path.join(ROOT, CONFIG.schema);
if (!fs.existsSync(schemaFile)) {
err(`Schema não encontrado: ${schemaFile}`);
process.exit(1);
if (fs.existsSync(schemaFile)) {
info('Aplicando schema base...');
psqlFile(schemaFile);
ok('Schema aplicado');
} else {
warn(`Schema não encontrado: ${schemaFile}`);
warn('Rode "node db.cjs schema-export" depois de uma migração fresh para gerar o schema.');
}
info('Aplicando schema...');
psqlFile(schemaFile);
ok('Schema aplicado');
// 2. Fixes
info('Aplicando fixes...');
for (const fix of CONFIG.fixes) {
const fixPath = path.join(ROOT, 'fixes', fix);
if (fs.existsSync(fixPath)) {
step(fix);
psqlFile(fixPath);
// 2. Fixes (aplicados antes dos seeds para corrigir o schema)
if (Array.isArray(CONFIG.fixes) && CONFIG.fixes.length > 0) {
info('Aplicando fixes...');
let fixCount = 0;
for (const fix of CONFIG.fixes) {
const fixPath = path.join(FIXES_DIR, fix);
if (fs.existsSync(fixPath)) {
step(fix);
psqlFile(fixPath);
fixCount++;
}
}
ok(`${fixCount} fix(es) aplicado(s)`);
}
ok(`${CONFIG.fixes.length} fixes aplicados`);
// 3. Seeds
// 3. Seeds (users + system)
commands.seed('all');
// 4. Migration table
// 4. Migration tracking table
ensureMigrationTable();
// 5. Record seeds as applied
const allSeeds = [...CONFIG.seeds.users, ...CONFIG.seeds.system];
// 5. Record seeds + fixes como aplicados
const allSeeds = [...(CONFIG.seeds?.users || []), ...(CONFIG.seeds?.system || [])];
for (const seed of allSeeds) {
const seedPath = path.join(ROOT, 'seeds', seed);
const seedPath = path.join(SEEDS_DIR, seed);
if (fs.existsSync(seedPath)) {
recordMigration(seed, fileHash(seedPath), 'seed');
}
}
for (const fix of CONFIG.fixes) {
const fixPath = path.join(ROOT, 'fixes', fix);
for (const fix of (CONFIG.fixes || [])) {
const fixPath = path.join(FIXES_DIR, fix);
if (fs.existsSync(fixPath)) {
recordMigration(fix, fileHash(fixPath), 'fix');
}
}
// 6. Aplicar migrations incrementais (opcional - só se tiver pendentes)
if (fs.existsSync(MIGRATIONS_DIR)) {
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql')).sort();
if (files.length > 0) {
info(`Aplicando ${files.length} migration(s)...`);
for (const file of files) {
step(file);
try {
psqlFile(path.join(MIGRATIONS_DIR, file));
recordMigration(file, fileHash(path.join(MIGRATIONS_DIR, file)), 'migration');
} catch (e) {
warn(` ${file} falhou (pode já ter sido aplicada via schema/seeds): ${e.message.split('\n')[0]}`);
recordMigration(file, fileHash(path.join(MIGRATIONS_DIR, file)), 'migration');
}
}
ok(`${files.length} migration(s) aplicada(s)`);
}
}
ok('Setup completo!');
log('');
// 6. Auto-backup
// 7. Auto-backup
info('Criando backup pós-setup...');
commands.backup();
// 7. Verify
// 8. Verify
commands.verify();
};
@@ -242,7 +349,8 @@ commands.backup = function () {
const dir = path.join(ROOT, 'backups', date);
ensureDir(dir);
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions', 'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics'];
const infraSchemas = ['storage', 'realtime', '_realtime', 'supabase_functions', 'extensions',
'graphql', 'graphql_public', 'pgsodium', 'vault', 'net', '_analytics', 'supabase_migrations'];
const excludeFlags = infraSchemas.map((s) => `--exclude-schema=${s}`).join(' ');
step('Exportando schema...');
@@ -257,15 +365,20 @@ commands.backup = function () {
const full = pgDump('--no-owner --no-privileges');
fs.writeFileSync(path.join(dir, 'full_dump.sql'), full);
const sizes = ['schema.sql', 'data.sql', 'full_dump.sql'].map((f) => {
const stat = fs.statSync(path.join(dir, f));
// Dump compatível com Supabase (restauração completa)
buildSupabaseDump(dir);
const files = ['schema.sql', 'data.sql', 'full_dump.sql', 'supabase_restore.sql'];
const sizes = files.map((f) => {
const fPath = path.join(dir, f);
if (!fs.existsSync(fPath)) return null;
const stat = fs.statSync(fPath);
return `${f}: ${(stat.size / 1024).toFixed(0)}KB`;
});
}).filter(Boolean);
ok(`Backup salvo em backups/${date}/`);
sizes.forEach((s) => step(s));
// Cleanup old backups
cleanupBackups();
};
@@ -278,8 +391,8 @@ function cleanupBackups() {
let removed = 0;
for (const b of backups) {
if (b < cutoffStr) {
const dir = path.join(ROOT, 'backups', b);
fs.rmSync(dir, { recursive: true, force: true });
const bDir = path.join(ROOT, 'backups', b);
fs.rmSync(bDir, { recursive: true, force: true });
removed++;
}
}
@@ -309,10 +422,6 @@ commands.restore = function (dateArg) {
process.exit(1);
}
const fullDump = path.join(dir, 'full_dump.sql');
const schemaFile = path.join(dir, 'schema.sql');
const dataFile = path.join(dir, 'data.sql');
// Safety backup before restore
info('Criando backup de segurança antes do restore...');
try {
@@ -321,24 +430,26 @@ commands.restore = function (dateArg) {
warn('Não foi possível criar backup de segurança (banco pode estar vazio)');
}
if (fs.existsSync(fullDump)) {
info(`Restaurando de backups/${date}/full_dump.sql ...`);
const supaRestore = path.join(dir, 'supabase_restore.sql');
const fullDump = path.join(dir, 'full_dump.sql');
const schemaFile = path.join(dir, 'schema.sql');
const dataFile = path.join(dir, 'data.sql');
// Drop and recreate public schema
if (fs.existsSync(supaRestore)) {
info(`Restaurando de backups/${date}/supabase_restore.sql (Supabase-compatible)...`);
psqlFile(supaRestore);
} else if (fs.existsSync(fullDump)) {
info(`Restaurando de backups/${date}/full_dump.sql ...`);
step('Limpando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
step('Aplicando full dump...');
psqlFile(fullDump);
} else if (fs.existsSync(schemaFile) && fs.existsSync(dataFile)) {
info(`Restaurando de backups/${date}/ (schema + data)...`);
step('Limpando schema public...');
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
step('Aplicando schema...');
psqlFile(schemaFile);
step('Aplicando dados...');
psqlFile(dataFile);
} else {
@@ -348,7 +459,6 @@ commands.restore = function (dateArg) {
ok(`Banco restaurado de backups/${date}/`);
// Verify
commands.verify();
};
@@ -358,14 +468,13 @@ commands.migrate = function () {
requireDocker();
ensureMigrationTable();
const migrationsDir = path.join(ROOT, 'migrations');
if (!fs.existsSync(migrationsDir)) {
if (!fs.existsSync(MIGRATIONS_DIR)) {
info('Nenhuma pasta migrations/ encontrada.');
return;
}
const files = fs
.readdirSync(migrationsDir)
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort();
@@ -389,7 +498,7 @@ commands.migrate = function () {
info(`${pending.length} migration(s) pendente(s):`);
for (const file of pending) {
step(`Aplicando ${file}...`);
const filePath = path.join(migrationsDir, file);
const filePath = path.join(MIGRATIONS_DIR, file);
try {
psqlFile(filePath);
recordMigration(file, fileHash(filePath), 'migration');
@@ -423,12 +532,12 @@ commands.seed = function (group) {
let total = 0;
for (const g of groups) {
const seeds = CONFIG.seeds[g];
const seeds = CONFIG.seeds?.[g];
if (!seeds || seeds.length === 0) continue;
info(`Grupo: ${g}`);
for (const seed of seeds) {
const seedPath = path.join(ROOT, 'seeds', seed);
const seedPath = path.join(SEEDS_DIR, seed);
if (!fs.existsSync(seedPath)) {
warn(` Arquivo não encontrado: ${seed}`);
continue;
@@ -452,10 +561,8 @@ commands.status = function () {
title('Status');
requireDocker();
// Docker
ok(`Container: ${CONTAINER} (rodando)`);
// Backups
const backups = listBackups();
if (backups.length > 0) {
ok(`Último backup: ${backups[0]}`);
@@ -464,20 +571,17 @@ commands.status = function () {
warn('Nenhum backup encontrado');
}
// Migrations
try {
const applied = getAppliedMigrations();
if (applied.length > 0) {
info(`Migrations aplicadas: ${applied.length}`);
info(`Registros em _db_migrations: ${applied.length}`);
applied.slice(-5).forEach((m) => {
step(`${m.filename} ${c.gray}(${m.category}, ${m.applied_at})${c.reset}`);
});
}
// Pending
const migrationsDir = path.join(ROOT, 'migrations');
if (fs.existsSync(migrationsDir)) {
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.sql'));
if (fs.existsSync(MIGRATIONS_DIR)) {
const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
const pending = files.filter((f) => !applied.map((m) => m.filename).includes(f));
if (pending.length > 0) {
warn(`${pending.length} migration(s) pendente(s):`);
@@ -488,28 +592,16 @@ commands.status = function () {
info('Tabela _db_migrations não existe (rode setup primeiro)');
}
// DB counts
log('');
info('Dados no banco:');
const counts = [
['auth.users', 'SELECT count(*) FROM auth.users'],
['profiles', 'SELECT count(*) FROM profiles'],
['tenants', 'SELECT count(*) FROM tenants'],
['plans', 'SELECT count(*) FROM plans'],
['features', 'SELECT count(*) FROM features'],
['plan_features', 'SELECT count(*) FROM plan_features'],
['subscriptions', 'SELECT count(*) FROM subscriptions'],
['email_templates_global', 'SELECT count(*) FROM email_templates_global'],
['notification_templates', 'SELECT count(*) FROM notification_templates']
];
for (const [label, sql] of counts) {
const statusTables = CONFIG.status?.tables || [];
for (const table of statusTables) {
try {
const count = psql(sql, { tuples: true }).trim();
const count = psql(`SELECT count(*) FROM ${table}`, { tuples: true }).trim();
const color = parseInt(count) > 0 ? c.green : c.red;
step(`${label}: ${color}${count}${c.reset}`);
step(`${table}: ${color}${count}${c.reset}`);
} catch {
step(`${label}: ${c.gray}(tabela não existe)${c.reset}`);
step(`${table}: ${c.gray}(tabela não existe)${c.reset}`);
}
}
};
@@ -534,10 +626,8 @@ commands.diff = function () {
info('Exportando schema atual...');
const currentSchema = pgDump('--schema-only --no-owner --no-privileges');
const lastSchema = fs.readFileSync(lastSchemaPath, 'utf8');
// Extract table definitions for comparison
const extractTables = (sql) => {
const tables = {};
const regex = /CREATE TABLE (?:IF NOT EXISTS )?(\S+)\s*\(([\s\S]*?)\);/g;
@@ -550,13 +640,9 @@ commands.diff = function () {
const currentTables = extractTables(currentSchema);
const lastTables = extractTables(lastSchema);
const allTables = new Set([...Object.keys(currentTables), ...Object.keys(lastTables)]);
let added = 0,
removed = 0,
changed = 0,
unchanged = 0;
let added = 0, removed = 0, changed = 0, unchanged = 0;
for (const table of [...allTables].sort()) {
if (!lastTables[table]) {
@@ -583,7 +669,6 @@ commands.reset = function () {
title('Reset — CUIDADO');
requireDocker();
// Safety backup
info('Criando backup antes do reset...');
try {
commands.backup();
@@ -595,7 +680,6 @@ commands.reset = function () {
psql('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO postgres; GRANT ALL ON SCHEMA public TO public;');
ok('Schema public resetado');
// Re-run setup
commands.setup();
};
@@ -604,23 +688,12 @@ commands.verify = function () {
title('Verificação de integridade');
requireDocker();
const checks = [
{ name: 'auth.users', sql: 'SELECT count(*) FROM auth.users', min: 1 },
{ name: 'profiles', sql: 'SELECT count(*) FROM profiles', min: 1 },
{ name: 'tenants', sql: 'SELECT count(*) FROM tenants', min: 1 },
{ name: 'plans', sql: 'SELECT count(*) FROM plans', min: 7 },
{ name: 'features', sql: 'SELECT count(*) FROM features', min: 20 },
{ name: 'plan_features', sql: 'SELECT count(*) FROM plan_features', min: 50 },
{ name: 'subscriptions', sql: 'SELECT count(*) FROM subscriptions', min: 1 },
{ name: 'email_templates', sql: 'SELECT count(*) FROM email_templates_global', min: 10 }
];
let pass = 0, fail = 0;
const verify = CONFIG.verify || { tables: [], views: [] };
let pass = 0,
fail = 0;
for (const check of checks) {
for (const check of (verify.tables || [])) {
try {
const count = parseInt(psql(check.sql, { tuples: true }).trim());
const count = parseInt(psql(`SELECT count(*) FROM ${check.name}`, { tuples: true }).trim());
if (count >= check.min) {
ok(`${check.name}: ${count} (mín: ${check.min})`);
pass++;
@@ -634,14 +707,15 @@ commands.verify = function () {
}
}
// Check entitlements view
try {
const ent = psql('SELECT count(*) FROM v_tenant_entitlements;', { tuples: true }).trim();
ok(`v_tenant_entitlements: ${ent} registros`);
pass++;
} catch {
err('v_tenant_entitlements: view não existe');
fail++;
for (const view of (verify.views || [])) {
try {
const cnt = psql(`SELECT count(*) FROM ${view};`, { tuples: true }).trim();
ok(`${view}: ${cnt} registros`);
pass++;
} catch {
err(`${view}: view não existe`);
fail++;
}
}
log('');
@@ -652,6 +726,225 @@ commands.verify = function () {
}
};
// ---- SCHEMA-EXPORT ----
commands['schema-export'] = function () {
title('Schema Export');
requireDocker();
const schemaDir = path.join(ROOT, 'schema');
const dirs = {
full: path.join(schemaDir, '00_full'),
extensions: path.join(schemaDir, '01_extensions'),
types: path.join(schemaDir, '02_types'),
functions: path.join(schemaDir, '03_functions'),
tables: path.join(schemaDir, '04_tables'),
views: path.join(schemaDir, '05_views'),
indexes: path.join(schemaDir, '06_indexes'),
foreignKeys: path.join(schemaDir, '07_foreign_keys'),
triggers: path.join(schemaDir, '08_triggers'),
policies: path.join(schemaDir, '09_policies'),
grants: path.join(schemaDir, '10_grants'),
};
// Limpa diretórios antes para remover arquivos stale de exports anteriores
for (const dir of Object.values(dirs)) {
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith('.sql')) fs.rmSync(path.join(dir, f), { force: true });
}
}
ensureDir(dir);
}
// 00_full — dump completo
step('00_full/schema.sql (dump completo)...');
const fullSchema = pgDump('--schema-only --no-owner --no-privileges');
fs.writeFileSync(path.join(dirs.full, 'schema.sql'), fullSchema);
// 01_extensions
step('01_extensions...');
const extSql = `SELECT 'CREATE EXTENSION IF NOT EXISTS ' || quote_ident(extname) || ' WITH SCHEMA ' || quote_ident(nspname) || ';' FROM pg_extension e JOIN pg_namespace n ON e.extnamespace = n.oid WHERE extname NOT IN ('plpgsql') ORDER BY extname;`;
const extResult = psql(extSql, { tuples: true });
const extLines = extResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
if (extLines.length > 0) {
const content = `-- Extensions\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${extLines.length}\n\n` + extLines.join('\n') + '\n';
fs.writeFileSync(path.join(dirs.extensions, 'extensions.sql'), content);
}
// 02_types — enums públicos
step('02_types...');
const typesSql = `
SELECT pg_catalog.format_type(t.oid, NULL) || ' AS ENUM (' ||
string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder) || ');'
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_namespace n ON t.typnamespace = n.oid
WHERE n.nspname = 'public'
GROUP BY t.oid, t.typname;`;
const typesResult = psql(typesSql, { tuples: true });
const typesLines = typesResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
if (typesLines.length > 0) {
const typesContent = `-- Public Enums & Types\n-- Gerado automaticamente em ${new Date().toISOString()}\n\n` +
typesLines.map(l => `CREATE TYPE ${l}`).join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.types, 'public_types.sql'), typesContent);
}
try {
const authTypesSql = typesSql.replace("'public'", "'auth'");
const authTypesResult = psql(authTypesSql, { tuples: true });
const authLines = authTypesResult.split('\n').filter(Boolean).map(l => l.trim()).filter(Boolean);
if (authLines.length > 0) {
const authContent = `-- Auth Enums & Types\n-- Gerado automaticamente em ${new Date().toISOString()}\n\n` +
authLines.map(l => `CREATE TYPE ${l}`).join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.types, 'auth_types.sql'), authContent);
}
} catch { /* auth types may not exist */ }
// 03_functions
step('03_functions...');
const funcBlocks = fullSchema.match(/CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+[\s\S]*?(?:\$\$[^$]*\$\$|\$[a-zA-Z_]+\$[\s\S]*?\$[a-zA-Z_]+\$)\s*(?:;|$)/gi) || [];
const funcsBySchema = {};
for (const block of funcBlocks) {
const nameMatch = block.match(/FUNCTION\s+([\w.]+)\./);
const schema = nameMatch ? nameMatch[1] : 'public';
if (!funcsBySchema[schema]) funcsBySchema[schema] = [];
funcsBySchema[schema].push(block);
}
for (const [schema, funcs] of Object.entries(funcsBySchema)) {
const content = `-- Functions: ${schema}\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${funcs.length}\n\n` +
funcs.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.functions, `${schema}.sql`), content);
}
if (funcBlocks.length > 0) {
const allFuncs = `-- All Functions\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${funcBlocks.length}\n\n` +
funcBlocks.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.functions, '_all.sql'), allFuncs);
}
// 04_tables — agrupado por domínio
step('04_tables...');
const tableBlocks = fullSchema.match(/CREATE TABLE (?:IF NOT EXISTS )?public\.\S+\s*\([\s\S]*?\);/gi) || [];
const domainTables = CONFIG.domains || {};
const tableToDomain = {};
for (const [domain, tables] of Object.entries(domainTables)) {
for (const t of tables) tableToDomain[t] = domain;
}
const tablesByDomain = {};
for (const block of tableBlocks) {
const nameMatch = block.match(/CREATE TABLE (?:IF NOT EXISTS )?public\.(\S+)/i);
if (!nameMatch) continue;
const name = nameMatch[1];
const domain = tableToDomain[name] || 'outros';
if (!tablesByDomain[domain]) tablesByDomain[domain] = [];
tablesByDomain[domain].push(block);
}
for (const [domain, blocks] of Object.entries(tablesByDomain)) {
const content = `-- Tables: ${domain}\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${blocks.length}\n\n` +
blocks.join('\n\n') + '\n';
const filename = domain.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') + '.sql';
fs.writeFileSync(path.join(dirs.tables, filename), content);
}
// 05_views
step('05_views...');
const viewBlocks = fullSchema.match(/CREATE(?:\s+OR\s+REPLACE)?\s+VIEW\s+public\.[\s\S]*?;/gi) || [];
if (viewBlocks.length > 0) {
const content = `-- Views\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${viewBlocks.length}\n\n` +
viewBlocks.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.views, 'views.sql'), content);
}
// 06_indexes
step('06_indexes...');
const indexLines = fullSchema.match(/CREATE (?:UNIQUE )?INDEX\s+\S+\s+ON\s+public\.\S+[\s\S]*?;/gi) || [];
if (indexLines.length > 0) {
const content = `-- Indexes\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${indexLines.length}\n\n` +
indexLines.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.indexes, 'indexes.sql'), content);
}
// 07_foreign_keys (+ PKs + UNIQUEs)
step('07_foreign_keys...');
const constraintLines = fullSchema.match(/ALTER TABLE ONLY public\.\S+\s+ADD CONSTRAINT\s+[\s\S]*?;/gi) || [];
if (constraintLines.length > 0) {
const content = `-- Constraints (PK, FK, UNIQUE, CHECK)\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${constraintLines.length}\n\n` +
constraintLines.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.foreignKeys, 'constraints.sql'), content);
}
// 08_triggers
step('08_triggers...');
const triggerLines = fullSchema.match(/CREATE TRIGGER\s+[\s\S]*?;/gi) || [];
if (triggerLines.length > 0) {
const content = `-- Triggers\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${triggerLines.length}\n\n` +
triggerLines.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.triggers, 'triggers.sql'), content);
}
// 09_policies
step('09_policies...');
const policyEnables = fullSchema.match(/ALTER TABLE public\.\S+ ENABLE ROW LEVEL SECURITY;/gi) || [];
const policyCreates = fullSchema.match(/CREATE POLICY\s+[\s\S]*?;/gi) || [];
if (policyEnables.length > 0 || policyCreates.length > 0) {
const content = `-- RLS Policies\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Enable RLS: ${policyEnables.length} tabelas\n-- Policies: ${policyCreates.length}\n\n` +
'-- Enable RLS\n' + policyEnables.join('\n') + '\n\n' +
'-- Policies\n' + policyCreates.join('\n\n') + '\n';
fs.writeFileSync(path.join(dirs.policies, 'policies.sql'), content);
}
// 10_grants
step('10_grants...');
const grantLines = fullSchema.match(/(?:GRANT|REVOKE)\s+[\s\S]*?;/gi) || [];
const publicGrants = grantLines.filter(g => /public\./i.test(g));
if (publicGrants.length > 0) {
const content = `-- Grants\n-- Gerado automaticamente em ${new Date().toISOString()}\n-- Total: ${publicGrants.length}\n\n` +
publicGrants.join('\n') + '\n';
fs.writeFileSync(path.join(dirs.grants, 'grants.sql'), content);
}
// Summary
log('');
ok('Schema exportado para schema/');
const summary = [
['00_full', '1 arquivo'],
['01_extensions', fs.readdirSync(dirs.extensions).length + ' arquivo(s)'],
['02_types', fs.readdirSync(dirs.types).length + ' arquivo(s)'],
['03_functions', fs.readdirSync(dirs.functions).length + ' arquivo(s), ' + funcBlocks.length + ' functions'],
['04_tables', fs.readdirSync(dirs.tables).length + ' arquivo(s), ' + tableBlocks.length + ' tabelas'],
['05_views', viewBlocks.length + ' views'],
['06_indexes', indexLines.length + ' indexes'],
['07_foreign_keys', constraintLines.length + ' constraints'],
['08_triggers', triggerLines.length + ' triggers'],
['09_policies', policyCreates.length + ' policies'],
['10_grants', publicGrants.length + ' grants'],
];
for (const [dir, desc] of summary) {
step(`${dir}: ${desc}`);
}
};
// ---- DASHBOARD ----
commands.dashboard = function (dateArg) {
title('Dashboard');
const scriptPath = path.join(ROOT, 'generate-dashboard.cjs');
if (!fs.existsSync(scriptPath)) {
err(`Script não encontrado: ${scriptPath}`);
process.exit(1);
}
const args = dateArg ? [scriptPath, dateArg] : [scriptPath];
const result = spawnSync('node', args, {
stdio: 'inherit',
cwd: ROOT,
env: { ...process.env, PYTHONIOENCODING: 'utf-8', LANG: 'C.UTF-8' }
});
if (result.status !== 0) {
err('Falha ao gerar dashboard.');
process.exit(result.status || 1);
}
};
// ---- HELP ----
commands.help = function () {
log(`
@@ -661,15 +954,17 @@ ${c.cyan}Uso:${c.reset} node db.cjs <comando> [opções]
${c.cyan}Comandos:${c.reset}
${c.bold}setup${c.reset} Instalação do zero (schema + fixes + seeds)
${c.bold}setup${c.reset} Instalação do zero (schema + fixes + seeds + migrations)
Cria backup automático após concluir
${c.bold}backup${c.reset} Exporta banco para backups/YYYY-MM-DD/
Gera: schema.sql, data.sql, full_dump.sql
Gera: schema.sql, data.sql, full_dump.sql,
${c.green}supabase_restore.sql${c.reset} (restauração completa)
${c.bold}restore [data]${c.reset} Restaura de um backup
Prioriza supabase_restore.sql se existir
Sem data = último backup disponível
Ex: node db.cjs restore 2026-03-23
Ex: node db.cjs restore 2026-04-17
${c.bold}migrate${c.reset} Aplica migrations pendentes (pasta migrations/)
Backup automático antes de aplicar
@@ -685,9 +980,39 @@ ${c.cyan}Comandos:${c.reset}
${c.yellow}⚠ Cria backup antes de resetar${c.reset}
${c.bold}verify${c.reset} Verifica integridade dos dados essenciais
(tabelas + views definidas em db.config.json → verify)
${c.bold}schema-export${c.reset} Exporta schema separado em schema/
00_full, 01_extensions, 02_types, 03_functions,
04_tables (agrupado por domínio), 05_views,
06_indexes, 07_foreign_keys, 08_triggers,
09_policies, 10_grants
${c.bold}dashboard [data]${c.reset} Gera dashboard HTML interativo do banco
Tabelas por domínio + seção Infraestrutura + busca
Sem data = usa schema do backup mais recente
${c.bold}help${c.reset} Mostra esta ajuda
${c.cyan}Backup Supabase-compatible:${c.reset}
O comando ${c.bold}backup${c.reset} gera automaticamente o arquivo
${c.green}supabase_restore.sql${c.reset} que contém TUDO necessário
para restaurar o banco do zero:
• Schema public (tabelas, índices, triggers, functions)
• Dados auth.users + identities (autenticação)
• Todos os dados das tabelas public
• Metadados de storage buckets
Para restaurar manualmente:
${c.gray}# Opção 1: via CLI${c.reset}
node db.cjs restore
${c.gray}# Opção 2: direto no container${c.reset}
cat backups/2026-04-17/supabase_restore.sql | \\
docker exec -i ${CONTAINER} psql -U ${USER} -d ${DB}
${c.cyan}Exemplos:${c.reset}
${c.gray}# Primeira vez — instala tudo${c.reset}
@@ -704,6 +1029,12 @@ ${c.cyan}Exemplos:${c.reset}
${c.gray}# Ver o que tem no banco${c.reset}
node db.cjs status
${c.gray}# Atualizar as pastas schema/*${c.reset}
node db.cjs schema-export
${c.gray}# Gerar dashboard HTML${c.reset}
node db.cjs dashboard
`);
};