Skip to content

Latest commit

 

History

History
1212 lines (956 loc) · 33.3 KB

08-development-guidelines.md

File metadata and controls

1212 lines (956 loc) · 33.3 KB

08 - Development Guidelines

8.1 Project Structure

openwa/
├── src/
│   ├── main.ts                    # Application entry
│   ├── app.module.ts              # Root module
│   ├── common/                    # Shared cache, security, storage, errors, utils
│   ├── config/                    # Runtime config, env validation, bootstrap security, Swagger
│   ├── core/                      # Hook and plugin framework
│   ├── database/                  # TypeORM data sources and migrations
│   ├── engine/                    # WhatsApp engine abstraction, adapters, identity mapping,
│   │                              # and the built-in engine plugins (engine/builtin/)
│   ├── modules/                   # API feature modules
├── test/                          # E2E smoke tests and mocks
├── dashboard/                     # React/Vite dashboard
├── sdk/                           # Client SDKs: go, java, javascript, php, python
├── docs/                          # Documentation
├── scripts/                       # Utility scripts
├── .github/workflows/             # CI and release workflows
├── package.json
├── tsconfig.json
├── nest-cli.json
├── eslint.config.mjs
├── docker-compose.yml
├── docker-compose.dev.yml
├── Dockerfile
└── README.md

8.2 Coding Standards

TypeScript Configuration

// tsconfig.json (abridged — see the file for the commented rationale)
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "ES2023",
    "rootDir": ".",
    "outDir": "./dist",
    "types": ["node", "jest"],
    "declaration": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "strictNullChecks": true,
    "noImplicitAny": true,
    "strictBindCallApply": true,
    "noFallthroughCasesInSwitch": true,
    "strictPropertyInitialization": false,
    "strictFunctionTypes": false,
    "useUnknownInCatchVariables": false
  },
  "include": ["src", "test"],
  "exclude": ["node_modules", "dist", "dashboard"]
}

There are no path aliases — no baseUrl, no paths. Import with relative paths (../common/services/logger.service), not @/…. Under TypeScript 6 the strict family defaults on, so the three false entries above are deliberate opt-outs pending their own migrations (strictPropertyInitialization alone flags around 260 TypeORM entity properties). types must be listed explicitly because TypeScript 6 no longer auto-includes every @types package.

ESLint Configuration

The backend uses ESLint flat config in eslint.config.mjs with type-aware TypeScript rules, Prettier integration, and an architecture guard for controllers. HTTP controllers must call capability services; they must not import IWhatsAppEngine or EngineRegistry, call getEngine(), or resolve an engine via engines.require() / engines.get().

npm run lint
npm run lint:fix

The dashboard has its own package scripts:

cd dashboard
npm run lint

Naming Conventions

// Files: kebab-case
session.controller.ts
send-message.dto.ts
api-key.guard.ts

// Classes: PascalCase
class SessionController {}
class SendMessageDto {}
class ApiKeyGuard {}

// Interfaces: PascalCase with 'I' prefix (optional)
interface ISessionConfig {}
interface SessionConfig {} // Also acceptable

// Functions/Methods: camelCase
function createSession() {}
async sendMessage() {}

// Variables: camelCase
const sessionId = 'abc';
let messageCount = 0;

// Constants: UPPER_SNAKE_CASE
const MAX_RETRY_COUNT = 3;
const DEFAULT_TIMEOUT = 30000;

// Enums: PascalCase with PascalCase values
enum SessionStatus {
  Created = 'created',
  Ready = 'ready',
  Disconnected = 'disconnected',
}

8.3 Module Structure

Standard Module Template

// modules/example/example.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExampleController } from './example.controller';
import { ExampleService } from './example.service';
import { ExampleRepository } from './example.repository';
import { Example } from './entities/example.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Example], 'data')],
  controllers: [ExampleController],
  providers: [ExampleService, ExampleRepository],
  exports: [ExampleService],
})
export class ExampleModule {}

Controller Template

// modules/example/example.controller.ts
import { Controller, Get, Post, Body, Headers, Param, Delete, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ExampleService } from './example.service';
import { CreateExampleDto } from './dto/create-example.dto';
import { ExampleResponseDto } from './dto/example-response.dto';

