From e8dc3aed6791a8d150412aaa9ef40ba134cc427d Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Tue, 28 Apr 2026 00:34:41 +0000 Subject: [PATCH] feat: metabase auto-registration + ui fixes + migration scripts - Add metabase.service.ts for automatic DB registration on tenant creation - Hook createTenant, addTenantToOwner and deleteTenant to sync with Metabase - Add environment variables for Metabase integration - Fix dashboard routing for global admin users - Fix CFDI status casing (Vigente vs vigente) - Fix sidebar empty nav crash - Fix KPI null regimen_fiscal values - Fix CFDI type mapping (EMITIDO/RECIBIDO) - Update branding from Horux360 to Horux Despachos - Add legacy migration scripts for central and tenant DBs --- apps/api/scripts/migrate-central-legacy.ts | 301 ++++++++++++++++ apps/api/scripts/migrate-tenant-legacy.ts | 323 ++++++++++++++++++ apps/api/src/app.ts | 4 + apps/api/src/services/metabase.service.ts | 167 +++++++++ apps/api/src/services/tenants.service.ts | 17 + apps/web/app/(auth)/login/page.tsx | 6 +- apps/web/app/(dashboard)/cfdi/page.tsx | 12 +- .../app/(dashboard)/configuracion/page.tsx | 2 +- apps/web/app/(dashboard)/dashboard/page.tsx | 13 +- apps/web/app/(dashboard)/usuarios/page.tsx | 8 +- apps/web/app/layout.tsx | 2 +- apps/web/components/cfdi/cfdi-invoice.tsx | 4 +- .../web/components/cfdi/cfdi-viewer-modal.tsx | 2 +- .../components/layouts/sidebar-compact.tsx | 8 +- .../components/layouts/sidebar-floating.tsx | 8 +- apps/web/components/layouts/sidebar.tsx | 2 +- apps/web/components/layouts/topnav.tsx | 6 +- ecosystem.config.js | 6 +- 18 files changed, 846 insertions(+), 45 deletions(-) create mode 100644 apps/api/scripts/migrate-central-legacy.ts create mode 100644 apps/api/scripts/migrate-tenant-legacy.ts create mode 100644 apps/api/src/services/metabase.service.ts diff --git a/apps/api/scripts/migrate-central-legacy.ts b/apps/api/scripts/migrate-central-legacy.ts new file mode 100644 index 0000000..87e0c47 --- /dev/null +++ b/apps/api/scripts/migrate-central-legacy.ts @@ -0,0 +1,301 @@ +/** + * ETL: Migración de BD central Horux360 → HoruxDespachos + * Lee desde horux360 y escribe en la BD central actual (horux_despachos) + */ +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; +import { Pool } from 'pg'; + +const SOURCE_DB = 'horux360'; + +const prisma = new PrismaClient(); +const source = new Pool({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb', + database: SOURCE_DB, + max: 5, +}); + +async function main() { + console.log('=== Central DB Migration: Horux360 → HoruxDespachos ===\n'); + + // 1. Seed roles (idempotente) + const rolesData = [ + { nombre: 'owner', descripcion: 'Dueño - acceso completo' }, + { nombre: 'cfo', descripcion: 'CFO - acceso completo (mismo nivel que el dueño)' }, + { nombre: 'contador', descripcion: 'Contador - dashboard, CFDI, impuestos, calendario, alertas, facturación' }, + { nombre: 'auxiliar', descripcion: 'Auxiliar - mismos permisos que contador' }, + { nombre: 'visor', descripcion: 'Visor - solo lectura de CFDI, impuestos, calendario, alertas' }, + { nombre: 'supervisor', descripcion: 'Supervisor de despacho — titular de RFCs, crea carteras' }, + { nombre: 'cliente', descripcion: 'Cliente visor externo — acceso read-only a sus RFCs' }, + ]; + + for (const r of rolesData) { + await prisma.rol.upsert({ + where: { nombre: r.nombre }, + update: {}, + create: r, + }); + } + console.log('✅ Roles seeded'); + + const roles = await prisma.rol.findMany(); + const rolMap = new Map(roles.map(r => [r.nombre, r.id])); + + // 2. Migrate tenants + const tenantsLegacy = await source.query(` + SELECT id, nombre, rfc, plan, database_name, cfdi_limit, users_limit, active, created_at, expires_at + FROM tenants + `); + + const planMap: Record = { + starter: 'starter', + business: 'business', + professional: 'business_ia', + enterprise: 'enterprise', + }; + + for (const t of tenantsLegacy.rows) { + await prisma.tenant.upsert({ + where: { rfc: t.rfc }, + update: {}, + create: { + id: t.id, + nombre: t.nombre, + rfc: t.rfc, + plan: (planMap[t.plan] || 'starter') as any, + databaseName: t.database_name, + cfdiLimit: t.cfdi_limit, + usersLimit: t.users_limit, + active: t.active, + createdAt: t.created_at, + expiresAt: t.expires_at, + }, + }); + } + console.log(`✅ ${tenantsLegacy.rows.length} tenants migrated`); + + // 3. Migrate users (sin tenantId/rolId legacy) + const usersLegacy = await source.query(` + SELECT id, email, password_hash, nombre, active, last_login, created_at + FROM users + `); + + for (const u of usersLegacy.rows) { + await prisma.user.upsert({ + where: { email: u.email }, + update: {}, + create: { + id: u.id, + email: u.email, + passwordHash: u.password_hash, + nombre: u.nombre, + active: u.active, + lastLogin: u.last_login, + createdAt: u.created_at, + tokenVersion: 0, + }, + }); + } + console.log(`✅ ${usersLegacy.rows.length} users migrated`); + + // 4. Create TenantMemberships (backfill from legacy user→tenant relationship) + const membershipsLegacy = await source.query(` + SELECT u.id as user_id, u.tenant_id, u.role, u.active, u.created_at + FROM users u + `); + + const roleNameMap: Record = { + admin: 'owner', + contador: 'contador', + visor: 'visor', + }; + + for (const m of membershipsLegacy.rows) { + const roleName = roleNameMap[m.role] || 'visor'; + const rolId = rolMap.get(roleName); + if (!rolId) continue; + + await prisma.tenantMembership.upsert({ + where: { + userId_tenantId: { + userId: m.user_id, + tenantId: m.tenant_id, + }, + }, + update: {}, + create: { + userId: m.user_id, + tenantId: m.tenant_id, + rolId, + isOwner: roleName === 'owner' || roleName === 'cfo', + active: m.active, + joinedAt: m.created_at, + }, + }); + } + console.log(`✅ ${membershipsLegacy.rows.length} tenant memberships created`); + + // 5. Set lastTenantId for all users + for (const m of membershipsLegacy.rows) { + await prisma.user.update({ + where: { id: m.user_id }, + data: { lastTenantId: m.tenant_id }, + }); + } + console.log('✅ lastTenantId set for all users'); + + // 6. Migrate FIEL credentials + const fielLegacy = await source.query(`SELECT * FROM fiel_credentials`); + for (const f of fielLegacy.rows) { + await prisma.fielCredential.upsert({ + where: { tenantId: f.tenant_id }, + update: {}, + create: { + id: f.id, + tenantId: f.tenant_id, + rfc: f.rfc, + cerData: f.cer_data, + keyData: f.key_data, + keyPasswordEncrypted: f.key_password_encrypted, + cerIv: f.cer_iv, + cerTag: f.cer_tag, + keyIv: f.key_iv, + keyTag: f.key_tag, + passwordIv: f.password_iv, + passwordTag: f.password_tag, + serialNumber: f.serial_number, + validFrom: f.valid_from, + validUntil: f.valid_until, + isActive: f.is_active, + createdAt: f.created_at, + updatedAt: f.updated_at, + }, + }); + } + console.log(`✅ ${fielLegacy.rows.length} FIEL credentials migrated`); + + // 7. Migrate subscriptions + const subsLegacy = await source.query(`SELECT * FROM subscriptions`); + for (const s of subsLegacy.rows) { + await prisma.subscription.create({ + data: { + id: s.id, + tenantId: s.tenant_id, + plan: (planMap[s.plan] || 'starter') as any, + mpPreapprovalId: s.mp_preapproval_id, + status: s.status, + amount: s.amount, + frequency: s.frequency, + currentPeriodStart: s.current_period_start, + currentPeriodEnd: s.current_period_end, + createdAt: s.created_at, + updatedAt: s.updated_at, + }, + }); + } + console.log(`✅ ${subsLegacy.rows.length} subscriptions migrated`); + + // 8. Migrate payments + const paymentsLegacy = await source.query(`SELECT * FROM payments`); + for (const p of paymentsLegacy.rows) { + await prisma.payment.create({ + data: { + id: p.id, + tenantId: p.tenant_id, + subscriptionId: p.subscription_id, + mpPaymentId: p.mp_payment_id, + amount: p.amount, + status: p.status, + paymentMethod: p.payment_method, + paidAt: p.paid_at, + createdAt: p.created_at, + }, + }); + } + console.log(`✅ ${paymentsLegacy.rows.length} payments migrated`); + + // 9. Migrate SAT sync jobs + const jobsLegacy = await source.query(`SELECT * FROM sat_sync_jobs`); + for (const j of jobsLegacy.rows) { + await prisma.satSyncJob.create({ + data: { + id: j.id, + tenantId: j.tenant_id, + type: j.type, + status: j.status, + dateFrom: j.date_from, + dateTo: j.date_to, + cfdiType: j.cfdi_type, + satRequestId: j.sat_request_id, + satPackageIds: j.sat_package_ids || [], + cfdisFound: j.cfdis_found, + cfdisDownloaded: j.cfdis_downloaded, + cfdisInserted: j.cfdis_inserted, + cfdisUpdated: j.cfdis_updated, + progressPercent: j.progress_percent, + errorMessage: j.error_message, + startedAt: j.started_at, + completedAt: j.completed_at, + createdAt: j.created_at, + retryCount: j.retry_count, + nextRetryAt: j.next_retry_at, + }, + }); + } + console.log(`✅ ${jobsLegacy.rows.length} SAT sync jobs migrated`); + + // 10. Migrate refresh tokens + const tokensLegacy = await source.query(`SELECT * FROM refresh_tokens`); + for (const t of tokensLegacy.rows) { + await prisma.refreshToken.create({ + data: { + id: t.id, + userId: t.user_id, + token: t.token, + expiresAt: t.expires_at, + createdAt: t.created_at, + }, + }); + } + console.log(`✅ ${tokensLegacy.rows.length} refresh tokens migrated`); + + // 11. Create platform_admin for global admin tenant (CAS2408138W2) + const globalAdmins = await prisma.tenantMembership.findMany({ + where: { + tenant: { rfc: 'CAS2408138W2' }, + isOwner: true, + }, + }); + + for (const m of globalAdmins) { + await prisma.userPlatformRole.upsert({ + where: { + userId_role: { + userId: m.userId, + role: 'platform_admin', + }, + }, + update: {}, + create: { + userId: m.userId, + role: 'platform_admin', + }, + }); + } + console.log(`✅ ${globalAdmins.length} platform_admin roles assigned`); + + console.log('\n=== Central migration complete ==='); +} + +main() + .catch((err) => { + console.error('Migration failed:', err); + process.exit(1); + }) + .finally(async () => { + await source.end(); + await prisma.$disconnect(); + }); diff --git a/apps/api/scripts/migrate-tenant-legacy.ts b/apps/api/scripts/migrate-tenant-legacy.ts new file mode 100644 index 0000000..53e362d --- /dev/null +++ b/apps/api/scripts/migrate-tenant-legacy.ts @@ -0,0 +1,323 @@ +/** + * ETL: Migración de BDs tenant Horux360 → HoruxDespachos + */ +import 'dotenv/config'; +import { Pool } from 'pg'; +import { prisma } from '../src/config/database.js'; +import { migrate } from '../src/config/tenant-migrations.js'; + +const BACKUP_SUFFIX = '_backup_20260427'; +const BATCH_SIZE = 1000; + +const adminPool = new Pool({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb', + database: 'postgres', + max: 2, +}); + +async function renameDatabase(oldName: string, newName: string) { + await adminPool.query(` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid(); + `, [oldName]); + await adminPool.query(`ALTER DATABASE "${oldName}" RENAME TO "${newName}";`); +} + +async function createDatabase(name: string) { + await adminPool.query(`CREATE DATABASE "${name}";`); +} + +async function dropDatabase(name: string) { + await adminPool.query(` + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = $1 AND pid <> pg_backend_pid(); + `, [name]); + await adminPool.query(`DROP DATABASE IF EXISTS "${name}";`); +} + +function mapTipoComprobante(tipo: string): string { + switch (tipo) { + case 'ingreso': return 'I'; + case 'egreso': return 'E'; + case 'traslado': return 'T'; + case 'nomina': return 'N'; + case 'pago': return 'P'; + default: return 'I'; + } +} + +async function runEtl(backupPool: Pool, targetPool: Pool, tenant: any) { + // Count source CFDIs + const countRes = await backupPool.query('SELECT COUNT(*)::int as cnt FROM cfdis'); + const total = countRes.rows[0].cnt; + if (total === 0) { + console.log(` No CFDIs to migrate`); + return { inserted: 0, total: 0 }; + } + + console.log(` Migrating ${total} CFDIs...`); + let offset = 0; + let inserted = 0; + + while (offset < total) { + const { rows } = await backupPool.query(` + SELECT + uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado, + rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, + subtotal, descuento, iva, isr_retenido, iva_retenido, total, + moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado, + xml_url, pdf_url, xml_original, created_at, updated_at, + last_sat_sync, sat_sync_job_id, source + FROM cfdis + ORDER BY created_at + LIMIT $1 OFFSET $2 + `, [BATCH_SIZE, offset]); + + if (rows.length === 0) break; + + const values: any[] = []; + const placeholders: string[] = []; + let paramIdx = 1; + + for (const row of rows) { + const mxnMultiplier = row.moneda === 'MXN' ? 1 : (parseFloat(row.tipo_cambio) || 1); + const tipoComp = mapTipoComprobante(row.tipo); + const year = row.fecha_emision ? new Date(row.fecha_emision).getFullYear().toString() : null; + const month = row.fecha_emision ? String(new Date(row.fecha_emision).getMonth() + 1).padStart(2, '0') : null; + + placeholders.push(`( + $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, + $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, + $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, + $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, + $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, + $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, + $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, + $${paramIdx++}, $${paramIdx++}, $${paramIdx++}, $${paramIdx++} + )`); + + values.push( + year, month, row.tipo, row.uuid_fiscal ? String(row.uuid_fiscal).toLowerCase() : null, + row.serie, row.folio, row.estado, row.fecha_emision, + row.rfc_emisor, row.nombre_emisor, row.rfc_receptor, row.nombre_receptor, + row.subtotal, parseFloat(row.subtotal || 0) * mxnMultiplier, + row.descuento, parseFloat(row.descuento || 0) * mxnMultiplier, + row.total, parseFloat(row.total || 0) * mxnMultiplier, + row.moneda, row.tipo_cambio, tipoComp, + row.metodo_pago, row.forma_pago, row.uso_cfdi, + row.xml_url, row.pdf_url, row.xml_original, + row.source || 'manual', row.last_sat_sync, row.sat_sync_job_id, + row.created_at, row.updated_at, + parseFloat(row.iva || 0), parseFloat(row.iva || 0) * mxnMultiplier, + parseFloat(row.isr_retenido || 0), parseFloat(row.isr_retenido || 0) * mxnMultiplier, + parseFloat(row.iva_retenido || 0), parseFloat(row.iva_retenido || 0) * mxnMultiplier, + null // contribuyente_id will be updated later + ); + } + + const sql = ` + INSERT INTO cfdis ( + year, month, type, uuid, serie, folio, status, + fecha_emision, rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, + subtotal, subtotal_mxn, descuento, descuento_mxn, total, total_mxn, + moneda, tipo_cambio, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi, + xml_url, pdf_url, xml_original, source, last_sat_sync, sat_sync_job_id, + creado_en, actualizado_en, + iva_traslado, iva_traslado_mxn, isr_retencion, isr_retencion_mxn, + iva_retencion, iva_retencion_mxn, contribuyente_id + ) VALUES ${placeholders.join(',')} + `; + + await targetPool.query(sql, values); + inserted += rows.length; + offset += rows.length; + + if (offset % 10000 === 0 || offset >= total) { + console.log(` ... ${inserted}/${total} CFDIs inserted`); + } + } + + return { inserted, total }; +} + +async function createDefaultContribuyente(target: Pool, tenant: any) { + const entidadRes = await target.query(` + INSERT INTO entidades_gestionadas (id, tipo, nombre, identificador, active, created_at, updated_at) + VALUES (gen_random_uuid(), 'contribuyente', $1, $2, true, NOW(), NOW()) + RETURNING id + `, [tenant.nombre, tenant.rfc]); + + const entidadId = entidadRes.rows[0].id; + + await target.query(` + INSERT INTO contribuyentes (entidad_id, rfc, codigo_postal, domicilio, email_preferences, activos_fijos_usos_excluidos) + VALUES ($1, $2, NULL, '{}'::jsonb, '{}'::jsonb, '[]'::jsonb) + `, [entidadId, tenant.rfc]); + + return entidadId; +} + +async function buildRfcCatalog(target: Pool) { + await target.query(` + INSERT INTO rfcs (rfc, razon_social) + SELECT DISTINCT rfc_emisor, nombre_emisor FROM cfdis WHERE rfc_emisor IS NOT NULL + ON CONFLICT (rfc) DO NOTHING; + `); + await target.query(` + INSERT INTO rfcs (rfc, razon_social) + SELECT DISTINCT rfc_receptor, nombre_receptor FROM cfdis WHERE rfc_receptor IS NOT NULL + ON CONFLICT (rfc) DO NOTHING; + `); + + await target.query(` + UPDATE cfdis c + SET rfc_emisor_id = r.id + FROM rfcs r + WHERE c.rfc_emisor = r.rfc AND c.rfc_emisor_id IS NULL; + `); + await target.query(` + UPDATE cfdis c + SET rfc_receptor_id = r.id + FROM rfcs r + WHERE c.rfc_receptor = r.rfc AND c.rfc_receptor_id IS NULL; + `); +} + +async function migrateTenant(tenant: any) { + console.log(`\n🔄 Processing tenant: ${tenant.rfc} (${tenant.nombre})`); + + const oldDb = tenant.databaseName; + const backupDb = oldDb + BACKUP_SUFFIX; + + const dbList = await adminPool.query(` + SELECT datname FROM pg_database WHERE datname = ANY($1) + `, [[oldDb, backupDb]]); + + const names = dbList.rows.map((r: any) => r.datname); + const hasBackup = names.includes(backupDb); + const hasNew = names.includes(oldDb); + + let backupPool: Pool; + let targetPool: Pool; + let isRetry = false; + + if (hasBackup && hasNew) { + // Retry mode: backup and new both exist. Just re-run ETL after truncating data tables. + console.log(` ⏭️ Retry mode: both backup and new DB exist. Truncating and re-inserting...`); + isRetry = true; + targetPool = new Pool({ host: 'localhost', port: 5432, user: 'postgres', password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb', database: oldDb, max: 5 }); + await targetPool.query(`TRUNCATE cfdis, rfcs, entidades_gestionadas, contribuyentes, activos_fijos_baja, cfdi_descartados, documentos_extras, papeleria_trabajo CASCADE`); + backupPool = new Pool({ host: 'localhost', port: 5432, user: 'postgres', password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb', database: backupDb, max: 3 }); + } else if (hasBackup && !hasNew) { + console.log(` ⏭️ Already migrated (backup exists, new missing). Skipping.`); + return { status: 'skipped', tenant: tenant.rfc }; + } else { + // Fresh migration + console.log(` 1/6 Renaming ${oldDb} → ${backupDb}`); + await renameDatabase(oldDb, backupDb); + + console.log(` 2/6 Creating new database ${oldDb}`); + await createDatabase(oldDb); + + targetPool = new Pool({ host: 'localhost', port: 5432, user: 'postgres', password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb', database: oldDb, max: 5 }); + console.log(` 3/6 Applying SQL migrations...`); + const applied = await migrate(targetPool, tenant.rfc); + console.log(` Migrations applied: ${applied}`); + + backupPool = new Pool({ host: 'localhost', port: 5432, user: 'postgres', password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb', database: backupDb, max: 3 }); + } + + // 4. Migrate CFDIs + console.log(` 4/6 Migrating CFDIs...`); + const contribuyenteId = await createDefaultContribuyente(targetPool, tenant); + console.log(` → Default contribuyente created: ${contribuyenteId}`); + + const cfdiResult = await runEtl(backupPool, targetPool, tenant); + + // Update contribuyente_id + if (cfdiResult.total > 0) { + await targetPool.query(`UPDATE cfdis SET contribuyente_id = $1 WHERE contribuyente_id IS NULL`, [contribuyenteId]); + } + + // 5. Build RFC catalog + console.log(` 5/6 Building RFC catalog...`); + if (cfdiResult.total > 0) { + await buildRfcCatalog(targetPool); + } + + // 6. Validation + console.log(` 6/6 Validating...`); + const valRes = await targetPool.query(`SELECT COUNT(*)::int as cnt FROM cfdis`); + const finalCount = valRes.rows[0].cnt; + const nullContrib = await targetPool.query(`SELECT COUNT(*)::int as cnt FROM cfdis WHERE contribuyente_id IS NULL`); + const dupUuids = await targetPool.query(` + SELECT LOWER(uuid::text), COUNT(*) FROM cfdis WHERE uuid IS NOT NULL + GROUP BY LOWER(uuid::text) HAVING COUNT(*) > 1 + `); + + console.log(` ✅ CFDIs: ${finalCount} (expected: ${cfdiResult.total})`); + console.log(` ✅ Null contribuyente_id: ${nullContrib.rows[0].cnt}`); + console.log(` ✅ Duplicate UUIDs: ${dupUuids.rows.length}`); + + await backupPool.end(); + await targetPool.end(); + + return { + status: 'success', + tenant: tenant.rfc, + cfdis: finalCount, + expected: cfdiResult.total, + }; +} + +async function main() { + console.log('=== Tenant DB Migration: Horux360 → HoruxDespachos ===\n'); + + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + orderBy: { createdAt: 'asc' }, + }); + + console.log(`Found ${tenants.length} active tenants\n`); + + const results: any[] = []; + + for (const tenant of tenants) { + try { + const result = await migrateTenant(tenant); + results.push(result); + } catch (err: any) { + console.error(` ❌ FAILED: ${err.message}`); + results.push({ status: 'failed', tenant: tenant.rfc, error: err.message }); + } + } + + console.log('\n=== Summary ==='); + for (const r of results) { + const icon = r.status === 'success' ? '✅' : r.status === 'skipped' ? '⏭️' : '❌'; + console.log(`${icon} ${r.tenant}: ${r.status}${r.cfdis !== undefined ? ` (${r.cfdis} CFDIs)` : ''}`); + } + + const successCount = results.filter(r => r.status === 'success').length; + const failCount = results.filter(r => r.status === 'failed').length; + console.log(`\nSuccess: ${successCount}, Failed: ${failCount}, Skipped: ${results.length - successCount - failCount}`); + + if (failCount > 0) { + process.exit(1); + } +} + +main() + .catch((err) => { + console.error('Fatal error:', err); + process.exit(1); + }) + .finally(async () => { + await adminPool.end(); + await prisma.$disconnect(); + }); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6c5c3c9..06beef8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -43,6 +43,10 @@ import metricasRoutes from './routes/metricas.routes.js'; const app: Express = express(); +// Trust proxy — nginx está delante en producción, necesitamos que +// express-rate-limit y req.ip usen X-Forwarded-For correctamente. +app.set('trust proxy', 1); + // Security. Helmet default incluye un CSP restrictivo que puede chocar con el // frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de // /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad diff --git a/apps/api/src/services/metabase.service.ts b/apps/api/src/services/metabase.service.ts new file mode 100644 index 0000000..abbf6f4 --- /dev/null +++ b/apps/api/src/services/metabase.service.ts @@ -0,0 +1,167 @@ +/** + * Metabase integration service. + * Automatically registers newly-provisioned tenant databases in Metabase. + */ + +const METABASE_URL = process.env.METABASE_URL || 'http://192.168.10.170:3000'; +const METABASE_USERNAME = process.env.METABASE_USERNAME || 'ialcarazsalazar@consultoria-as.com'; +const METABASE_PASSWORD = process.env.METABASE_PASSWORD || ''; + +// PostgreSQL connection details exposed to Metabase +const PG_HOST = process.env.METABASE_PG_HOST || '192.168.10.90'; +const PG_PORT = parseInt(process.env.METABASE_PG_PORT || '5432', 10); +const PG_USER = process.env.METABASE_PG_USER || 'postgres'; +const PG_PASSWORD = process.env.METABASE_PG_PASSWORD || ''; + +let cachedSessionToken: string | null = null; +let tokenExpiresAt = 0; + +async function getSessionToken(): Promise { + // Re-use cached token if still valid (Metabase sessions last 2 weeks by default) + if (cachedSessionToken && Date.now() < tokenExpiresAt) { + return cachedSessionToken; + } + + if (!METABASE_PASSWORD) { + console.error('[METABASE] METABASE_PASSWORD not configured'); + return null; + } + + try { + const res = await fetch(`${METABASE_URL}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: METABASE_USERNAME, + password: METABASE_PASSWORD, + }), + }); + + if (!res.ok) { + const text = await res.text(); + console.error(`[METABASE] Auth failed: ${res.status} ${text}`); + return null; + } + + const data = await res.json() as { id?: string }; + if (!data.id) { + console.error('[METABASE] Auth response missing session id'); + return null; + } + + cachedSessionToken = data.id; + tokenExpiresAt = Date.now() + 13 * 24 * 60 * 60 * 1000; // 13 days + return cachedSessionToken; + } catch (err) { + console.error('[METABASE] Error fetching session token:', err); + return null; + } +} + +interface RegisterDatabaseInput { + nombre: string; + dbName: string; +} + +export async function registerDatabase(input: RegisterDatabaseInput): Promise { + const sessionToken = await getSessionToken(); + if (!sessionToken) { + console.error('[METABASE] Skipping database registration — no session token'); + return; + } + + if (!PG_PASSWORD) { + console.error('[METABASE] METABASE_PG_PASSWORD not configured'); + return; + } + + const payload = { + name: input.nombre, + engine: 'postgres', + details: { + host: PG_HOST, + port: PG_PORT, + dbname: input.dbName, + user: PG_USER, + password: PG_PASSWORD, + ssl: false, + 'tunnel-enabled': false, + 'advanced-options': false, + 'schema-filters-type': 'all', + }, + auto_run_queries: true, + is_full_sync: true, + is_on_demand: false, + }; + + try { + const res = await fetch(`${METABASE_URL}/api/database`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Metabase-Session': sessionToken, + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const text = await res.text(); + // 409 or duplicate name is not fatal — log and continue + if (res.status === 400 && text.includes('already exists')) { + console.log(`[METABASE] Database "${input.nombre}" already registered`); + return; + } + console.error(`[METABASE] Register database failed: ${res.status} ${text}`); + return; + } + + const data = await res.json() as { id?: number }; + console.log(`[METABASE] Database "${input.nombre}" registered with id=${data.id}`); + } catch (err) { + console.error('[METABASE] Error registering database:', err); + } +} + +export async function deleteDatabase(databaseName: string): Promise { + const sessionToken = await getSessionToken(); + if (!sessionToken) { + console.error('[METABASE] Skipping database deletion — no session token'); + return; + } + + try { + // Find database by name + const listRes = await fetch(`${METABASE_URL}/api/database`, { + headers: { 'X-Metabase-Session': sessionToken }, + }); + + if (!listRes.ok) { + console.error(`[METABASE] Failed to list databases: ${listRes.status}`); + return; + } + + const listData = await listRes.json() as { data?: Array<{ id: number; name: string; details?: { dbname?: string } }> }; + const db = listData.data?.find( + (d) => d.details?.dbname === databaseName || d.name.includes(databaseName) + ); + + if (!db) { + console.log(`[METABASE] No database found for ${databaseName}`); + return; + } + + const deleteRes = await fetch(`${METABASE_URL}/api/database/${db.id}`, { + method: 'DELETE', + headers: { 'X-Metabase-Session': sessionToken }, + }); + + if (!deleteRes.ok) { + console.error(`[METABASE] Delete database failed: ${deleteRes.status}`); + return; + } + + console.log(`[METABASE] Database ${db.id} (${databaseName}) deleted`); + } catch (err) { + console.error('[METABASE] Error deleting database:', err); + } +} diff --git a/apps/api/src/services/tenants.service.ts b/apps/api/src/services/tenants.service.ts index 843b44a..583700f 100644 --- a/apps/api/src/services/tenants.service.ts +++ b/apps/api/src/services/tenants.service.ts @@ -1,6 +1,7 @@ import { prisma, tenantDb } from '../config/database.js'; import { PLANS } from '@horux/shared'; import { emailService } from './email/email.service.js'; +import * as metabaseService from './metabase.service.js'; import { randomBytes } from 'crypto'; import bcrypt from 'bcryptjs'; @@ -54,6 +55,12 @@ export async function createTenant(data: { // 1. Provision a dedicated database for this tenant const databaseName = await tenantDb.provisionDatabase(data.rfc); + // 1b. Register tenant database in Metabase (non-blocking, logs errors only) + metabaseService.registerDatabase({ + nombre: data.nombre, + dbName: databaseName, + }).catch(err => console.error('[METABASE] Register failed:', err)); + // 2. Create tenant record const tenant = await prisma.tenant.create({ data: { @@ -158,6 +165,12 @@ export async function addTenantToOwner(data: { // 1. Provision BD dedicada const databaseName = await tenantDb.provisionDatabase(rfcUpper); + // 1b. Register tenant database in Metabase (non-blocking, logs errors only) + metabaseService.registerDatabase({ + nombre: data.nombre, + dbName: databaseName, + }).catch(err => console.error('[METABASE] Register failed:', err)); + // 2. Crea el tenant const tenant = await prisma.tenant.create({ data: { @@ -342,5 +355,9 @@ export async function deleteTenant(id: string) { if (tenant) { await tenantDb.deprovisionDatabase(tenant.databaseName); tenantDb.invalidatePool(id); + // Remove from Metabase (non-blocking) + metabaseService.deleteDatabase(tenant.databaseName).catch(err => + console.error('[METABASE] Delete failed:', err) + ); } } diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index ffa8bd4..8a6116f 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -7,7 +7,7 @@ import Image from 'next/image'; import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui'; import { login } from '@/lib/api/auth'; import { useAuthStore } from '@/stores/auth-store'; -import { isGlobalAdminRfc } from '@horux/shared'; +import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared'; export default function LoginPage() { const router = useRouter(); @@ -32,7 +32,7 @@ export default function LoginPage() { const userRole = response.user?.role; // Admin global aterriza directo en `/clientes` — su home natural es la // gestión de tenants, no el dashboard operativo del despacho. - const platformRoles = (response.user as { platformRoles?: string[] }).platformRoles; + const platformRoles = (response.user as { platformRoles?: PlatformRole[] }).platformRoles; const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles); if (isGlobalAdmin) { router.push('/clientes'); @@ -58,7 +58,7 @@ export default function LoginPage() {
Horux360 { const subtotal = formData.subtotal || 0; const descuento = formData.descuento || 0; - const iva = formData.ivaTrasladoTraslado || 0; + const iva = formData.ivaTraslado || 0; const isrRetencion = formData.isrRetencion || 0; - const ivaRetencion = formData.ivaTrasladoRetencion || 0; + const ivaRetencion = formData.ivaRetencion || 0; return subtotal - descuento + iva - isrRetencion - ivaRetencion; }; @@ -1641,11 +1641,11 @@ export default function CfdiPage() {