feat: Implement Phase 1 & 2 - Full monorepo architecture

## Backend API (apps/api)
- Express.js server with TypeScript
- JWT authentication with access/refresh tokens
- Multi-tenant middleware (schema per tenant)
- Complete CRUD routes: auth, cfdis, transactions, contacts, categories, metrics, alerts
- SAT integration: CFDI 4.0 XML parser, FIEL authentication
- Metrics engine: 50+ financial metrics (Core, Startup, Enterprise)
- Rate limiting, CORS, Helmet security

## Frontend Web (apps/web)
- Next.js 14 with App Router
- Authentication pages: login, register, forgot-password
- Dashboard layout with Sidebar and Header
- Dashboard pages: overview, cash-flow, revenue, expenses, metrics
- Zustand stores for auth and UI state
- Theme support with flash prevention

## Database Package (packages/database)
- PostgreSQL migrations with multi-tenant architecture
- Public schema: plans, tenants, users, sessions, subscriptions
- Tenant schema: sat_credentials, cfdis, transactions, contacts, accounts, alerts
- Tenant management functions
- Seed data for plans and super admin

## Shared Package (packages/shared)
- TypeScript types: auth, tenant, financial, metrics, reports
- Zod validation schemas for all entities
- Utility functions for formatting

## UI Package (packages/ui)
- Chart components: LineChart, BarChart, AreaChart, PieChart
- Data components: DataTable, MetricCard, KPICard, AlertBadge
- PeriodSelector and Skeleton components

## Infrastructure
- Docker Compose: PostgreSQL 15, Redis 7, MinIO, Mailhog
- Makefile with 25+ development commands
- Development scripts: dev-setup.sh, dev-down.sh
- Complete .env.example template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 11:05:24 +00:00
parent c1321c3f0c
commit a9b1994c48
110 changed files with 40788 additions and 0 deletions

View File

