Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View 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();

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

View 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 };
}