Initial commit - Horux Despachos NL
This commit is contained in:
234
apps/api/src/config/database.ts
Normal file
234
apps/api/src/config/database.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Pool, type PoolConfig } from 'pg';
|
||||
import { env } from './env.js';
|
||||
import { migrate } from './tenant-migrations.js';
|
||||
|
||||
// ===========================================
|
||||
// Prisma Client (central database: horux360)
|
||||
// ===========================================
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma = globalThis.prisma || new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prisma = prisma;
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// TenantConnectionManager (per-tenant DBs)
|
||||
// ===========================================
|
||||
|
||||
interface PoolEntry {
|
||||
pool: Pool;
|
||||
lastAccess: Date;
|
||||
}
|
||||
|
||||
function parseDatabaseUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port || '5432'),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
};
|
||||
}
|
||||
|
||||
class TenantConnectionManager {
|
||||
private pools: Map<string, PoolEntry> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private dbConfig: { host: string; port: number; user: string; password: string };
|
||||
private migratedPools: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
|
||||
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a connection pool for a tenant's database.
|
||||
* Runs lazy migrations on first access (or after pool invalidation).
|
||||
*/
|
||||
async getPool(
|
||||
tenantId: string,
|
||||
databaseName: string,
|
||||
connectionOverride?: { host: string; port: number; user: string; password: string },
|
||||
): Promise<Pool> {
|
||||
let pool: Pool;
|
||||
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.lastAccess = new Date();
|
||||
pool = entry.pool;
|
||||
} else {
|
||||
const poolConfig: PoolConfig = {
|
||||
host: connectionOverride?.host ?? this.dbConfig.host,
|
||||
port: connectionOverride?.port ?? this.dbConfig.port,
|
||||
user: connectionOverride?.user ?? this.dbConfig.user,
|
||||
password: connectionOverride?.password ?? this.dbConfig.password,
|
||||
database: databaseName,
|
||||
max: 3,
|
||||
idleTimeoutMillis: 300_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
};
|
||||
|
||||
pool = new Pool(poolConfig);
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
|
||||
});
|
||||
|
||||
this.pools.set(tenantId, { pool, lastAccess: new Date() });
|
||||
}
|
||||
|
||||
if (!this.migratedPools.has(tenantId)) {
|
||||
try {
|
||||
await migrate(pool, databaseName);
|
||||
} catch (err) {
|
||||
console.error(`[TenantDB] Migration error for tenant ${tenantId} (${databaseName}):`, err);
|
||||
}
|
||||
this.migratedPools.add(tenantId);
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database for a tenant with all required tables and indexes.
|
||||
*/
|
||||
async provisionDatabase(rfc: string, overrideDatabaseName?: string): Promise<string> {
|
||||
const databaseName = overrideDatabaseName || `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
const exists = await adminPool.query(
|
||||
`SELECT 1 FROM pg_database WHERE datname = $1`,
|
||||
[databaseName]
|
||||
);
|
||||
|
||||
if (exists.rows.length > 0) {
|
||||
throw new Error(`Database ${databaseName} already exists`);
|
||||
}
|
||||
|
||||
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
|
||||
|
||||
const tenantPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: databaseName,
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await migrate(tenantPool, databaseName);
|
||||
} finally {
|
||||
await tenantPool.end();
|
||||
}
|
||||
|
||||
return databaseName;
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: rename database so it can be recovered.
|
||||
*/
|
||||
async deprovisionDatabase(databaseName: string): Promise<void> {
|
||||
// Close any active pool for this tenant
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
// We check pool config to match the database
|
||||
if ((entry.pool as any).options?.database === databaseName) {
|
||||
await entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await adminPool.query(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()
|
||||
`, [databaseName]);
|
||||
|
||||
await adminPool.query(
|
||||
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
|
||||
);
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate (close and remove) a specific tenant's pool.
|
||||
*/
|
||||
invalidatePool(tenantId: string): void {
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
this.migratedPools.delete(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove idle pools (not accessed in last 5 minutes).
|
||||
*/
|
||||
private cleanupIdlePools(): void {
|
||||
const now = Date.now();
|
||||
const maxIdle = 5 * 60 * 1000;
|
||||
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||
entry.pool.end().catch((err) =>
|
||||
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
|
||||
);
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown: close all pools.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
const closePromises = Array.from(this.pools.values()).map((entry) =>
|
||||
entry.pool.end()
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
this.pools.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats about active pools.
|
||||
*/
|
||||
getStats(): { activePools: number; tenantIds: string[] } {
|
||||
return {
|
||||
activePools: this.pools.size,
|
||||
tenantIds: Array.from(this.pools.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const tenantDb = new TenantConnectionManager();
|
||||
89
apps/api/src/config/env.ts
Normal file
89
apps/api/src/config/env.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { z } from 'zod';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Load .env file from the api package root
|
||||
config({ path: resolve(process.cwd(), '.env') });
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.string().default('4000'),
|
||||
DATABASE_URL: z.string(),
|
||||
JWT_SECRET: z.string().min(32),
|
||||
JWT_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||
|
||||
// Frontend URL (for MercadoPago back_url, emails, etc.)
|
||||
FRONTEND_URL: z.string().default('https://horuxfin.com'),
|
||||
|
||||
// FIEL encryption (separate from JWT to allow independent rotation)
|
||||
FIEL_ENCRYPTION_KEY: z.string().min(32),
|
||||
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
|
||||
|
||||
// MercadoPago
|
||||
MP_ACCESS_TOKEN: z.string().optional(),
|
||||
// Token sandbox (TEST-...) para pruebas locales sin cobro real. Conviven con
|
||||
// el de prod para no estar swap-eando manualmente. Solo se usa cuando
|
||||
// MP_USE_SANDBOX=true.
|
||||
MP_ACCESS_TOKEN_SANDBOX: z.string().optional(),
|
||||
// Toggle global: cuando true, todas las llamadas a MP usan
|
||||
// MP_ACCESS_TOKEN_SANDBOX. Default false → usa MP_ACCESS_TOKEN (prod).
|
||||
MP_USE_SANDBOX: z.string().transform(v => v === 'true' || v === '1').default('false'),
|
||||
MP_WEBHOOK_SECRET: z.string().optional(),
|
||||
MP_NOTIFICATION_URL: z.string().optional(),
|
||||
// Solo dev/staging: override del payer_email enviado a MercadoPago. Útil cuando
|
||||
// el owner del tenant tiene el mismo email vinculado al MP_ACCESS_TOKEN
|
||||
// (vendedor) — MP rechaza con "Payer and collector cannot be the same user".
|
||||
// Al setearlo, todas las llamadas a MP usan este email como payer en lugar del
|
||||
// owner real. Production: dejar vacío. (string vacío se trata como undefined
|
||||
// para que prod pueda dejar la línea declarada sin valor sin romper Zod.)
|
||||
MP_TEST_PAYER_EMAIL: z.preprocess(
|
||||
v => (v === '' ? undefined : v),
|
||||
z.string().email().optional(),
|
||||
),
|
||||
|
||||
// SMTP (Gmail Workspace)
|
||||
SMTP_HOST: z.string().default('smtp.gmail.com'),
|
||||
SMTP_PORT: z.string().default('587'),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
|
||||
|
||||
// Admin notification email
|
||||
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
|
||||
|
||||
// Facturapi
|
||||
FACTURAPI_USER_KEY: z.string().optional(),
|
||||
|
||||
// Cloudflare Tunnel (connector BYO-DB)
|
||||
CLOUDFLARE_API_TOKEN: z.string().optional(),
|
||||
CLOUDFLARE_ACCOUNT_ID: z.string().optional(),
|
||||
CLOUDFLARE_TUNNEL_DOMAIN: z.string().default('tunnel.horux.mx'),
|
||||
|
||||
// KMS for encrypting DB connection strings and connector tokens
|
||||
CONNECTOR_ENCRYPTION_KEY: z.string().optional(),
|
||||
|
||||
// Metabase (auto-registro de BDs tenant en Metabase para BI)
|
||||
METABASE_URL: z.string().optional(),
|
||||
METABASE_USERNAME: z.string().optional(),
|
||||
METABASE_PASSWORD: z.string().optional(),
|
||||
METABASE_PG_HOST: z.string().optional(),
|
||||
METABASE_PG_PORT: z.string().optional(),
|
||||
METABASE_PG_USER: z.string().optional(),
|
||||
METABASE_PG_PASSWORD: z.string().optional(),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
|
||||
// Parse CORS origins (comma-separated) into array
|
||||
export function getCorsOrigins(): string[] {
|
||||
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
|
||||
}
|
||||
143
apps/api/src/config/tenant-migrations.ts
Normal file
143
apps/api/src/config/tenant-migrations.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Pool } from 'pg';
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { prisma } from './database.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
const MIGRATIONS_DIR = join(__dirname, '..', 'migrations', 'tenant');
|
||||
|
||||
export interface MigrationFile {
|
||||
version: number;
|
||||
name: string;
|
||||
sql: string;
|
||||
}
|
||||
|
||||
export async function getMigrationFiles(): Promise<MigrationFile[]> {
|
||||
let files: string[];
|
||||
|
||||
try {
|
||||
files = await readdir(MIGRATIONS_DIR);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
console.warn(`[Migrations] Directory not found: ${MIGRATIONS_DIR}`);
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const pattern = /^(\d{3})_(.+)\.sql$/;
|
||||
const migrations: MigrationFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const match = pattern.exec(file);
|
||||
if (!match) continue;
|
||||
|
||||
const version = parseInt(match[1], 10);
|
||||
const name = file;
|
||||
const sql = await readFile(join(MIGRATIONS_DIR, file), 'utf8');
|
||||
|
||||
migrations.push({ version, name, sql });
|
||||
}
|
||||
|
||||
migrations.sort((a, b) => a.version - b.version);
|
||||
return migrations;
|
||||
}
|
||||
|
||||
export async function migrate(pool: Pool, label?: string): Promise<number> {
|
||||
const prefix = label ? `[Migrations] (${label})` : '[Migrations]';
|
||||
|
||||
// Ensure schema_migrations table exists
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
applied_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
// Get already-applied versions
|
||||
const { rows } = await pool.query<{ version: number }>(
|
||||
'SELECT version FROM schema_migrations ORDER BY version'
|
||||
);
|
||||
const appliedVersions = new Set(rows.map((r) => r.version));
|
||||
|
||||
// Get all migration files
|
||||
const migrationFiles = await getMigrationFiles();
|
||||
const pending = migrationFiles.filter((m) => !appliedVersions.has(m.version));
|
||||
|
||||
if (pending.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(`${prefix} Applying ${pending.length} pending migration(s)...`);
|
||||
|
||||
for (const migration of pending) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(migration.sql);
|
||||
await client.query(
|
||||
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
|
||||
[migration.version, migration.name]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
console.log(`${prefix} Applied: ${migration.name}`);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
return pending.length;
|
||||
}
|
||||
|
||||
export async function migrateAll(): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}> {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const parsed = new URL(env.DATABASE_URL);
|
||||
const pool = new Pool({
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port || '5432'),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
database: tenant.databaseName,
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
const applied = await migrate(pool, tenant.rfc);
|
||||
if (applied > 0) {
|
||||
success++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (err: any) {
|
||||
failed++;
|
||||
console.error(
|
||||
`[Migrations] (${tenant.rfc}) Failed: ${err.message}`
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Migrations] Summary — success: ${success}, skipped: ${skipped}, failed: ${failed}`
|
||||
);
|
||||
|
||||
return { success, failed, skipped };
|
||||
}
|
||||
Reference in New Issue
Block a user