From 9e9e874dc5adfd05626ad7c12215573aa272065c Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Tue, 28 Apr 2026 04:17:02 +0000 Subject: [PATCH] feat: re-integrate Metabase auto-registration - Add metabase.service.ts for automatic DB registration - Hook createTenant, addTenantToOwner and deleteTenant to sync with Metabase - Keep all existing features from latest version --- apps/api/src/services/metabase.service.ts | 167 ++++++++++++++++++++++ apps/api/src/services/tenants.service.ts | 17 +++ 2 files changed, 184 insertions(+) create mode 100644 apps/api/src/services/metabase.service.ts 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) + ); } }