@ApiTags('examples')
@Controller('examples')
export class ExampleController {
  constructor(private readonly exampleService: ExampleService) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  @ApiOperation({ summary: 'Create example' })
  @ApiResponse({ status: 201, type: ExampleResponseDto })
  async create(
    @Body() dto: CreateExampleDto,
    @Headers('x-request-id') requestId?: string,
  ): Promise<ExampleResponseDto> {
    return this.exampleService.create(dto, { requestId });
  }

  @Get(':id')
  @ApiOperation({ summary: 'Get example by ID' })
  @ApiResponse({ status: 200, type: ExampleResponseDto })
  async findOne(@Param('id') id: string): Promise<ExampleResponseDto> {
    return this.exampleService.findOne(id);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  @ApiOperation({ summary: 'Delete example' })
  async remove(@Param('id') id: string): Promise<void> {
    return this.exampleService.remove(id);
  }
}

Controllers are protected by the global API key guard unless marked with @Public(). Keep controllers thin: validate transport input through DTOs, delegate behavior to services, and never resolve an engine directly from a controller. Engine-specific details belong behind capability services and engine adapters.

A capability service reaches the live engine through EngineRegistry, the narrow port exported by the (global) EngineModule — not through SessionService, which drives the session lifecycle (start/stop/delete/reconnect, owned by SessionEngineLifecycle) and should only be injected by code that actually drives it:

@Injectable()
export class ExampleService {
  constructor(private readonly engines: EngineRegistry) {}

  // require() throws 400 "Session is not started" by default; pass a factory to keep an
  // endpoint's own documented status/message.
  private getEngine(sessionId: string): IWhatsAppEngine {
    return this.engines.require(sessionId);
  }
}

Service Template

// modules/example/example.service.ts
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { ExampleRepository } from './example.repository';
import { CreateExampleDto } from './dto/create-example.dto';
import { Example } from './entities/example.entity';

@Injectable()
export class ExampleService {
  private readonly logger = createLogger('ExampleService');

  constructor(private readonly repository: ExampleRepository) {}

  async create(dto: CreateExampleDto, context?: { requestId?: string }): Promise<Example> {
    this.logger.log(`Creating example: ${dto.name}`, context);

    const example = this.repository.create(dto);
    return this.repository.save(example);
  }

  async findOne(id: string): Promise<Example> {
    const example = await this.repository.findOne({ where: { id } });

    if (!example) {
      throw new NotFoundException(`Example with ID ${id} not found`);
    }

    return example;
  }