@@ -0,0 +1,406 @@
/**
* PostgreSQL Connection Pool with Multi-Tenant Support
*
* Implements a connection pool manager that supports schema-based multi-tenancy.
* Each tenant has their own schema, and connections can be scoped to a specific tenant.
*/
import { Pool, PoolClient, PoolConfig, QueryResult, QueryResultRow } from 'pg';
import { EventEmitter } from 'events';
// Database configuration interface
export interface DatabaseConfig {
host: string;
port: number;
database: string;
user: string;
password: string;
ssl?: boolean | { rejectUnauthorized: boolean };
max?: number;
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
}
// Tenant context for queries
export interface TenantContext {
tenantId: string;
schemaName: string;
userId?: string;
}
// Query options
export interface QueryOptions {
tenant?: TenantContext;
timeout?: number;
}
// Connection pool events
export interface PoolEvents {
connect: (client: PoolClient) => void;
acquire: (client: PoolClient) => void;
release: (client: PoolClient) => void;
error: (error: Error, client: PoolClient) => void;
remove: (client: PoolClient) => void;
}
// Singleton pool instance
let globalPool: Pool | null = null;
// Pool statistics
interface PoolStats {
totalConnections: number;
idleConnections: number;
waitingRequests: number;
}
/**
* Database connection manager with multi-tenant support
*/
export class DatabaseConnection extends EventEmitter {
private pool: Pool;
private config: DatabaseConfig;
private isConnected: boolean = false;
constructor(config?: DatabaseConfig) {
super();
this.config = config || this.getConfigFromEnv();
const poolConfig: PoolConfig = {
host: this.config.host,
port: this.config.port,
database: this.config.database,
user: this.config.user,
password: this.config.password,
ssl: this.config.ssl,
max: this.config.max || 20,
idleTimeoutMillis: this.config.idleTimeoutMillis || 30000,
connectionTimeoutMillis: this.config.connectionTimeoutMillis || 10000,
};
this.pool = new Pool(poolConfig);
this.setupPoolEvents();
}
/**
* Get configuration from environment variables
*/
private getConfigFromEnv(): DatabaseConfig {
return {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
database: process.env.DB_NAME || 'horux_strategy',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || '',
ssl: process.env.DB_SSL === 'true'
? { rejectUnauthorized: process.env.DB_SSL_REJECT_UNAUTHORIZED !== 'false' }
: false,
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '10000', 10),
};
}
/**
* Setup pool event handlers
*/
private setupPoolEvents(): void {
this.pool.on('connect', (client) => {
this.isConnected = true;
this.emit('connect', client);
});
this.pool.on('acquire', (client) => {
this.emit('acquire', client);
});
this.pool.on('release', (client) => {
this.emit('release', client);
});
this.pool.on('error', (err, client) => {
console.error('Unexpected pool error:', err);
this.emit('error', err, client);
});
this.pool.on('remove', (client) => {
this.emit('remove', client);
});
}
/**
* Test database connection
*/
async connect(): Promise<boolean> {
try {
const client = await this.pool.connect();
await client.query('SELECT 1');
client.release();
this.isConnected = true;
console.log('Database connection established successfully');
return true;
} catch (error) {
console.error('Failed to connect to database:', error);
this.isConnected = false;
throw error;
}
}
/**
* Close all connections in the pool
*/
async disconnect(): Promise<void> {
await this.pool.end();
this.isConnected = false;
console.log('Database connection pool closed');
}
/**
* Get pool statistics
*/
getStats(): PoolStats {
return {
totalConnections: this.pool.totalCount,
idleConnections: this.pool.idleCount,
waitingRequests: this.pool.waitingCount,
};
}
/**
* Check if connected
*/
isPoolConnected(): boolean {
return this.isConnected;
}
/**
* Execute a query with optional tenant context
*/
async query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[],
options?: QueryOptions
): Promise<QueryResult<T>> {
const client = await this.pool.connect();
try {
// Set search path for tenant if provided
if (options?.tenant) {
await client.query(`SET search_path TO ${this.escapeIdentifier(options.tenant.schemaName)}, public`);
}
// Set query timeout if provided
if (options?.timeout) {
await client.query(`SET statement_timeout = ${options.timeout}`);
}
const result = await client.query<T>(text, params);
return result;
} finally {
// Reset search path and timeout before releasing
if (options?.tenant || options?.timeout) {
await client.query('RESET search_path; RESET statement_timeout;').catch(() => {});
}
client.release();
}
}
/**
* Execute a query in the public schema
*/
async queryPublic<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return this.query<T>(text, params);
}
/**
* Execute a query in a tenant schema
*/
async queryTenant<T extends QueryResultRow = QueryResultRow>(
tenant: TenantContext,
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return this.query<T>(text, params, { tenant });
}
/**
* Get a client for transaction handling
*/
async getClient(): Promise<PoolClient> {
return this.pool.connect();
}
/**
* Execute a function within a transaction
*/
async transaction<T>(
fn: (client: PoolClient) => Promise<T>,
options?: QueryOptions
): Promise<T> {
const client = await this.pool.connect();
try {
// Set search path for tenant if provided
if (options?.tenant) {
await client.query(`SET search_path TO ${this.escapeIdentifier(options.tenant.schemaName)}, public`);
}
await client.query('BEGIN');
try {
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
} finally {
if (options?.tenant) {
await client.query('RESET search_path').catch(() => {});
}
client.release();
}
}
/**
* Execute a function within a tenant transaction
*/
async tenantTransaction<T>(
tenant: TenantContext,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
return this.transaction(fn, { tenant });
}
/**
* Escape an identifier (schema name, table name, etc.)
*/
private escapeIdentifier(identifier: string): string {
// Validate identifier format (alphanumeric and underscores only)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(identifier)) {
throw new Error(`Invalid identifier: ${identifier}`);
}
return `"${identifier}"`;
}
/**
* Check if a schema exists
*/
async schemaExists(schemaName: string): Promise<boolean> {
const result = await this.query<{ exists: boolean }>(
`SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1) as exists`,
[schemaName]
);
return result.rows[0]?.exists ?? false;
}
/**
* Get list of all tenant schemas
*/
async getTenantSchemas(): Promise<string[]> {
const result = await this.query<{ schema_name: string }>(
`SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
ORDER BY schema_name`
);
return result.rows.map(row => row.schema_name);
}
/**
* Get the underlying pool (use with caution)
*/
getPool(): Pool {
return this.pool;
}
}
/**
* Get or create the global database connection
*/
export function getDatabase(config?: DatabaseConfig): DatabaseConnection {
if (!globalPool) {
const connection = new DatabaseConnection(config);
globalPool = connection.getPool();
return connection;
}
return new DatabaseConnection(config);
}
/**
* Create a new database connection (non-singleton)
*/
export function createDatabase(config?: DatabaseConfig): DatabaseConnection {
return new DatabaseConnection(config);
}
/**
* Tenant-scoped database client
* Provides a simplified interface for tenant-specific operations
*/
export class TenantDatabase {
private db: DatabaseConnection;
private tenant: TenantContext;
constructor(db: DatabaseConnection, tenant: TenantContext) {
this.db = db;
this.tenant = tenant;
}
/**
* Execute a query in the tenant schema
*/
async query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return this.db.queryTenant<T>(this.tenant, text, params);
}
/**
* Execute a transaction in the tenant schema
*/
async transaction<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
return this.db.tenantTransaction(this.tenant, fn);
}
/**
* Get tenant context
*/
getContext(): TenantContext {
return this.tenant;
}
/**
* Query the public schema (for cross-tenant operations)
*/
async queryPublic<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: unknown[]
): Promise<QueryResult<T>> {
return this.db.queryPublic<T>(text, params);
}
}
/**
* Create a tenant-scoped database client
*/
export function createTenantDatabase(
db: DatabaseConnection,
tenantId: string,
userId?: string
): TenantDatabase {
const tenant: TenantContext = {
tenantId,
schemaName: `tenant_${tenantId}`,
userId,
};
return new TenantDatabase(db, tenant);
}
// Export types
export type { Pool, PoolClient, QueryResult, QueryResultRow };