import { Module, DynamicModule, Type } from '@nestjs/common'; import { ServeStaticModule } from '@nestjs/serve-static'; import { ConfigModule, ConfigService } from '@nestjs/config'; import * as fs from 'fs'; import * as path from 'path'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule } from '@nestjs/throttler'; import Redis, { type RedisOptions } from 'ioredis'; import { RedisThrottlerStorage } from './common/throttler/redis-throttler.storage'; import configuration from './config/configuration'; import { validateEnv } from './config/env.validation'; import { SessionModule } from './modules/session/session.module'; import { MessageModule } from './modules/message/message.module'; import { TemplateModule } from './modules/template/template.module'; import { WebhookModule } from './modules/webhook/webhook.module'; import { HealthModule } from './modules/health/health.module'; import { AuthModule } from './modules/auth/auth.module'; import { AuditModule } from './modules/audit/audit.module'; import { EngineModule } from './engine/engine.module'; import { LoggerModule } from './common/services/logger.module'; import { SettingsModule } from './modules/settings/settings.module'; import { InfraModule } from './modules/infra/infra.module'; import { EventsModule } from './modules/events/events.module'; import { ContactModule } from './modules/contact/contact.module'; import { GroupModule } from './modules/group/group.module'; import { ProfileModule } from './modules/profile/profile.module'; import { CallModule } from './modules/call/call.module'; import { LabelModule } from './modules/label/label.module'; import { ChannelModule } from './modules/channel/channel.module'; import { CacheModule } from './common/cache'; import { StorageModule } from './common/storage/storage.module'; import { StatsModule } from './modules/stats/stats.module'; import { MetricsModule } from './modules/metrics/metrics.module'; import { StatusModule } from './modules/status/status.module'; import { CatalogModule } from './modules/catalog/catalog.module'; import { HooksModule } from './core/hooks'; import { PluginsModule } from './core/plugins'; import { PluginsApiModule } from './modules/plugins/plugins.module'; import { AgentToolsModule } from './core/agent-tools/agent-tools.module'; import { IntegrationModule } from './modules/integration/integration.module'; import { SearchModule } from './modules/search/search.module'; // Only import QueueModule if explicitly enabled to avoid Redis connection errors const queueModules: Array = []; if (process.env.QUEUE_ENABLED === 'true') { // eslint-disable-next-line @typescript-eslint/no-require-imports const queueModule = require('./modules/queue/queue.module') as { QueueModule: Type; }; queueModules.push(queueModule.QueueModule); } // Global message search. Opt-out via SEARCH_ENABLED=false: the module (route + provider + registry) // is absent entirely — zero footprint, no DI wiring. Mirrors the queueModules/MCP conditional shape so // an opt-out deployment never even loads the search providers. Default is ON for zero-config first boot. const searchModules: Array = []; if (process.env.SEARCH_ENABLED !== 'false') { searchModules.push(SearchModule); } // Only mount the MCP server if explicitly enabled to avoid startup cost and // the SDK import (which pulls in @modelcontextprotocol/sdk) in non-MCP deployments. const mcpModules: Array = []; if (process.env.MCP_ENABLED === 'true') { // eslint-disable-next-line @typescript-eslint/no-require-imports const { McpModule } = require('./modules/mcp/mcp.module') as typeof import('./modules/mcp/mcp.module'); // eslint-disable-next-line @typescript-eslint/no-require-imports const { version } = require('../package.json') as { version: string }; mcpModules.push( McpModule.forRoot({ basePath: '/mcp', serverInfo: { name: 'openwa', version }, }), ); } // Serve the bundled dashboard SPA from this same NestJS process/port when a build is // present (the production image copies dashboard/dist in). In local dev the build is // absent, so this stays inert and the Vite dev server (:2886) handles the UI. Opt out // explicitly with SERVE_DASHBOARD=false. The path + flags are exported so main.ts can // log a clear status line (served / disabled / build missing) at startup. export const DASHBOARD_DIST = path.resolve(__dirname, '..', 'dashboard', 'dist'); export const dashboardServingEnabled = process.env.SERVE_DASHBOARD !== 'false'; export const dashboardBuildPresent = fs.existsSync(path.join(DASHBOARD_DIST, 'index.html')); const serveStaticModules: Array = []; if (dashboardServingEnabled && dashboardBuildPresent) { serveStaticModules.push( ServeStaticModule.forRoot({ rootPath: DASHBOARD_DIST, // Let Nest own these so unknown API/socket routes return real 404s/JSON rather // than the SPA index.html fallback (Express 5 / path-to-regexp v8 wildcard syntax). exclude: ['/api/{*splat}', '/socket.io/{*splat}', '/mcp', '/mcp/{*splat}'], }), ); } @Module({ imports: [ // Configuration ConfigModule.forRoot({ isGlobal: true, load: [configuration], validate: validateEnv, }), // Main Database (always SQLite - boot config) TypeOrmModule.forRootAsync({ name: 'main', imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => { // Default ON for zero-config first boot. When disabled // (MAIN_DATABASE_SYNCHRONIZE=false), the main-owned migrations create the // api_keys/audit_logs schema instead — never both at once. const synchronize = configService.get('database.synchronize', true); return { name: 'main', type: 'better-sqlite3' as const, database: configService.get('database.database', './data/main.sqlite'), entities: [ __dirname + '/modules/auth/**/*.entity{.ts,.js}', __dirname + '/modules/audit/**/*.entity{.ts,.js}', ], // Dedicated migrations dir for the main connection only (must NOT run the // data-connection migrations, which target session/webhook/message tables). migrations: [__dirname + '/database/migrations-main/*{.ts,.js}'], synchronize, migrationsRun: !synchronize, logging: configService.get('database.logging', false), }; }, }), // Data Storage Database (pluggable - user data) TypeOrmModule.forRootAsync({ name: 'data', imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => { const dbType = configService.get<'sqlite' | 'postgres'>('dataDatabase.type', 'sqlite'); const baseConfig = { entities: [ __dirname + '/modules/session/**/*.entity{.ts,.js}', __dirname + '/modules/webhook/**/*.entity{.ts,.js}', __dirname + '/modules/message/**/*.entity{.ts,.js}', __dirname + '/modules/template/**/*.entity{.ts,.js}', __dirname + '/engine/**/*.entity{.ts,.js}', __dirname + '/modules/integration/**/*.entity{.ts,.js}', ], migrations: [__dirname + '/database/migrations/*{.ts,.js}'], logging: configService.get('dataDatabase.logging', false), }; if (dbType === 'postgres') { // Schema selection: 'public' (default) is a no-op vs the historical behavior. A non-public // schema additionally sets the session search_path via pg's startup `options` parameter so // the project's RAW, unqualified migration SQL (CREATE TABLE "x"..., ALTER TABLE "y"...) // resolves to the configured schema — TypeORM's `schema` option alone does NOT set // search_path, so without this raw DDL would land in `public` while the migration ledger // lands in the configured schema. const schema = configService.get('dataDatabase.schema', 'public'); const useCustomSearchPath = schema && schema !== 'public'; return { ...baseConfig, name: 'data', type: 'postgres' as const, schema, host: configService.get('dataDatabase.host'), port: configService.get('dataDatabase.port'), username: configService.get('dataDatabase.username'), password: configService.get('dataDatabase.password'), database: configService.get('dataDatabase.name', 'openwa'), ssl: configService.get('dataDatabase.ssl', false) ? { rejectUnauthorized: configService.get('dataDatabase.sslRejectUnauthorized', true), } : false, // Never auto-sync Postgres in production; rely on migrations. synchronize: configService.get('dataDatabase.synchronize', false), migrationsRun: true, retryAttempts: 10, retryDelay: 3000, extra: { max: configService.get('dataDatabase.poolSize', 10), // Runtime query/pool timeouts so a stuck query or saturated pool fails fast instead of // hanging requests. statement_timeout bounds live runtime queries; the boot migrations // (migrationsRun above) reset it to 0 per-transaction via SET LOCAL, so a long // CREATE INDEX / backfill at boot is never aborted by it. statement_timeout: configService.get('dataDatabase.statementTimeoutMs', 30000), idleTimeoutMillis: configService.get('dataDatabase.idleTimeoutMs', 30000), connectionTimeoutMillis: configService.get('dataDatabase.connectionTimeoutMs', 10000), // Only set for a non-public schema (see above). `,public` keeps public on the // path so pg_catalog + any public helpers still resolve; the configured schema wins. ...(useCustomSearchPath ? { options: `-c search_path=${schema},public` } : {}), }, }; } // SQLite data DB: schema is MIGRATION-managed by default (DATABASE_SYNCHRONIZE unset/false), // matching configuration.ts and .env.example ("Set false in production"). Set // DATABASE_SYNCHRONIZE=true for zero-config synchronize instead. Computed once: the resolved // value is always a boolean, so a get(..., true) fallback would never fire (and would be a trap). const synchronize = configService.get('dataDatabase.synchronize', false); return { ...baseConfig, name: 'data', type: 'better-sqlite3' as const, database: configService.get('dataDatabase.database', './data/openwa.sqlite'), synchronize, migrationsRun: !synchronize, }; }, }), // Rate limiting. When REDIS_ENABLED, the hit-count storage moves to Redis so limits aggregate // across replicas; otherwise the default in-memory (per-process) storage is used. Default off — // a single-node deployment gains nothing from Redis storage, and it adds a connection dep. ThrottlerModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => { const redisTls = configService.get('redis.tls'); const throttlers = [ { name: 'short', ttl: configService.get('api.rateLimit.shortTtl', 1000), limit: configService.get('api.rateLimit.shortLimit', 10), }, { name: 'medium', ttl: configService.get('api.rateLimit.mediumTtl', 60000), limit: configService.get('api.rateLimit.mediumLimit', 100), }, { name: 'long', ttl: configService.get('api.rateLimit.longTtl', 3600000), limit: configService.get('api.rateLimit.longLimit', 1000), }, ]; // Fail-open on Redis error (see RedisThrottlerStorage), so a Redis outage never blocks the API. const redisStorage = process.env.REDIS_ENABLED === 'true' ? new RedisThrottlerStorage( new Redis({ host: configService.get('redis.host', 'localhost'), port: configService.get('redis.port', 6379), username: configService.get('redis.username'), password: configService.get('redis.password'), connectTimeout: configService.get('redis.connectTimeoutMs', 5000), ...(redisTls ? { tls: redisTls } : {}), maxRetriesPerRequest: 3, }), ) : undefined; return { throttlers, ...(redisStorage ? { storage: redisStorage } : {}) }; }, }), // Core modules HooksModule, // Global hook system for plugin integration PluginsModule, // Global plugin system LoggerModule, CacheModule, StorageModule, AuditModule, EventsModule, // WebSocket real-time events ...queueModules, AuthModule, EngineModule, SessionModule, MessageModule, TemplateModule, WebhookModule, HealthModule, SettingsModule, InfraModule, ContactModule, GroupModule, ProfileModule, // Own-profile API (name / status / picture) CallModule, // Incoming-call API (reject a ringing call) LabelModule, // Phase 3: Labels Management ChannelModule, // Phase 3: Channels/Newsletter StatsModule, // Phase 3: Statistics Dashboard MetricsModule, // Prometheus /api/metrics StatusModule, // Phase 3: Status/Stories API CatalogModule, // Phase 3: Catalog API (WhatsApp Business) PluginsApiModule, // Phase 5: Plugins API AgentToolsModule, // Agent-invocable tool registry (protocol-neutral) IntegrationModule, // Integration Fabric: @Public provider-webhook ingress + fast-ack pipeline ...searchModules, // Global message search (opt-out via SEARCH_ENABLED=false; default ON) ...mcpModules, // MCP Streamable-HTTP server (opt-in via MCP_ENABLED=true) ...serveStaticModules, // Bundled dashboard SPA (production single-port setup) ], }) export class AppModule {}