  async remove(id: string): Promise<void> {
    const example = await this.findOne(id);
    await this.repository.remove(example);

    this.logger.log(`Deleted example: ${id}`);
  }
}

DTO Template

// modules/example/dto/create-example.dto.ts
import { IsString, IsOptional, MaxLength, IsUrl } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateExampleDto {
  @ApiProperty({ description: 'Example name', example: 'My Example' })
  @IsString()
  @MaxLength(100)
  name: string;

  @ApiPropertyOptional({ description: 'Optional description' })
  @IsOptional()
  @IsString()
  @MaxLength(500)
  description?: string;

  @ApiPropertyOptional({ description: 'Callback URL' })
  @IsOptional()
  @IsUrl({ protocols: ['https'] })
  callbackUrl?: string;
}

Global modules — the fixed set

@Global() makes a module's exported providers injectable everywhere without an import, which is convenient exactly until two modules both assume they own a name and the injector silently picks one. The sanctioned set is therefore frozen at these ten — every one of them a cross-cutting concern that nearly every module would otherwise have to import:

Module Path Why it is global
AuthModule src/modules/auth/auth.module.ts API-key guard + role checks run on nearly every route
AuditModule src/modules/audit/audit.module.ts Every mutation surface writes audit entries
EventsModule src/modules/events/events.module.ts The event bus fans out from every module
EngineModule src/engine/engine.module.ts EngineRegistry, the narrow port to the live engines
PluginsModule src/core/plugins/plugins.module.ts Plugin services are consumed across modules
HooksModule src/core/hooks/hooks.module.ts HookManager is invoked from unrelated modules
CacheModule src/common/cache/cache.module.ts Shared cache service
StorageModule src/common/storage/storage.module.ts File/media storage used across modules
LoggerModule src/common/services/logger.module.ts The logger is needed literally everywhere
AgentToolsModule src/core/agent-tools/agent-tools.module.ts The tool registry is shared by MCP and the REST layer

Do not add an eleventh. A new global needs an ADR-level justification — written down and reviewed with the same weight as an architecture decision record — because the cost (implicit coupling, order-dependent provider resolution, tests that pass only because the whole app booted) is paid by every future module, not by the one that opts out of an explicit imports entry. The default for a new module is the standard template above: declare exports and let consumers imports you.

8.4 Git Workflow

Branch Strategy

gitGraph
    commit id: "initial"
    branch develop
    commit id: "setup"
    branch feature/session-api
    commit id: "session controller"
    commit id: "session service"
    checkout develop
    merge feature/session-api
    branch feature/webhook
    commit id: "webhook impl"
    checkout develop
    merge feature/webhook
    checkout main
    merge develop tag: "v1.0.0"
    checkout develop
    branch hotfix/bug-fix
    commit id: "fix bug"
    checkout main
    merge hotfix/bug-fix tag: "v1.0.1"
    checkout develop
    merge hotfix/bug-fix
Loading

Branch Naming

main            # Production-ready code
develop         # Integration branch
feature/*       # New features
bugfix/*        # Bug fixes
hotfix/*        # Production hotfixes
release/*       # Release preparation

Examples:
feature/session-management
feature/webhook-retry
bugfix/qr-code-timeout
hotfix/security-patch
release/1.0.0

Commit Message Convention

<type>(<scope>): <subject>

<body>

<footer>

Types:
- feat:     New feature
- fix:      Bug fix
- docs:     Documentation
- style:    Formatting (no code change)
- refactor: Code refactoring
- test:     Adding tests
- chore:    Maintenance

Examples:
feat(session): add multi-session support

- Implement session manager for multiple sessions
- Add session limit configuration
- Update documentation

Closes #123

fix(webhook): handle timeout errors gracefully

Previously, webhook timeouts would crash the worker.
Now they are caught and logged properly.

Fixes #456

Pull Request Template

## Description

Brief description of changes

## Type of Change

- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update

## Checklist

- [ ] Tests added/updated
- [ ] Documentation updated
- [ ] Lint passes
- [ ] Self-reviewed

## Screenshots (if applicable)

## Related Issues

Closes #

8.5 Testing Guidelines

Test Structure

Unit tests live next to source files as *.spec.ts. E2E smoke tests live in test/.

src/
├── common/security/ssrf-guard.spec.ts
├── engine/adapters/baileys.adapter.spec.ts
├── modules/session/session.service.spec.ts
└── modules/webhook/webhook.service.spec.ts

test/
├── app.e2e-spec.ts
├── baileys-engine.e2e-spec.ts
├── serve-static.e2e-spec.ts
├── jest-e2e.json
└── setup-e2e.ts

Unit Test Example

// src/modules/session/reconnect-config.spec.ts
import { resolveReconnectConfig } from './session-engine-lifecycle.service';

describe('resolveReconnectConfig', () => {
  it('keeps reconnect settings finite and bounded', () => {
    // Invalid maxReconnectAttempts falls back to the default: unlimited retries (the backoff
    // parks at the 5-minute cap); an invalid baseDelay is clamped up to the 1s minimum.
    expect(resolveReconnectConfig({ maxReconnectAttempts: 'bad', reconnectBaseDelay: -1 })).toEqual({
      maxAttempts: Number.POSITIVE_INFINITY,
      baseDelay: 1000,
    });
  });
});

E2E Test Example

// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('App (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('GET /api/health', () => {
    it('returns health status without an API key', () => {
      return request(app.getHttpServer())
        .get('/api/health')
        .expect(200)
        .expect(res => {
          expect(res.body.status).toBe('ok');
        });
    });
  });
});

Test Coverage Requirements

Run the normal backend checks before opening a PR:

npm test -- --runInBand
npm run test:e2e -- --runInBand
npm run lint

Coverage thresholds are enforced by Jest in package.json. Security-sensitive code under src/common/security/ has stricter thresholds than the global baseline.

8.6 Documentation Standards

Code Documentation

/**
 * Session service handles all session-related operations.
 *
 * @example
 * ```typescript
 * const session = await sessionService.create({ name: 'my-bot' });
 * console.log(session.id);
 * ```
 */
@Injectable()
export class SessionService {
  /**
   * Creates a new WhatsApp session.
   *
   * @param dto - Session creation parameters
   * @returns The created session with QR code if applicable
   * @throws {ConflictException} If session name already exists
   * @throws {ServiceUnavailableException} If max sessions reached
   */
  async create(dto: CreateSessionDto): Promise<Session> {
    // Implementation
  }
}

API Documentation (Swagger)

@ApiTags('sessions')
@Controller('sessions')
export class SessionController {
  @Post()
  @ApiOperation({
    summary: 'Create a new session',
    description: 'Creates a new WhatsApp session and returns QR code for authentication',
  })
  @ApiBody({ type: CreateSessionDto })
  @ApiResponse({
    status: 201,
    description: 'Session created successfully',
    type: SessionResponseDto,
  })
  @ApiResponse({
    status: 409,
    description: 'Session name already exists',
  })
  async create(@Body() dto: CreateSessionDto): Promise<SessionResponseDto> {
    // Implementation
  }
}

8.7 Error Handling

There is no custom exception base class and no global exception filter. Handlers throw NestJS's built-in HTTP exceptions and NestJS's own BaseExceptionFilter renders them, so responses carry the framework default shape { statusCode, message, error }.

throw new BadRequestException('Session is not started');
throw new NotFoundException(`Contact ${contactId} not found`);

Domain errors extend a built-in exception

Where a failure mode recurs across engines, src/common/errors/ defines a named error that extends the NestJS exception carrying the right status, so throwing it from an adapter maps to the intended HTTP code with no filter involved:

Error Extends Status
EngineNotSupportedError NotImplementedException 501
ChannelMediaNotSupportedError NotImplementedException 501
EngineNotReadyError ConflictException 409
EngineRefusedError ForbiddenException 403
EngineTransportError ServiceUnavailableException 503
ChatLabelsUnsupportedError UnprocessableEntityException 422
CallNotFoundError / ChannelNotFoundError / GroupNotFoundError / MessageNotFoundError NotFoundException 404

Add a new one only when the condition is engine-agnostic and recurs; a one-off stays an inline throw new BadRequestException(...).

Do not introduce a response envelope. A ResponseInterceptor/HttpExceptionFilter pair that wrapped every payload in { success, data, meta } was written once, never registered, and has since been deleted — dashboard/src/services/api.ts reads the raw payload.


8.8 Environment Setup

Prerequisites

# Required
- Node.js 22 LTS
- npm 10+
- Docker & Docker Compose
- Git

# Optional (for development)
- VS Code with extensions
- Postman or Insomnia
- pgAdmin or DBeaver

Dependency installation patches whatsapp-web.js for the WhatsApp Web 2.3000.x message-id rename. It applies the patch with GNU patch, falling back to git apply — so Git alone is enough, including on Windows outside Git Bash. With neither available the install still succeeds, but the whatsapp-web.js engine then fails on every send; see docs/12-troubleshooting-faq.md.

The committed lockfile resolves dependencies from the npm registry and works with npm 12's default Git-dependency block. Do not work around EALLOWGIT by changing npm or Git configuration globally; update to a current lockfile instead.

Quick Start

# 1. Clone repository
git clone https://github.com/rmyndharis/OpenWA.git
cd OpenWA

# 2. Install the locked dependencies (also installs dashboard dependencies)
npm ci

# 3. Start API + dashboard in development mode
npm run dev

Use npm install only when intentionally changing dependencies and updating the lockfile.

On first boot the API creates data/.env.generated with a minimal SQLite/local-storage configuration. A project-level .env is optional; real process environment variables take precedence over .env, which takes precedence over data/.env.generated.

For a production-image local smoke test:

docker compose -f docker-compose.dev.yml up -d --build

For production compose:

docker compose up -d
docker compose --profile postgres up -d
docker compose --profile full up -d

VS Code Extensions

// .vscode/extensions.json
{
  "recommendations": [
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode",
    "ms-azuretools.vscode-docker",
    "humao.rest-client",
    "bradlc.vscode-tailwindcss",
    "orta.vscode-jest"
  ]
}

VS Code Settings

// .vscode/settings.json
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "typescript.preferences.importModuleSpecifier": "relative",
  "files.exclude": {
    "**/node_modules": true,
    "**/dist": true
  }
}

