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
This commit is contained in:
Horux Dev
2026-04-28 00:34:41 +00:00
parent 56a05ba767
commit e8dc3aed67
18 changed files with 846 additions and 45 deletions

View File

@@ -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<string, string> = {
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<string, string> = {
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();
});

View File

@@ -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();
});

View File

@@ -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

View File

@@ -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<string | null> {
// 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<void> {
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<void> {
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);
}
}

View File

@@ -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)
);
}
}

View File

@@ -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() {
<div className="flex justify-center mb-4">
<Image
src="/logo.jpg"
alt="Horux360"
alt="Horux Despachos"
width={80}
height={80}
className="rounded-full"

View File

@@ -776,9 +776,9 @@ export default function CfdiPage() {
const calculateTotal = () => {
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() {
<Button
variant="ghost"
size="icon"
onClick={() => handleViewCfdi(cfdi.id)}
disabled={loadingCfdi === cfdi.id}
onClick={() => handleViewCfdi(cfdi.id.toString())}
disabled={loadingCfdi === String(cfdi.id)}
title="Ver factura"
>
{loadingCfdi === cfdi.id ? (
{loadingCfdi === String(cfdi.id) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
@@ -1679,7 +1679,7 @@ export default function CfdiPage() {
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(cfdi.id)}
onClick={() => handleDelete(cfdi.id.toString())}
className="text-destructive hover:text-destructive"
title="Eliminar registro (solo local)"
>

View File

@@ -77,7 +77,7 @@ function RegimenesActivosSection() {
useEffect(() => {
if (activos && catalogo) {
const ids = new Set(activos.map(a => catalogo.find(c => c.clave === a.clave)?.id).filter(Boolean) as number[]);
const ids = new Set(activos.map((a: { clave: string }) => catalogo.find(c => c.clave === a.clave)?.id).filter(Boolean) as number[]);
setSelected(ids);
}
}, [activos, catalogo]);

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layouts/header';
import { KpiCard } from '@horux/shared-ui';
import { BarChart } from '@/components/charts/bar-chart';
@@ -9,7 +8,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { useKpis, useIngresosEgresos, useAlertas, useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import {
TrendingUp,
TrendingDown,
@@ -44,16 +42,7 @@ function shiftDatesOneYear(fechaInicio: string, fechaFin: string, delta: number)
}
export default function DashboardPage() {
const router = useRouter();
const { user } = useAuthStore();
// Admin global no opera sobre datos de despacho — su home natural es
// `/clientes` (gestión de tenants). Redirige al primer render.
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
useEffect(() => {
if (isGlobalAdmin) router.replace('/clientes');
}, [isGlobalAdmin, router]);
const now = new Date();
const { user } = useAuthStore(); const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
const [fechaInicio, setFechaInicio] = useState(defaultRange.start);

View File

@@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
import Link from 'next/link';
import { cn } from '@horux/shared-ui';
import { isDespachoTenant } from '@horux/shared';
import type { Role } from '@horux/shared';
import type { Role, UserInvite } from '@horux/shared';
// ── Horux360 legacy roles ─────────────────────────────────────────────────────
const legacyRoleLabels: Record<string, { label: string; icon: React.ElementType; color: string }> = {
@@ -175,7 +175,7 @@ export default function UsuariosPage() {
return;
}
try {
const newUser = await inviteUsuario.mutateAsync(inviteForm);
const newUser = await inviteUsuario.mutateAsync(inviteForm as UserInvite);
// If role is 'cliente' and RFCs were selected, grant access to each
if (inviteForm.role === 'cliente' && selectedRfcIds.length > 0) {
await Promise.all(
@@ -269,11 +269,11 @@ export default function UsuariosPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{inviteRoles.map(r => (
{inviteRoles.map((r: any) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex flex-col">
<span>{r.label}</span>
{'description' in r && r.description && (
{r.description && (
<span className="text-xs text-muted-foreground">{r.description}</span>
)}
</div>

View File

@@ -7,7 +7,7 @@ import { QueryProvider } from '@/components/providers/query-provider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Horux360 - Análisis Financiero',
title: 'Horux Despachos',
description: 'Plataforma de análisis financiero y gestión fiscal para empresas mexicanas',
};

View File

@@ -177,8 +177,8 @@ export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Moneda</p>
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
{cfdi.typeCambio && cfdi.typeCambio !== 1 && (
<p className="text-xs text-gray-500">TC: {cfdi.typeCambio}</p>
{cfdi.tipoCambio && cfdi.tipoCambio !== 1 && (
<p className="text-xs text-gray-500">TC: {cfdi.tipoCambio}</p>
)}
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">

View File

@@ -121,7 +121,7 @@ export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
let xml = xmlContent;
if (!xml) {
xml = await getCfdiXml(cfdi.id);
xml = await getCfdiXml(cfdi.id.toString());
}
if (!xml) {

View File

@@ -51,13 +51,13 @@ export function SidebarCompact() {
const role = user?.role || 'visor';
const filteredNav = navigation.filter((item) => {
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role as any)) return false;
return true;
});
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
const allNavigation = isGlobalAdmin
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]].filter(Boolean)
: filteredNav;
const handleLogout = async () => {
@@ -86,7 +86,7 @@ export function SidebarCompact() {
<Link href="/dashboard" className="flex items-center gap-2">
<Image
src="/logo.jpg"
alt="Horux360"
alt="Horux Despachos"
width={32}
height={32}
className="rounded-full flex-shrink-0"
@@ -95,7 +95,7 @@ export function SidebarCompact() {
'font-bold text-lg whitespace-nowrap transition-opacity duration-300',
expanded ? 'opacity-100' : 'opacity-0 w-0 overflow-hidden'
)}>
Horux360
Horux Despachos
</span>
</Link>
</div>

View File

@@ -49,13 +49,13 @@ export function SidebarFloating() {
const role = user?.role || 'visor';
const filteredNav = navigation.filter((item) => {
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role as any)) return false;
return true;
});
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
const allNavigation = isGlobalAdmin
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]].filter(Boolean)
: filteredNav;
const handleLogout = async () => {
@@ -76,13 +76,13 @@ export function SidebarFloating() {
<div className="flex items-center gap-3 mb-6 px-2">
<Image
src="/logo.jpg"
alt="Horux360"
alt="Horux Despachos"
width={40}
height={40}
className="rounded-full shadow-lg shadow-primary/25"
/>
<div>
<span className="font-bold text-lg block">Horux360</span>
<span className="font-bold text-lg block">Horux Despachos</span>
<span className="text-xs text-muted-foreground">Análisis Fiscal</span>
</div>
</div>

View File

@@ -108,7 +108,7 @@ export function Sidebar() {
// (Horux 360) no tiene contribuyentes propios y nunca los tendrá.
const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin;
const allNavigation = isGlobalAdmin
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]].filter(Boolean)
: filteredNav;
return (

View File

@@ -51,13 +51,13 @@ export function TopNav() {
const role = user?.role || 'visor';
const filteredNav = navigation.filter((item) => {
if ('feature' in item && item.feature && !hasFeature(plan, item.feature)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role)) return false;
if ('roles' in item && item.roles && !item.roles.includes(role as any)) return false;
return true;
});
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
const allNavigation = isGlobalAdmin
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]].filter(Boolean)
: filteredNav;
const handleLogout = async () => {
@@ -79,7 +79,7 @@ export function TopNav() {
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-lg">H</span>
</div>
<span className="font-bold text-xl">Horux360</span>
<span className="font-bold text-xl">Horux Despachos</span>
</Link>
{/* Navigation */}

View File

@@ -3,9 +3,9 @@ module.exports = {
{
name: 'horux-api',
interpreter: 'node',
script: '/root/Horux/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs',
script: '/root/HoruxDespachos/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs',
args: 'src/index.ts',
cwd: '/root/Horux/apps/api',
cwd: '/root/HoruxDespachos/apps/api',
instances: 1,
exec_mode: 'fork',
autorestart: true,
@@ -21,7 +21,7 @@ module.exports = {
name: 'horux-web',
script: 'node_modules/next/dist/bin/next',
args: 'start',
cwd: '/root/Horux/apps/web',
cwd: '/root/HoruxDespachos/apps/web',
instances: 1,
exec_mode: 'fork',
autorestart: true,