File size: 14,643 Bytes
46252cd
 
 
 
 
 
 
b58ffca
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b58ffca
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b58ffca
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
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<Type | DynamicModule> = [];
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<Type | DynamicModule> = [];
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<Type | DynamicModule> = [];
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<Type | DynamicModule> = [];
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<boolean>('database.synchronize', true);
        return {
          name: 'main',
          type: 'better-sqlite3' as const,
          database: configService.get<string>('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<boolean>('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<boolean>('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<string>('dataDatabase.schema', 'public');
          const useCustomSearchPath = schema && schema !== 'public';
          return {
            ...baseConfig,
            name: 'data',
            type: 'postgres' as const,
            schema,
            host: configService.get<string>('dataDatabase.host'),
            port: configService.get<number>('dataDatabase.port'),
            username: configService.get<string>('dataDatabase.username'),
            password: configService.get<string>('dataDatabase.password'),
            database: configService.get<string>('dataDatabase.name', 'openwa'),

            ssl: configService.get<boolean>('dataDatabase.ssl', false)
              ? {
                  rejectUnauthorized: configService.get<boolean>('dataDatabase.sslRejectUnauthorized', true),
                }
              : false,

            // Never auto-sync Postgres in production; rely on migrations.
            synchronize: configService.get<boolean>('dataDatabase.synchronize', false),
            migrationsRun: true,
            retryAttempts: 10,
            retryDelay: 3000,
            extra: {
              max: configService.get<number>('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<number>('dataDatabase.statementTimeoutMs', 30000),
              idleTimeoutMillis: configService.get<number>('dataDatabase.idleTimeoutMs', 30000),
              connectionTimeoutMillis: configService.get<number>('dataDatabase.connectionTimeoutMs', 10000),
              // Only set for a non-public schema (see above). `<schema>,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<boolean>('dataDatabase.synchronize', false);
        return {
          ...baseConfig,
          name: 'data',
          type: 'better-sqlite3' as const,
          database: configService.get<string>('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<RedisOptions['tls']>('redis.tls');
        const throttlers = [
          {
            name: 'short',
            ttl: configService.get<number>('api.rateLimit.shortTtl', 1000),
            limit: configService.get<number>('api.rateLimit.shortLimit', 10),
          },
          {
            name: 'medium',
            ttl: configService.get<number>('api.rateLimit.mediumTtl', 60000),
            limit: configService.get<number>('api.rateLimit.mediumLimit', 100),
          },
          {
            name: 'long',
            ttl: configService.get<number>('api.rateLimit.longTtl', 3600000),
            limit: configService.get<number>('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<string>('redis.host', 'localhost'),
                  port: configService.get<number>('redis.port', 6379),
                  username: configService.get<string>('redis.username'),
                  password: configService.get<string>('redis.password'),
                  connectTimeout: configService.get<number>('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 {}