Environment Variables

OpenWA supports multiple infrastructure configurations. Choose based on your needs:

Minimal Profile (Development / Single Session)

# Application
NODE_ENV=development
PORT=2785
LOG_LEVEL=debug

# Database: SQLite (zero config)
DATABASE_TYPE=sqlite
DATABASE_NAME=./data/openwa.sqlite
DATABASE_SYNCHRONIZE=true

# Storage: Local filesystem
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=./data/media

# Redis and queue disabled by default
REDIS_ENABLED=false
QUEUE_ENABLED=false

# Optional: seed a known admin key. If omitted, OpenWA generates a random key and writes data/.api-key.
API_MASTER_KEY=

# Session
SESSION_DATA_PATH=./data/sessions

# Engine: whatsapp-web.js = Chromium-based; baileys = browser-free WebSocket
ENGINE_TYPE=whatsapp-web.js
PUPPETEER_HEADLESS=true

# Swagger defaults ON outside production and OFF under NODE_ENV=production.
# Set it explicitly to force either way.
ENABLE_SWAGGER=true

Standard Profile (Production / Multi-Session)

# Application
NODE_ENV=production
PORT=2785
LOG_LEVEL=info

# Database: PostgreSQL
DATABASE_TYPE=postgres
DATABASE_HOST=postgres
DATABASE_PORT=5432
DATABASE_USERNAME=openwa
DATABASE_PASSWORD=<set-a-strong-password>
DATABASE_NAME=openwa
DATABASE_SYNCHRONIZE=false
DATABASE_POOL_SIZE=10

# Storage: Local filesystem
STORAGE_TYPE=local
STORAGE_LOCAL_PATH=/app/data/media

# Cache: Redis
REDIS_ENABLED=true
REDIS_HOST=redis
REDIS_PORT=6379
QUEUE_ENABLED=true

# Security
API_MASTER_KEY=<set-a-strong-initial-admin-key>
API_KEY_PEPPER=<optional-hash-pepper>
CORS_ORIGINS=https://dashboard.example.com

# Session
SESSION_DATA_PATH=/app/data/sessions

# Engine
ENGINE_TYPE=whatsapp-web.js
PUPPETEER_HEADLESS=true
PUPPETEER_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-gpu
ENABLE_SWAGGER=false

Tip

For development, use the minimal profile with SQLite. PostgreSQL, Redis, and S3/MinIO are optional.

8.9 Debugging Guide

NestJS Debugging

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug NestJS",
      "type": "node",
      "request": "launch",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "start:debug"],
      "console": "integratedTerminal",
      "restart": true,
      "autoAttachChildProcesses": true
    },
    {
      "name": "Debug Tests",
      "type": "node",
      "request": "launch",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "test:debug"],
      "console": "integratedTerminal"
    }
  ]
}

Logging Best Practices

// Use createLogger from the shared LoggerService, not console.*
import { Inject, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { createLogger } from '../common/services/logger.service';

@Injectable({ scope: Scope.REQUEST })
export class MyService {
  private readonly logger = createLogger('MyService');

  constructor(@Inject(REQUEST) private readonly request: Request) {}

  async doSomething(id: string): Promise<void> {
    // Log entry with context
    const requestId = this.request?.requestId;
    this.logger.log(`Processing item`, { id, requestId });

    try {
      await this.process(id);
      this.logger.log(`Item processed successfully`, { id, requestId });
    } catch (error) {
      // Log error with full stack
      this.logger.error(`Failed to process item`, error.stack, { id, requestId });
      throw error;
    }
  }
}

Note

Propagate X-Request-ID from controller to service and include it in all logs for easier cross-component tracing.

Request ID Interceptor (Optional)

Use an interceptor to ensure every request has a requestId and propagate it to the response header.

// common/interceptors/request-id.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RequestIdInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const response = context.switchToHttp().getResponse();

    const requestId = request.headers['x-request-id'] || `req_${Date.now()}`;
    request.requestId = requestId;
    response.setHeader('X-Request-ID', requestId);

    return next.handle();
  }
}
// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new RequestIdInterceptor());
  await app.listen(3000);
}

Note

If you use REQUEST injection in a service, make sure the provider is request-scoped (@Injectable({ scope: Scope.REQUEST })) so requestId does not get mixed across requests.

Debug WhatsApp Engine

// Enable verbose logging for whatsapp-web.js
const client = new Client({
  puppeteer: {
    headless: false, // See browser window
    devtools: true, // Open DevTools automatically
  },
});

// Log all events for debugging
const events = ['qr', 'ready', 'authenticated', 'disconnected', 'message'];
events.forEach(event => {
  client.on(event, (...args) => {
    console.log(`[WA Event: ${event}]`, JSON.stringify(args, null, 2));
  });
});

Common Debugging Commands

# Run single test file
npm test -- session.service.spec.ts

# Run tests with verbose output
npm test -- --verbose

# Check for TypeScript errors without emitting (the Nest CLI has no --noEmit; call tsc directly).
# Use the root tsconfig, not tsconfig.build.json — it is the only one that also type-checks the
# colocated *.spec.ts files, which is what CI gates on.
npx tsc --noEmit -p tsconfig.json

# Lint with auto-fix
npm run lint -- --fix

# Debug database queries (TypeORM) — add to .env:
# DATABASE_LOGGING=true
# (there is no DEBUG=typeorm:query switch; both connections read DATABASE_LOGGING)
# PII warning: this logs full queries WITH bound parameters — message bodies and phone
# numbers end up in the application log. Use on a local/debug data set only, never in production.

# View Docker logs (service is `openwa-api` in docker-compose.yml, `openwa` in
# docker-compose.dev.yml — there is no service named `app`)
docker compose logs -f openwa-api

8.10 Performance Best Practices

Database Queries

// ❌ Bad: N+1 query problem
const sessions = await sessionRepo.find();
for (const session of sessions) {
  session.webhooks = await webhookRepo.find({ where: { sessionId: session.id } });
}

// ✅ Good: Use relations
const sessions = await sessionRepo.find({
  relations: ['webhooks'],
});

// ✅ Good: Use QueryBuilder for complex queries
const sessions = await sessionRepo
  .createQueryBuilder('session')
  .leftJoinAndSelect('session.webhooks', 'webhook')
  .where('session.status = :status', { status: 'ready' })
  .orderBy('session.createdAt', 'DESC')
  .take(10)
  .getMany();

Caching Strategy

// CacheService exposes typed helpers; prefer those over ad hoc string keys in feature code.
@Injectable()
export class SessionStatsService {
  constructor(private readonly cache: CacheService) {}

  async getCachedStats(): Promise<SessionStats | null> {
    return this.cache.getSessionsStats();
  }

  async updateCachedStats(stats: SessionStats): Promise<void> {
    await this.cache.setSessionsStats(stats);
  }
}

Async Operations

// ❌ Bad: Sequential execution
const contact1 = await getContact('id1');
const contact2 = await getContact('id2');
const contact3 = await getContact('id3');

// ✅ Good: Parallel execution
const [contact1, contact2, contact3] = await Promise.all([getContact('id1'), getContact('id2'), getContact('id3')]);

// ✅ Good: Batch processing with concurrency limit
import pLimit from 'p-limit';

const limit = pLimit(5); // Max 5 concurrent
const results = await Promise.all(chatIds.map(id => limit(() => sendMessage(id, text))));

Memory Management

// Bound teardown so one stuck browser/socket cannot block shutdown.
@Injectable()
export class EngineTeardownService {
  private readonly logger = createLogger('EngineTeardownService');

  async destroyWithTimeout(sessionId: string, engine: IWhatsAppEngine): Promise<void> {
    const timeout = new Promise<never>((_, reject) => {
      setTimeout(() => reject(new Error('engine.destroy() timed out')), 10_000);
    });

    try {
      await Promise.race([engine.destroy(), timeout]);
    } catch (error) {
      this.logger.warn(`Engine teardown failed for ${sessionId}: ${String(error)}`);
    }
  }
}

8.11 Common Gotchas & Troubleshooting

WhatsApp Engine Issues

## QR Code Not Generated

**Symptom:** Session stuck in 'initializing' status

**Causes & Solutions:**

1. **Chrome/Puppeteer issue**
   - Ensure Chrome for Testing is installed: `ls /usr/local/bin/puppeteer-chrome`
   - Check Puppeteer args: `--no-sandbox --disable-setuid-sandbox`

2. **Previous session data corrupted**
   - Clear the stored auth/session data for the session under `data/sessions`
   - For Baileys, also check `BAILEYS_AUTH_DIR` (default `./data/baileys`)

3. **WhatsApp rate limit**
   - Wait 5-10 minutes before retrying

## Session Disconnects Randomly

**Causes & Solutions:**

1. **Memory pressure**
   - Monitor memory: `docker stats`
   - Increase container memory limit

2. **Network issues**
   - Check WebSocket connection stability
   - Implement auto-reconnect logic

3. **WhatsApp detected automation**
   - Add random delays between messages
   - Avoid sending too many messages quickly

Database Issues

## Connection Pool Exhausted

**Symptom:** "too many clients already" error

**Solution:**

```typescript
// config/typeorm.config.ts
{
  type: 'postgres',
  // Limit pool size
  extra: {
    max: 20, // Default is 10
    connectionTimeoutMillis: 5000,
    idleTimeoutMillis: 30000,
  },
}
```

Migration Fails

Symptom: "relation already exists" error

Solution:

# Check migration status
npm run migration:show

# Revert last migration
npm run migration:revert

# Regenerate migration
npm run migration:generate --name=FixMigration

### TypeScript/NestJS Issues

```markdown
## Circular Dependency

**Symptom:** "Cannot read property 'X' of undefined"

**Solution:**
```typescript
// Use forwardRef for circular deps
@Module({
  imports: [
    forwardRef(() => SessionModule),
  ],
})
export class WebhookModule {}

// In service
constructor(
  @Inject(forwardRef(() => SessionService))
  private readonly sessionService: SessionService,
) {}

DI Token Not Found

Symptom: "Nest can't resolve dependencies"

Solution:

  • Ensure provider is exported from its module
  • Check if module is imported where needed
  • Use @Injectable() decorator on services

### Docker Issues

```markdown
## Container Keeps Restarting

**Check logs:**
```bash
docker compose logs openwa-api --tail 100

Common causes:

  1. Missing environment variables
  2. Database not ready (use depends_on + healthcheck)
  3. Port already in use

Chrome Crashes in Docker

Solution:

# Add shared memory size
docker run --shm-size=2gb openwa

Or in docker-compose.yml:

services:
  openwa-api:
    shm_size: '2gb'

## 8.12 Contributing Guide

### Getting Started

```markdown
1. Fork the repository
2. Create feature branch: `git checkout -b feature/amazing-feature`
3. Make changes following our coding standards
4. Write/update tests
5. Run linter: `npm run lint`
6. Run tests: `npm test`
7. Commit: `git commit -m 'feat(scope): add amazing feature'`
8. Push: `git push origin feature/amazing-feature`
9. Open Pull Request

Code Review Checklist

- [ ] Code follows project style guide
- [ ] Tests are included and passing
- [ ] Documentation is updated
- [ ] No console.log statements
- [ ] Error handling is proper
- [ ] No hardcoded values
- [ ] Security considerations addressed
- [ ] Performance impact considered

Issue Reporting

**Bug Report Template:**

- **Description:** Clear description of the bug
- **Steps to Reproduce:** Numbered steps
- **Expected Behavior:** What should happen
- **Actual Behavior:** What actually happens
- **Environment:** Node version, OS, Docker version
- **Logs:** Relevant error logs