diff --git a/README.md b/README.md index ffc6a021..c088a4e7 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,154 @@ -## 🚦 Local Validation: Analytics & Cost Tracking +# TeachLink Backend -To quickly validate feature analytics and cost tracking end-to-end: +[![CI](https://github.com/teachlink/backend/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/teachlink/backend/actions/workflows/ci.yml) +[![Coverage](https://img.shields.io/badge/coverage-70%25%20threshold-brightgreen)](#-ci--testing) +[![Branch Protection](https://img.shields.io/badge/branch%20protection-enabled-blue)](#-branch-protection) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md) -```bash -# 1. Install dependencies -npm install +> **Replace** `teachlink/backend` in the badge URLs above with your actual `org/repo` slug once the repository is on GitHub. -# 2. Start backend (in background) -npm run start:dev & +**TeachLink** is a decentralized platform for sharing, analyzing, and monetizing knowledge. This is the **NestJS backend API** — the core service powering the TeachLink ecosystem. -# 3. Start infra monitoring stack -cd infra/monitoring -cp -n .env.example .env || true -docker compose up -d -cd ../../ +--- -# 4. Send test analytics event -curl -X POST http://localhost:3000/analytics/event \ - -H 'Content-Type: application/json' \ - -d '{"category":"feature","action":"launch_button_clicked"}' +## Quick Start (5 minutes) -# 5. Send test cost event -curl -X POST http://localhost:3000/metrics/cost \ - -H 'Content-Type: application/json' \ - -d '{"amountUsd": 5}' +```bash +# 1. Clone and install +git clone https://github.com/teachlink/backend.git +cd teachlink_backend +pnpm install -# 6. Open Prometheus: http://localhost:9090 and search for feature_events_total and infrastructure_hourly_cost_usd -# 7. Open Grafana: http://localhost:3001 (admin/admin) and view the TeachLink Overview dashboard -``` +# 2. Configure environment +cp .env.example .env +# (edit .env with your settings, defaults work for local dev) -Or run the helper script: +# 3. Start databases +docker compose up -d postgres redis -```bash -./setup-local.sh +# 4. Start the server +pnpm start:dev + +# 5. Verify it works +curl http://localhost:3000/health ``` -To stop the backend: +Open http://localhost:3000/api/docs for the interactive API documentation. + +> **New developer?** See the full [setup guide](docs/setup.md) for detailed instructions, prerequisites, and troubleshooting. + +--- + +## Prerequisites + +| Tool | Version | Install | +|------|---------|---------| +| Node.js | >= 18 | [nodejs.org](https://nodejs.org/) | +| pnpm | >= 8 | `npm install -g pnpm` | +| Docker | >= 24 | [docker.com](https://www.docker.com/products/docker-desktop/) | +| Docker Compose | >= 2.24 | Included with Docker Desktop | +| Git | >= 2 | [git-scm.com](https://git-scm.com/) | + +--- + +## Onboarding Documentation + +| Document | Description | +|----------|-------------| +| [Setup guide](docs/setup.md) | Step-by-step setup from scratch | +| [Troubleshooting guide](docs/troubleshooting.md) | Common issues and fixes | +| [Developer runbook](docs/runbook.md) | Day-to-day operational commands | +| [Migrations guide](docs/migrations.md) | Database migration commands | +| [API documentation](http://localhost:3000/api/docs) | Swagger UI (requires running server) | + +--- + +## Setup Video Tutorial + +A video walkthrough for visual learners. Covers installation, configuration, and first API call. + +**Video link:** https://example.com/setup-video *(placeholder — to be recorded)* + +**What the video covers:** + +1. Installing prerequisites (Node.js, pnpm, Docker) +2. Cloning the repo and installing dependencies +3. Environment variable configuration explained +4. Starting PostgreSQL and Redis with Docker +5. Running database migrations +6. Starting the development server +7. Making your first API request +8. Running the verification script + +--- + +## Available Commands + +| Command | Description | +|---------|-------------| +| `pnpm start:dev` | Start dev server with hot-reload | +| `pnpm build` | Compile TypeScript to `dist/` | +| `pnpm lint` | Lint and auto-fix | +| `pnpm typecheck` | TypeScript type checking | +| `pnpm test` | Run unit tests | +| `pnpm test:e2e` | Run end-to-end tests | +| `pnpm validate:env` | Validate environment variables | +| `pnpm migrate:run` | Run pending migrations | +| `pnpm migrate:status` | Check migration status | +| `pnpm verify` | Run setup verification | + +--- + +## CI / Testing + +Every pull request and every push to `main` / `develop` runs an automated pipeline defined in [`.github/workflows/ci.yml`](.github/workflows/ci.yml). + +### Pipeline stages + +| Stage | Tool | Fails on | +| -------------- | ---------------- | ----------------------------------------- | +| **Install** | `pnpm install` | Dependency resolution error | +| **Lint** | ESLint | Any warning or error (`--max-warnings 0`) | +| **Format** | Prettier | Any file that would be reformatted | +| **Type Check** | `tsc --noEmit` | Any TypeScript error | +| **Build** | NestJS CLI | Compilation failure | +| **Unit Tests** | Jest + ts-jest | Test failure or coverage below 70 % | +| **E2E Tests** | Jest + Supertest | Test failure (uses real Postgres + Redis) | + +### Running checks locally ```bash -kill $(lsof -ti:3000) -``` +# Lint (auto-fix) +pnpm lint -# 🧠 TeachLink Backend +# Lint (CI-strict, no auto-fix) +pnpm lint:ci -[![CI](https://github.com/teachlink/backend/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/teachlink/backend/actions/workflows/ci.yml) -[![Coverage](https://img.shields.io/badge/coverage-70%25%20threshold-brightgreen)](#-ci--testing) -[![Branch Protection](https://img.shields.io/badge/branch%20protection-enabled-blue)](#-branch-protection) -[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md) +# Format check (no rewrite) +pnpm format:check -> **Replace** `teachlink/backend` in the badge URLs above with your actual `org/repo` slug once the repository is on GitHub. +# TypeScript type check only +pnpm typecheck -**TeachLink** is a decentralized platform built to enable technocrats to **share, analyze, and monetize knowledge, skills, and ideas**. This repository contains the **backend API** built with **NestJS**, **TypeORM**, and powered by **Starknet** and **PostgreSQL**, serving as the core of the TeachLink ecosystem. +# Unit tests with coverage report +pnpm test:ci -This is the **NestJS** backend powering TeachLink — offering APIs, authentication, user management, notifications, and knowledge monetization features. +# E2E tests (requires Postgres + Redis running locally) +pnpm test:e2e +``` -- Pagination is limited to a maximum page size of **100** items per request. +### Coverage thresholds + +Configured in `jest.config.js`. The pipeline fails if **any** global metric falls below: + +| Metric | Threshold | +| ---------- | --------- | +| Statements | 70 % | +| Branches | 70 % | +| Functions | 70 % | +| Lines | 70 % | + +Coverage HTML report is uploaded as a GitHub Actions artifact (`coverage-report`) on every run. --- @@ -442,107 +536,48 @@ When replicas are configured, TypeORM replication routes writes to the primary a See [docs/database-read-replicas.md](docs/database-read-replicas.md) for setup, routing behavior, consistent-read guidance, and failover operations. -## �🚀 Getting Started - -### Prerequisites - -- **Node.js** 18+ with npm -- **PostgreSQL** 14+ (or Docker) -- **Redis** 6+ (for caching and queues) -- **Git** for version control - -### Quick Start - -1. **Clone the repository** - -```bash -git clone https://github.com/teachlink/backend.git -cd teachlink_backend -``` +## Getting Started -2. **Install dependencies** - -```bash -npm install -``` +Detailed setup instructions are available in the [setup guide](docs/setup.md). -3. **Set up environment variables** +**Quick reference:** ```bash -cp .env.example .env -# Edit .env with your configuration +pnpm install # Install dependencies +cp .env.example .env # Configure environment +docker compose up -d postgres redis # Start databases +pnpm start:dev # Start dev server +pnpm verify # Verify setup ``` -4. **Start PostgreSQL and Redis** - -```bash -# Using Docker (recommended) -docker-compose up -d postgres redis +### Access the API -# Or install locally and start services -# PostgreSQL: sudo systemctl start postgresql -# Redis: sudo systemctl start redis -``` +| Endpoint | URL | +|----------|-----| +| REST API | http://localhost:3000 | +| API Documentation | http://localhost:3000/api/docs | +| Health Check | http://localhost:3000/health | -5. **Run database migrations** - -```bash -npm run typeorm migration:run -``` +### Docker Compose -6. **Start the development server** +A development `docker-compose.yml` is provided at the project root: ```bash -npm run start:dev -``` - -7. **Access the API** - -- **REST API**: http://localhost:3000 -- **API Documentation**: http://localhost:3000/api -- **Health Check**: http://localhost:3000/health - -### Environment Configuration - -Key environment variables to configure: - -```env -# Database -DATABASE_HOST=localhost -DATABASE_PORT=5432 -DATABASE_USER=postgres -DATABASE_PASSWORD=yourpassword -DATABASE_NAME=teachlink - -# Authentication -JWT_SECRET=your-super-secret-jwt-key -ENCRYPTION_SECRET=your-32-char-encryption-key - -# Redis -REDIS_HOST=localhost -REDIS_PORT=6379 - -# External Services (Optional) -STRIPE_SECRET_KEY=your_stripe_key -AWS_ACCESS_KEY_ID=your_aws_key -AWS_SECRET_ACCESS_KEY=your_aws_secret -``` - -### Docker Setup - -For complete development environment with Docker: +# Start all infrastructure services +docker compose up -d -```bash -# Start all services -docker-compose up -d +# Start only database services (for local dev) +docker compose up -d postgres redis # View logs -docker-compose logs -f +docker compose logs -f -# Stop services -docker-compose down +# Stop everything +docker compose down ``` +For the full monitoring stack (Prometheus, Grafana, Elasticsearch, Kibana), see `infra/monitoring/docker-compose.yml`. + ## 🤝 Contributing We welcome contributions from the community! Please follow our guidelines to ensure a smooth contribution process. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..e3869449 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,108 @@ +# TeachLink Backend - Development Environment +# +# Start with: docker compose up -d +# Stop with: docker compose down +# +# This runs: +# postgres:5432 - Primary database +# redis:6379 - Caching, sessions, and job queues +# app:3000 - NestJS API server (optional, can run locally instead) +# +# To run just the dependencies (recommended for local dev): +# docker compose up -d postgres redis +# npm run start:dev + +version: "3.8" + +networks: + teachlink: + driver: bridge + +volumes: + postgres-data: + redis-data: + +services: + # ────────────────────────────────────────────── + # PostgreSQL 16 - Primary database + # ────────────────────────────────────────────── + postgres: + image: postgres:16-alpine + container_name: teachlink-postgres + restart: unless-stopped + ports: + - "${DATABASE_PORT:-5432}:5432" + environment: + POSTGRES_USER: ${DATABASE_USER:-postgres} + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-postgres} + POSTGRES_DB: ${DATABASE_NAME:-teachlink} + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - teachlink + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-postgres} -d ${DATABASE_NAME:-teachlink}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + # ────────────────────────────────────────────── + # Redis 7 - Caching, session store, and queues + # ────────────────────────────────────────────── + redis: + image: redis:7-alpine + container_name: teachlink-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + command: + - redis-server + - --appendonly yes + - --appendfsync everysec + - --maxmemory 256mb + - --maxmemory-policy allkeys-lru + volumes: + - redis-data:/data + networks: + - teachlink + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + + # ────────────────────────────────────────────── + # NestJS API Server (optional - for dockerized dev) + # ────────────────────────────────────────────── + app: + build: + context: . + dockerfile: Dockerfile + target: production + container_name: teachlink-app + restart: unless-stopped + ports: + - "${APP_PORT:-3000}:3000" + env_file: + - .env + environment: + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + REDIS_HOST: redis + REDIS_PORT: 6379 + NODE_ENV: ${NODE_ENV:-development} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - teachlink + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s diff --git a/docs/migrations.md b/docs/migrations.md index cd3b3f9d..588ca48a 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -1,198 +1,253 @@ # Database Migration Guide -This document covers how to run, roll back, and manage database migrations in the teachLink backend. +How to manage database schema changes safely. + +--- ## Overview -Migrations are managed by the custom `MigrationModule` built on top of TypeORM and NestJS. Every migration implements a `MigrationConfig` interface with two methods: +The TeachLink backend uses **TypeORM migrations** for schema management. Migration files are standard TypeORM `MigrationInterface` classes located in `src/migrations/`. -- `up(connection)` — applies the schema change -- `down(connection)` — fully reverses the schema change +There are two mechanisms for schema updates: -All migrations are registered in `src/migrations/migration.registry.ts` and tracked in the `migrations` database table. +1. **TypeORM `synchronize`** (development only — auto-creates tables from entities) +2. **Explicit migration files** (all environments — controlled, versioned changes) --- -## Migration Files +## How migrations work -Migration files live in `src/migrations/samples/` and follow the naming convention: +Migration files live in `src/migrations/` and follow the naming convention: ``` -NNN-description-of-change.migration.ts +-.ts ``` -Where `NNN` is a zero-padded sequence number (e.g. `001`, `002`). This ensures a deterministic execution order. +Each file exports a class implementing `MigrationInterface` with two methods: -### Current Migrations +- `up(queryRunner)` — applies the schema change +- `down(queryRunner)` — reverses the schema change -| # | Name | Description | -|---|------|-------------| -| 006 | `006-create-migrations-tracking-table` | Creates the `migrations` tracking table | -| 001 | `001-create-users-table` | Creates the `users` table with roles, status, and indexes | -| 002 | `002-create-courses-table` | Creates the `course` table with FK to users | -| 003 | `003-create-course-modules-table` | Creates the `course_module` table | -| 004 | `004-create-lessons-table` | Creates the `lesson` table | -| 005 | `005-create-enrollments-table` | Creates the `enrollment` table | +Example (`src/migrations/1630000000000-CreateMessageTable.ts`): ---- +```typescript +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +export class CreateMessageTable1630000000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'messages', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'senderId', type: 'uuid', isNullable: false }, + { name: 'recipientId', type: 'uuid', isNullable: false }, + { name: 'content', type: 'text', isNullable: false }, + { name: 'createdAt', type: 'timestamptz', default: 'now()' }, + { name: 'readAt', type: 'timestamptz', isNullable: true }, + ], + }), + ); + } -## Running Migrations + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('messages'); + } +} +``` -### Via npm scripts (requires the app to be running) +### Current migrations + +| File | Description | +|------|-------------| +| `1630000000000-CreateMessageTable.ts` | Creates `messages` table with sender/recipient FKs | +| `1680000000000-create-schema-version-and-change-tables.ts` | Creates `schema_version` and `schema_change` tables | +| `1685000001000-add-currency-and-location-fields-to-users.ts` | Adds currency/location to users | +| `1685000001001-add-currency-field-to-courses.ts` | Adds currency to courses | +| `1748600000000-add-course-bulk-operations.ts` | Adds course bulk operations support | +| `1748700000000-add-grading-system.ts` | Adds grading system tables | +| `1748800000000-add-gamification-tiers.ts` | Adds gamification tier tables | +| `1762000000000-create-audit-log-table.ts` | Creates `audit_log` table | +| `AddTimezoneLocalePreferences.ts` | Adds timezone/locale preferences | +| `src/achievements/migrations/1700000000000-CreateAchievementsSchema.ts` | Creates achievements schema | -```bash -# Run all pending migrations -npm run migrate:run +--- -# Check status of all migrations -npm run migrate:status -``` +## Running migrations -### Via HTTP API directly +### Via HTTP API (server must be running) ```bash -# Run all pending migrations +# Start the server first +pnpm start:dev + +# In another terminal, run pending migrations curl -X POST http://localhost:3000/migrations/run -# List all migrations and their status +# Check migration status curl http://localhost:3000/migrations ``` -### Automatic on startup +Or via npm scripts: + +```bash +pnpm migrate:run # Run all pending +pnpm migrate:status # Check status +``` -Set the environment variable to run migrations automatically when the app boots: +### Via TypeORM CLI (alternative) ```bash -AUTO_RUN_MIGRATIONS=true +# Build the project first +pnpm build + +# Run migrations using TypeORM CLI +npx typeorm-ts-node-commonjs migration:run -d src/config/datasource.ts ``` --- -## Rolling Back Migrations +## Development mode (synchronize) + +In development (`NODE_ENV=development`), TypeORM's `synchronize: true` is enabled. This means: + +- Tables are **auto-created** from entity definitions on server startup +- You do NOT need to run migrations for schema changes during active development +- This is fast for prototyping but provides no version tracking + +> **Important:** When `synchronize` is on, running explicit migrations may fail with "relation already exists" because tables are already created. In that case, either: +> - Disable synchronize (`NODE_ENV=production` or edit `database.config.ts`) +> - Drop tables first, then run migrations + +--- + +## Rolling back migrations ### Roll back the last migration ```bash -npm run migrate:rollback -# or curl -X POST http://localhost:3000/migrations/rollback +# or +pnpm migrate:rollback ``` -### Roll back the last N migrations +### Roll back multiple migrations ```bash -# Roll back last 3 migrations -COUNT=3 npm run migrate:rollback:count -# or +# Roll back last 3 curl -X POST http://localhost:3000/migrations/rollback/3 +# or +COUNT=3 pnpm migrate:rollback:count ``` -### Roll back a specific named migration +### Roll back to a specific version ```bash -curl -X PUT http://localhost:3000/migrations/002-create-courses-table/rollback +curl -X POST http://localhost:3000/migrations/rollback/to/002-create-courses-table +# or +MIGRATION_NAME=002-create-courses-table pnpm migrate:rollback:to ``` -> **Note:** This will fail if later migrations that depend on this one are still applied. Roll those back first. - -### Roll back to a specific version - -Rolls back all migrations applied *after* the named migration, leaving the named migration itself in place. +### Reset all migrations (development only) ```bash -MIGRATION_NAME=002-create-courses-table npm run migrate:rollback:to +curl -X DELETE http://localhost:3000/migrations/reset # or -curl -X POST http://localhost:3000/migrations/rollback/to/002-create-courses-table +pnpm migrate:reset ``` +> ⚠️ **Never run reset in production.** It drops all managed tables. + --- -## Resetting All Migrations (Development Only) +## Creating a new migration -This rolls back every applied migration in reverse order and clears the tracking table. +1. Create a new file in `src/migrations/`: ```bash -npm run migrate:reset -# or -curl -X DELETE http://localhost:3000/migrations/reset +# Naming convention: -.ts +touch src/migrations/$(date +%s%N | cut -b1-13)-add-bio-to-users.ts ``` -> ⚠️ **Never run this in production.** It will drop all managed tables. - ---- - -## Creating a New Migration - -1. Create a new file in `src/migrations/samples/` following the naming convention: +2. Implement the migration class: ```typescript -// src/migrations/samples/007-add-bio-to-users.migration.ts -import { Injectable, Logger } from '@nestjs/common'; -import { MigrationConfig } from '../migration.service'; +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; -@Injectable() -export class AddBioToUsersMigration implements MigrationConfig { - name = '007-add-bio-to-users'; - version = '1.0.0'; - dependencies = ['001-create-users-table']; +export class AddBioToUsers implements MigrationInterface { + name = 'AddBioToUsers'; - private readonly logger = new Logger(AddBioToUsersMigration.name); - - async up(connection: any): Promise { - await connection.query(`ALTER TABLE users ADD COLUMN bio TEXT;`); + async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'users', + new TableColumn({ name: 'bio', type: 'text', isNullable: true }), + ); } - async down(connection: any): Promise { - await connection.query(`ALTER TABLE users DROP COLUMN IF EXISTS bio;`); + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('users', 'bio'); } } ``` -2. Register it in `src/migrations/migration.registry.ts`: +3. Build and run: -```typescript -import { AddBioToUsersMigration } from './samples/007-add-bio-to-users.migration'; - -export const MIGRATION_REGISTRY: MigrationConfig[] = [ - // ... existing migrations ... - new AddBioToUsersMigration(), -]; +```bash +pnpm build +# Restart server or run migration ``` --- -## Migration Best Practices +## Best practices -- **Always implement `down()`** as the exact inverse of `up()` — same columns, same types, same constraints, in reverse order. -- **Declare dependencies** in the `dependencies` array. The runner validates them before executing. -- **Never modify an existing migration** that has already been applied to any environment. Create a new migration instead. -- **Test rollbacks locally** before merging. Run `up`, verify, then run `down` and verify the schema is restored. -- **Use `IF EXISTS` / `IF NOT EXISTS`** guards in SQL to make migrations idempotent where possible. -- **Back up your database** before running migrations in staging or production. +| Practice | Why | +|----------|-----| +| Always implement `down()` | Enables safe rollback | +| Never modify an applied migration | Create a new migration instead | +| Test rollbacks locally | Run `up` → verify → `down` → verify | +| Use `IF EXISTS` / `IF NOT NULL` | Makes migrations idempotent | +| Backup database before staging/prod migrations | Safety net | +| Keep migrations small and focused | Easier to review and rollback | +| Use timestamp-based naming | Ensures deterministic ordering | --- -## Environment-Specific Considerations +## Common migration failures + +| Error | Cause | Fix | +|-------|-------|-----| +| `relation already exists` | Table created by `synchronize` or a prior migration | Drop the table or disable `synchronize` | +| `column "X" of relation "Y" already exists` | Duplicate migration | Create a new migration to handle the state | +| `Cannot roll back: later migrations depend` | Dependency chain | Roll back later migrations first | +| `migration:run` returns 404 | Migration endpoints not wired | Check if endpoints exist; use `synchronize` for dev | +| Foreign key violation during migration | Data integrity issue | Clean data, then retry | -| Environment | `AUTO_RUN_MIGRATIONS` | Notes | -|-------------|----------------------|-------| -| Development | `true` (recommended) | Migrations run on every app start | -| Test | `false` | Use `migrate:run` before test suites | +--- + +## Environment-specific settings + +| Environment | `synchronize` | Migrations | +|-------------|---------------|------------| +| Development | `true` (default) | Optional (synchronize handles schema) | +| Test | `true` | Run before test suite | | Staging | `false` | Run manually after deployment | -| Production | `false` | Run manually with a backup in place | +| Production | `false` | Run manually with backup | --- -## Troubleshooting +## Seed data -**Migration stuck in `pending` status** -The migration was registered but never executed. Run `npm run migrate:run`. +Seed data is available for specific modules: -**Migration stuck in `failed` status** -Check the `error_message` column in the `migrations` table. Fix the underlying issue, then either re-run or roll back. +- **Achievements:** `src/achievements/achievements.seed.ts` — seed achievement definitions + +To run seeds, execute the seed function (typically exposed via an API endpoint or called during module initialization). + +--- -**`Dependency not met` error** -A migration's dependency hasn't been applied yet. Check the registry order and run the dependency first. +## Related -**`Cannot roll back` error** -Later migrations that depend on this one are still applied. Roll those back first, then retry. +- [Setup guide](./setup.md) — how to get the database running +- [Troubleshooting guide](./troubleshooting.md) — database connection issues +- [Database config](../src/config/database.config.ts) — connection settings diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 00000000..26cc50d4 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,303 @@ +# Developer Runbook + +Command-driven solutions for the most common day-to-day issues encountered when working on the TeachLink backend. + +--- + +## Fresh install not working + +You cloned the repo and followed setup but the server won't start. + +### Diagnostics + +```bash +# 1. Verify prerequisites +node --version # Need >= 18 +pnpm --version # Need >= 8 + +# 2. Check dependencies are installed +ls node_modules/.package-lock.json 2>/dev/null && echo "installed" || echo "missing" + +# 3. Check .env exists and has required variables +ls .env && grep -q "DATABASE_HOST" .env && echo "env OK" || echo "env missing or incomplete" + +# 4. Run validation +pnpm validate:env + +# 5. Run full verification +pnpm verify +``` + +### Fixes + +```bash +# Missing dependencies +pnpm install + +# Missing .env +cp .env.example .env +# Edit .env with appropriate values + +# Try a clean install +rm -rf node_modules pnpm-lock.yaml +pnpm install +``` + +--- + +## Database not syncing + +Tables are missing or the schema doesn't match entities. + +### Diagnostics + +```bash +# Check database connection +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "SELECT current_database(), version();" + +# List existing tables +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "\dt" + +# Check if TypeORM synchronize is enabled (development default) +grep "synchronize" src/config/database.config.ts +``` + +### Fixes + +```bash +# Option 1: Restart with synchronize (development only) +# In .env, ensure NODE_ENV=development (enables auto-sync) +# Then restart the server + +# Option 2: Drop and recreate the database (development only) +docker compose down +docker volume rm teachlink_backend_postgres-data +docker compose up -d postgres redis +# Then start the server — tables will be created on startup + +# Option 3: Manually create the database +docker exec -it teachlink-postgres psql -U postgres -c "CREATE DATABASE teachlink;" +``` + +--- + +## Redis not connecting + +The server logs show Redis connection errors. + +### Diagnostics + +```bash +# Check Redis container status +docker compose ps redis + +# Test connectivity directly +docker exec -it teachlink-redis redis-cli ping + +# Check .env values +grep -E "REDIS_(HOST|PORT)" .env +``` + +### Fixes + +```bash +# Start Redis +docker compose up -d redis + +# If Redis is running but unreachable, check the host address +# When running the app locally, REDIS_HOST should be localhost (not 'redis') +# When running the app in Docker, REDIS_HOST should be 'redis' (the service name) + +# Restart Redis with clean state +docker compose down redis +docker volume rm teachlink_backend_redis-data +docker compose up -d redis +``` + +--- + +## Migrations failing repeatedly + +Migration commands return errors or the migration API is unreachable. + +### Diagnostics + +```bash +# Check migration status (requires running server) +curl -s http://localhost:3000/migrations | head -20 + +# If 404, migration endpoints may not be wired — check database directly +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "\dt" + +# Check for existing schema_migrations or migrations table +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "\dt" | grep -i migration +``` + +### Fixes + +```bash +# Verify server is running first +curl http://localhost:3000/health + +# If migration endpoints aren't available: +# The app uses TypeORM's synchronize in development mode +# Just restart the server and tables will auto-create + +# For a full reset (development only): +docker compose down +docker volume rm teachlink_backend_postgres-data +docker compose up -d postgres redis +pnpm start:dev +``` + +--- + +## Server won't start + +The `pnpm start:dev` command fails immediately. + +### Diagnostics + +```bash +# Check the actual error +pnpm start:dev 2>&1 | tail -30 + +# Common patterns: +# "ECONNREFUSED" → Database or Redis not running +# "Cannot find module" → Missing dependencies +# "EADDRINUSE" → Port conflict +``` + +### Fixes + +```bash +# Database/Redis not running +docker compose up -d postgres redis + +# Port conflict (e.g., port 3000 in use) +netstat -ano | findstr :3000 +taskkill /PID /F + +# Missing dependencies +pnpm install + +# Build artifacts from a different version +pnpm build +``` + +--- + +## Tests failing + +Unit or E2E tests fail with unexpected errors. + +### Diagnostics + +```bash +# Run unit tests +pnpm test -- --verbose 2>&1 | tail -40 + +# Run E2E tests (requires Postgres + Redis) +pnpm test:e2e 2>&1 | tail -40 + +# Check TypeScript compilation +pnpm typecheck +``` + +### Fixes + +```bash +# If E2E tests fail, ensure services are running +docker compose ps postgres redis + +# If tests timeout, increase test timeout +# In test/jest-e2e.json, increase testTimeout + +# If TypeScript errors, fix the reported type issues +pnpm typecheck + +# Clear Jest cache +pnpm test -- --clearCache +pnpm test:e2e -- --clearCache +``` + +--- + +## Lint or format errors + +Pre-commit hooks or CI fails due to code style issues. + +### Fixes + +```bash +# Auto-fix lint errors +pnpm lint + +# Check formatting +pnpm format:check + +# Auto-format +pnpm format + +# Run type checking +pnpm typecheck +``` + +--- + +## "Hello World" — Quick validation + +Once everything is running, confirm the full stack works: + +```bash +# 1. Check health +curl http://localhost:3000/health + +# 2. Check API docs are served +curl -s http://localhost:3000/api/docs | head -5 + +# 3. Check database via API +curl -s http://localhost:3000/migrations + +# 4. Verify Redis session store is working +# (Login via the API would create a session) +``` + +--- + +## Environment reset — complete fresh start + +If you want to wipe everything and start from scratch: + +```bash +# Stop everything +docker compose down -v # -v removes volumes (data is lost!) + +# Remove node_modules +rm -rf node_modules + +# Remove .env (optional — you'll need to recreate it) +rm .env + +# Re-clone +cd .. +rm -rf teachlink_backend +git clone +cd teachlink_backend + +# Reinstall +pnpm install +cp .env.example .env +# (edit .env) +docker compose up -d postgres redis +pnpm start:dev +``` + +--- + +## Related documentation + +- [Setup guide](./setup.md) — full setup instructions +- [Troubleshooting guide](./troubleshooting.md) — common issues +- [Migrations guide](./migrations.md) — migration commands +- [Contributing guide](../CONTRIBUTING.md) — PR workflow diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 00000000..7325b24e --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,292 @@ +# Setup Guide + +Complete step-by-step instructions to get the TeachLink backend running locally. + +**Estimated time:** 15-30 minutes + +--- + +## Prerequisites + +| Tool | Version | Purpose | +|------|---------|---------| +| Node.js | >= 18.0.0 | JavaScript runtime | +| pnpm | >= 8.x | Package manager (recommended) | +| Docker | >= 24.x | Running PostgreSQL and Redis | +| Docker Compose | >= 2.24.x | Orchestrating containers | +| Git | >= 2.x | Version control | + +**Verify installed versions:** + +```bash +node --version # v18.0.0+ +pnpm --version # 8.x+ +docker --version # 24.x+ +docker compose version # 2.24.x+ +git --version # 2.x+ +``` + +> **pnpm is the primary package manager** for this project. While npm is supported, use pnpm for consistency with CI and the lockfile (`pnpm-lock.yaml`). +> +> Install pnpm: `npm install -g pnpm` (requires npm 9+) + +--- + +## Step 1: Clone the repository + +```bash +git clone https://github.com/teachlink/backend.git +cd teachlink_backend +``` + +--- + +## Step 2: Install dependencies + +```bash +pnpm install +``` + +This installs all production and development dependencies defined in `package.json`. + +--- + +## Step 3: Configure environment variables + +Copy the example environment file: + +```bash +cp .env.example .env +``` + +Edit `.env` with your preferred settings. For local development, these default values work out of the box: + +```env +# ─── Core ────────────────────────────────────── +NODE_ENV=development +PORT=3000 +APP_URL=http://localhost:3000 + +# ─── Database ────────────────────────────────── +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_USER=postgres +DATABASE_PASSWORD=postgres +DATABASE_NAME=teachlink + +# ─── Redis ───────────────────────────────────── +REDIS_HOST=localhost +REDIS_PORT=6379 + +# ─── Auth (generate random values for these) ─── +JWT_SECRET=dev-jwt-secret-change-me +JWT_REFRESH_SECRET=dev-refresh-secret-change-me +ENCRYPTION_SECRET=12345678901234567890123456789012 +SESSION_SECRET=dev-session-secret-change-me + +# ─── SMTP (optional for local dev) ───────────── +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASS= +EMAIL_FROM=noreply@teachlink.local + +# ─── AWS (optional for local dev) ────────────── +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test +AWS_S3_BUCKET=teachlink-local + +# ─── Stripe (optional for local dev) ─────────── +STRIPE_SECRET_KEY=sk_test_placeholder +STRIPE_WEBHOOK_SECRET=whsec_placeholder + +# ─── SendGrid (optional for local dev) ───────── +SENDGRID_API_KEY=SG.placeholder +``` + +> **Security note:** Never commit `.env` to version control. The `.env.example` file is the template — always copy it, never modify it in place. + +Validate your environment: + +```bash +pnpm validate:env +``` + +--- + +## Step 4: Start PostgreSQL and Redis + +### Option A: Docker (recommended) + +Start just the infrastructure services: + +```bash +docker compose up -d postgres redis +``` + +Verify they are healthy: + +```bash +docker compose ps +# Both postgres and redis should show "healthy" status +``` + +### Option B: Local installations + +**PostgreSQL (Windows - using Chocolatey):** + +```powershell +choco install postgresql +pg_ctl -D "C:\Program Files\PostgreSQL\16\data" start +``` + +**Redis (Windows - using WSL or Memurai):** + +```powershell +# Using WSL +wsl --install -d Ubuntu +wsl sudo apt install redis-server +wsl sudo service redis-server start + +# Or install Memurai (Windows-native Redis) +# https://www.memurai.com/ +``` + +--- + +## Step 5: Create the database + +The Docker PostgreSQL container creates the database automatically using `POSTGRES_DB`. If running PostgreSQL natively: + +```bash +# Using the Docker container (it auto-creates the DB) +# Or manually via psql: +docker exec -it teachlink-postgres psql -U postgres -c "CREATE DATABASE teachlink;" +``` + +--- + +## Step 6: Run database migrations + +Migrations are TypeORM `MigrationInterface` classes located in `src/migrations/`. + +Start the server (migrations require the running app): + +```bash +pnpm start:dev +``` + +In a second terminal, run pending migrations via the migration API: + +```bash +# Run all pending migrations +curl -X POST http://localhost:3000/migrations/run + +# Check migration status +curl http://localhost:3000/migrations +``` + +If migration endpoints are not yet wired, you can verify the database schema is set via TypeORM's `synchronize` (enabled in development): + +```bash +# Check that tables were created +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "\dt" +``` + +> **Note:** The `getDatabaseConfig()` sets `synchronize: true` in non-production environments, which auto-creates tables from entities. For production, run explicit migrations. + +--- + +## Step 7: Seed data (optional) + +If seed scripts exist: + +```bash +# Achievement seeds are at src/achievements/achievements.seed.ts +# Run them via the API or CLI when available +``` + +--- + +## Step 8: Verify the server is running + +```bash +# Health check +curl http://localhost:3000/health + +# Expected response: HTTP 200 with status "ok" or similar + +# API documentation +open http://localhost:3000/api/docs +``` + +--- + +## Step 9: Run the verification script + +```bash +pnpm verify +``` + +This checks: +- Node.js version (>= 18) +- `.env` file exists +- Database connectivity +- Redis connectivity +- Server health endpoint + +--- + +## Step 10: Run the tests + +```bash +# Unit tests +pnpm test + +# With coverage +pnpm test:cov + +# E2E tests (requires Postgres + Redis running) +pnpm test:e2e +``` + +--- + +## Available commands + +| Command | Description | +|---------|-------------| +| `pnpm start:dev` | Start dev server with hot-reload | +| `pnpm build` | Compile TypeScript to `dist/` | +| `pnpm lint` | Lint and auto-fix | +| `pnpm typecheck` | TypeScript type checking | +| `pnpm test` | Run unit tests | +| `pnpm test:e2e` | Run end-to-end tests | +| `pnpm validate:env` | Validate environment variables | +| `pnpm migrate:run` | Run pending migrations | +| `pnpm migrate:status` | Check migration status | +| `pnpm verify` | Run setup verification | + +--- + +## Ports and services + +| Port | Service | Purpose | +|------|---------|---------| +| 3000 | NestJS API | Application server | +| 5432 | PostgreSQL | Database | +| 6379 | Redis | Caching, sessions, queues | +| 9090 | Prometheus | Metrics (if monitoring stack is up) | +| 3001 | Grafana | Dashboards (if monitoring stack is up) | +| 9200 | Elasticsearch | Search (if monitoring stack is up) | +| 5601 | Kibana | Log search (if monitoring stack is up) | + +--- + +## Next steps + +- [Migrations guide](./migrations.md) — detailed migration commands +- [Troubleshooting guide](./troubleshooting.md) — common issues and fixes +- [Developer runbook](./runbook.md) — day-to-day operational guide +- [Architecture docs](./complex-algorithms.md) — system design details +- [Contributing guide](../CONTRIBUTING.md) — how to contribute diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 00000000..02883b7a --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,349 @@ +# Troubleshooting Guide + +Common issues new developers encounter when setting up the TeachLink backend, with causes and fixes. + +--- + +## Database connection errors + +### "ECONNREFUSED" or "connect ECONNREFUSED 127.0.0.1:5432" + +**Cause:** PostgreSQL is not running or not accessible on the expected port. + +**Fix:** + +```bash +# Check if PostgreSQL container is running +docker compose ps postgres + +# If not running, start it +docker compose up -d postgres + +# Check Docker service is running +docker info +``` + +If using a local PostgreSQL installation: + +```bash +# Windows (check service) +Get-Service postgresql* + +# If stopped, start it +Start-Service postgresql* +``` + +### "authentication failed" or "password authentication failed" + +**Cause:** `DATABASE_USER` or `DATABASE_PASSWORD` in `.env` does not match the PostgreSQL credentials. + +**Fix:** + +```bash +# Check current .env values +grep -E "DATABASE_(USER|PASSWORD)" .env + +# Verify with psql using those credentials +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "SELECT 1" +``` + +### "database does not exist" or "database 'teachlink' not found" + +**Cause:** The database specified in `DATABASE_NAME` has not been created. + +**Fix:** + +```bash +# Create the database inside the container +docker exec -it teachlink-postgres psql -U postgres -c "CREATE DATABASE teachlink;" + +# Or let Docker create it on startup (set POSTGRES_DB env var) +``` + +### "Connection pool exhausted" or "too many clients" + +**Cause:** Too many open database connections. The pool may be too large or connections are leaking. + +**Fix:** + +```bash +# Check active connections +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "SELECT count(*) FROM pg_stat_activity;" + +# Kill idle connections +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state = 'idle';" + +# Reduce pool size in .env +# DATABASE_POOL_MAX=10 +# DATABASE_POOL_MIN=2 +``` + +--- + +## Redis connection failures + +### "ECONNREFUSED 127.0.0.1:6379" + +**Cause:** Redis is not running. + +**Fix:** + +```bash +# Start Redis via Docker +docker compose up -d redis + +# Verify it's healthy +docker compose ps redis +``` + +### "Redis connection to 127.0.0.1:6379 failed" + +**Cause:** `REDIS_HOST` or `REDIS_PORT` in `.env` is incorrect. + +**Fix:** + +```bash +# Check current settings +grep -E "REDIS_(HOST|PORT)" .env + +# Verify Redis is listening on the correct host/port +docker exec -it teachlink-redis redis-cli ping +# Should return: PONG +``` + +### "NOAUTH Authentication required" + +**Cause:** Redis requires a password but `REDIS_PASSWORD` is not set in `.env`. + +**Fix:** Add `REDIS_PASSWORD=yourpassword` to `.env` or disable password on the Redis server. + +--- + +## Port conflicts + +### "Error: listen EADDRINUSE :::3000" + +**Cause:** Another process is already using port 3000. + +**Fix:** + +```bash +# Windows - find the process using port 3000 +netstat -ano | findstr :3000 + +# Kill the process (replace PID with the actual process ID) +taskkill /PID /F +``` + +### Port conflict with PostgreSQL (5432) or Redis (6379) + +**Cause:** A local installation of PostgreSQL or Redis is already bound to the default port. + +**Fix:** + +```bash +# Stop the local service +# PostgreSQL +net stop postgresql-x64-16 + +# Or change the Docker port mapping in docker-compose.yml: +# ports: +# - "5433:5432" # Maps Docker's 5432 to host's 5433 +``` + +--- + +## Missing environment variables + +### "Config validation error: DATABASE_HOST is required" + +**Cause:** A required environment variable is missing from `.env`. + +**Fix:** + +```bash +# Validate your .env +pnpm validate:env + +# Copy the example and fill in missing values +cp .env.example .env +``` + +Common required variables for local development: + +| Variable | Typical value | +|----------|--------------| +| `DATABASE_HOST` | `localhost` | +| `DATABASE_PORT` | `5432` | +| `DATABASE_USER` | `postgres` | +| `DATABASE_PASSWORD` | `postgres` | +| `DATABASE_NAME` | `teachlink` | +| `REDIS_HOST` | `localhost` | +| `REDIS_PORT` | `6379` | +| `JWT_SECRET` | Any string >= 10 chars | +| `JWT_REFRESH_SECRET` | Any string >= 10 chars | +| `ENCRYPTION_SECRET` | Exactly 32 characters | +| `SESSION_SECRET` | Any string >= 10 chars | +| `SMTP_HOST` | `localhost` (can be dummy) | +| `SMTP_PORT` | `1025` | +| `SMTP_USER` | (empty) | +| `SMTP_PASS` | (empty) | +| `EMAIL_FROM` | `noreply@teachlink.local` | +| `AWS_ACCESS_KEY_ID` | Can be placeholder for local dev | +| `AWS_SECRET_ACCESS_KEY` | Can be placeholder for local dev | +| `AWS_S3_BUCKET` | Can be placeholder for local dev | +| `STRIPE_SECRET_KEY` | Placeholder for local dev | +| `STRIPE_WEBHOOK_SECRET` | Placeholder for local dev | +| `SENDGRID_API_KEY` | Placeholder for local dev | + +--- + +## Migration errors + +### "Migration failed: relation already exists" + +**Cause:** A migration is trying to create a table that already exists (often because `synchronize: true` auto-created it first). + +**Fix:** + +```bash +# Drop the conflicting table and re-run migration +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "DROP TABLE IF EXISTS CASCADE;" +curl -X POST http://localhost:3000/migrations/run +``` + +**Prevention:** In development, you can either: +- Use `synchronize: false` and rely entirely on migrations, or +- Accept that `synchronize` handles schema and skip migrations + +### "Cannot roll back: later migrations depend on this one" + +**Cause:** You're trying to roll back a migration that later migrations depend on. + +**Fix:** Roll back the later migrations first, then the target migration. + +```bash +# Roll back the last 3 migrations +curl -X POST http://localhost:3000/migrations/rollback/3 + +# Or reset all (development only) +curl -X DELETE http://localhost:3000/migrations/reset +``` + +### Migration endpoints return 404 + +**Cause:** The migration HTTP endpoints may not be wired into the application yet. + +**Fix:** Verify tables are created via TypeORM's `synchronize` feature (enabled in development). Check directly in PostgreSQL: + +```bash +docker exec -it teachlink-postgres psql -U postgres -d teachlink -c "\dt" +``` + +--- + +## Docker issues + +### "docker: command not found" + +**Cause:** Docker is not installed or not in PATH. + +**Fix:** Install Docker Desktop from https://www.docker.com/products/docker-desktop/ + +### "Cannot connect to the Docker daemon" + +**Cause:** Docker Desktop is not running. + +**Fix:** + +```bash +# Start Docker Desktop +# Windows: Start menu → Docker Desktop +# Then verify: +docker info +``` + +### "Port 5432 is already allocated" + +**Cause:** Another PostgreSQL instance (local install or another container) is using port 5432. + +**Fix:** + +```bash +# Stop the conflicting container +docker stop + +# Or use a different port in docker-compose.yml: +# postgres: +# ports: +# - "5433:5432" +# Then update .env: DATABASE_PORT=5433 +``` + +### Docker containers exit immediately + +**Cause:** The container may be crashing on startup due to misconfiguration. + +**Fix:** + +```bash +# View container logs +docker compose logs postgres +docker compose logs redis + +# Common fixes: +# - Ensure .env has the required variables +# - Check that ports are not in use +# - Ensure enough disk space for Docker volumes +``` + +--- + +## Server startup issues + +### "Cannot find module '@nestjs/core'" + +**Cause:** Dependencies are not installed. + +**Fix:** + +```bash +pnpm install +``` + +### "TypeScript compilation errors on startup" + +**Cause:** TypeScript code has type errors. + +**Fix:** + +```bash +# Check for type errors +pnpm typecheck + +# Common fixes: +# - Update imports for renamed modules +# - Check for missing type definitions +``` + +### Server starts but immediately exits + +**Cause:** An unhandled error during bootstrap (often database or Redis connection failure). + +**Fix:** + +```bash +# Check the server logs for the actual error +pnpm start:dev 2>&1 | head -50 + +# Most common: database not reachable — check step 4 of setup +``` + +--- + +## Still stuck? + +1. Run the verification script: `pnpm verify` +2. Check the [developer runbook](./runbook.md) +3. Search existing issues: https://github.com/teachlink/backend/issues +4. Ask in the [Telegram community](https://t.me/teachlinkOD) diff --git a/package.json b/package.json index 0c3d2dcf..9ee78f15 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "lint:typed": "eslint \"{src,apps,libs,test}/**/*.ts\" --parser-options=project:tsconfig.json --max-warnings 0", "typecheck": "tsc --project tsconfig.build.json --noEmit", "validate:env": "node scripts/validate-env.js", + "verify": "node scripts/verify-setup.js", "prepare": "husky", "test": "jest", "test:watch": "jest --watch", diff --git a/scripts/verify-setup.js b/scripts/verify-setup.js new file mode 100644 index 00000000..2035e093 --- /dev/null +++ b/scripts/verify-setup.js @@ -0,0 +1,309 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const SEPARATOR = '─'.repeat(56); + +let exitCode = 0; +const results = []; + +function pass(label, detail = '') { + const msg = detail ? `${label} (${detail})` : label; + console.log(` ${'✓'.padEnd(3)}${'PASS'.padEnd(8)}${msg}`); +} + +function fail(label, detail = '') { + const msg = detail ? `${label} — ${detail}` : label; + console.log(` ${'✗'.padEnd(3)}${'FAIL'.padEnd(8)}${msg}`); + exitCode = 1; +} + +function skip(label, reason) { + console.log(` ${'−'.padEnd(3)}${'SKIP'.padEnd(8)}${label} (${reason})`); +} + +function header(title) { + console.log(`\n${SEPARATOR}`); + console.log(` ${title}`); + console.log(SEPARATOR); +} + +async function check(label, fn) { + try { + const result = await fn(); + if (result === true || result === undefined) { + pass(label); + } else { + fail(label, result); + } + } catch (e) { + fail(label, e.message || e); + } + results.push(label); +} + +// ──────────────────────────────────────────────────────────────── +// 1. Node version +// ──────────────────────────────────────────────────────────────── +header('1. Node.js version'); + +await check('Node >= 18.0.0', () => { + const [major] = process.versions.node.split('.').map(Number); + if (major < 18) return `found ${process.version}, need >= 18`; + return true; +}); + +// ──────────────────────────────────────────────────────────────── +// 2. Package manager +// ──────────────────────────────────────────────────────────────── +header('2. Package manager'); + +await check('pnpm installed', () => { + const out = execSync('pnpm --version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + pass('pnpm version', out); + return true; +}); + +await check('Dependencies installed', () => { + if (!fs.existsSync(path.join(process.cwd(), 'node_modules'))) { + return 'node_modules missing — run pnpm install'; + } + if (!fs.existsSync(path.join(process.cwd(), 'node_modules', '@nestjs', 'core'))) { + return 'node_modules incomplete — run pnpm install'; + } + return true; +}); + +// ──────────────────────────────────────────────────────────────── +// 3. Environment file +// ──────────────────────────────────────────────────────────────── +header('3. Environment file'); + +const envPath = path.join(process.cwd(), '.env'); +await check('.env file exists', () => fs.existsSync(envPath) || 'copy .env.example to .env'); + +if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf8'); + + await check('.env has DATABASE_HOST', () => { + if (!/^DATABASE_HOST=/m.test(envContent)) return 'missing DATABASE_HOST'; + return true; + }); + await check('.env has DATABASE_PORT', () => { + if (!/^DATABASE_PORT=/m.test(envContent)) return 'missing DATABASE_PORT'; + return true; + }); + await check('.env has DATABASE_USER', () => { + if (!/^DATABASE_USER=/m.test(envContent)) return 'missing DATABASE_USER'; + return true; + }); + await check('.env has DATABASE_PASSWORD', () => { + if (!/^DATABASE_PASSWORD=/m.test(envContent)) return 'missing DATABASE_PASSWORD'; + return true; + }); + await check('.env has DATABASE_NAME', () => { + if (!/^DATABASE_NAME=/m.test(envContent)) return 'missing DATABASE_NAME'; + return true; + }); + await check('.env has REDIS_HOST', () => { + if (!/^REDIS_HOST=/m.test(envContent)) return 'missing REDIS_HOST'; + return true; + }); + await check('.env has REDIS_PORT', () => { + if (!/^REDIS_PORT=/m.test(envContent)) return 'missing REDIS_PORT'; + return true; + }); + await check('.env has JWT_SECRET', () => { + if (!/^JWT_SECRET=/m.test(envContent)) return 'missing JWT_SECRET'; + return true; + }); +} + +// ──────────────────────────────────────────────────────────────── +// 4. Docker and services +// ──────────────────────────────────────────────────────────────── +header('4. Docker services'); + +await check('Docker available', () => { + execSync('docker info', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + return true; +}); + +await check('Docker Compose available', () => { + execSync('docker compose version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + return true; +}); + +try { + const psOut = execSync('docker compose ps --format json 2>NUL', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + if (psOut) { + let services; + try { + services = JSON.parse(psOut); + } catch { + services = []; + } + const serviceList = Array.isArray(services) ? services : [services]; + + const hasPostgres = serviceList.some((s) => s.Name && s.Name.includes('postgres')); + const hasRedis = serviceList.some((s) => s.Name && s.Name.includes('redis')); + + if (hasPostgres) { + const pg = serviceList.find((s) => s.Name && s.Name.includes('postgres')); + const pgStatus = pg ? pg.Status || pg.Health || 'running' : 'running'; + await check('PostgreSQL container', () => { + if (pgStatus.includes('unhealthy')) return 'container unhealthy'; + if (pgStatus.includes('exited')) return 'container exited'; + return true; + }); + } else { + skip('PostgreSQL container', 'not running — start with: docker compose up -d postgres'); + } + + if (hasRedis) { + const rd = serviceList.find((s) => s.Name && s.Name.includes('redis')); + const rdStatus = rd ? rd.Status || rd.Health || 'running' : 'running'; + await check('Redis container', () => { + if (rdStatus.includes('unhealthy')) return 'container unhealthy'; + if (rdStatus.includes('exited')) return 'container exited'; + return true; + }); + } else { + skip('Redis container', 'not running — start with: docker compose up -d redis'); + } + } else { + skip('Docker services', 'no containers from compose — start with: docker compose up -d'); + } +} catch { + skip('Docker services', 'docker compose not available in this directory'); +} + +// ──────────────────────────────────────────────────────────────── +// 5. Database connectivity +// ──────────────────────────────────────────────────────────────── +header('5. Database connectivity'); + +const envFile = {}; +if (fs.existsSync(envPath)) { + const content = fs.readFileSync(envPath, 'utf8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx === -1) continue; + envFile[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim(); + } +} + +const pgHost = envFile.DATABASE_HOST || process.env.DATABASE_HOST || 'localhost'; +const pgPort = parseInt(envFile.DATABASE_PORT || process.env.DATABASE_PORT || '5432', 10); +const pgUser = envFile.DATABASE_USER || process.env.DATABASE_USER || 'postgres'; +const pgPass = envFile.DATABASE_PASSWORD || process.env.DATABASE_PASSWORD || 'postgres'; +const pgDb = envFile.DATABASE_NAME || process.env.DATABASE_NAME || 'teachlink'; + +await check('PostgreSQL reachable', async () => { + let Client; + try { + Client = require('pg').Client; + } catch { + return 'pg module not found — run pnpm install'; + } + try { + const client = new Client({ + host: pgHost, port: pgPort, user: pgUser, + password: pgPass, database: pgDb, + connectionTimeoutMillis: 5000, + }); + await client.connect(); + const result = await client.query('SELECT 1 AS ok'); + await client.end(); + if (result.rows[0].ok !== 1) return 'query returned unexpected result'; + return true; + } catch (e) { + if (e.code === 'ECONNREFUSED') return `connection refused at ${pgHost}:${pgPort}`; + if (e.code === 'ENOTFOUND') return `host not found: ${pgHost}`; + if (e.code === '28P01') return 'authentication failed — check DATABASE_USER/PASSWORD'; + if (e.code === '3D000') return `database "${pgDb}" does not exist`; + return e.message; + } +}); + +// ──────────────────────────────────────────────────────────────── +// 6. Redis connectivity +// ──────────────────────────────────────────────────────────────── +header('6. Redis connectivity'); + +const redisHost = envFile.REDIS_HOST || process.env.REDIS_HOST || 'localhost'; +const redisPort = parseInt(envFile.REDIS_PORT || process.env.REDIS_PORT || '6379', 10); + +await check('Redis reachable', async () => { + let Redis; + try { + Redis = require('ioredis'); + } catch { + return 'ioredis module not found — run pnpm install'; + } + try { + const redis = new Redis({ + host: redisHost, port: redisPort, + maxRetriesPerRequest: 1, + retryStrategy: () => null, + lazyConnect: true, + }); + await redis.connect(); + const pong = await redis.ping(); + await redis.quit(); + if (pong !== 'PONG') return `unexpected ping response: ${pong}`; + return true; + } catch (e) { + if (e.code === 'ECONNREFUSED') return `connection refused at ${redisHost}:${redisPort}`; + if (e.code === 'ENOTFOUND') return `host not found: ${redisHost}`; + return e.message; + } +}); + +// ──────────────────────────────────────────────────────────────── +// 7. Server health +// ──────────────────────────────────────────────────────────────── +header('7. Server health'); + +const appPort = parseInt(envFile.PORT || process.env.PORT || '3000', 10); + +await check('Health endpoint responds', async () => { + try { + const res = await fetch(`http://localhost:${appPort}/health`, { + signal: AbortSignal.timeout(5000), + }); + if (res.status !== 200) return `HTTP ${res.status}`; + return true; + } catch (e) { + if (e.name === 'TimeoutError' || e.name === 'AbortError') { + return `timed out after 5s — is the server running on port ${appPort}?`; + } + if (e.code === 'ECONNREFUSED') { + return `connection refused on port ${appPort} — start with: pnpm start:dev`; + } + return e.message; + } +}); + +// ──────────────────────────────────────────────────────────────── +// Summary +// ──────────────────────────────────────────────────────────────── +console.log(`\n${SEPARATOR}`); +if (exitCode === 0) { + console.log(' ✅ All checks passed — your development environment is ready!'); +} else { + console.log(' ❌ Some checks failed. See details above and refer to:'); + console.log(' docs/setup.md — step-by-step setup guide'); + console.log(' docs/troubleshooting.md — common issues and fixes'); +} +console.log(SEPARATOR); + +process.exit(exitCode); diff --git a/src/app.module.ts b/src/app.module.ts index 81dd098b..870a9311 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,6 +25,8 @@ import { DeepLinkModule } from './deep-link/deep-link.module'; import { InvoicesModule } from './payments/invoices/invoices.module'; import { ReportingModule } from './payments/reporting/reporting.module'; import { HealthModule } from './health/health.module'; +import { QueueModule } from './queues/queue.module'; +import { WorkersBridgeModule } from './workers/bridge/workers-bridge.module'; // ✅ keep BOTH modules import { ReadReplicaModule } from './database/read-replica'; @@ -54,6 +56,8 @@ const featureFlags = loadFeatureFlags(); InvoicesModule, ReportingModule, HealthModule, + QueueModule, + WorkersBridgeModule, // ✅ always include read replicas (or wrap if needed) ReadReplicaModule, diff --git a/src/common/constants/queue.constants.ts b/src/common/constants/queue.constants.ts index 21e3ca63..3217a2cb 100644 --- a/src/common/constants/queue.constants.ts +++ b/src/common/constants/queue.constants.ts @@ -9,6 +9,7 @@ export const QUEUE_NAMES = { USER_DATA_EXPORT: 'user-data-export', SUBSCRIPTIONS: 'subscriptions', WEBHOOKS: 'webhooks', + DEAD_LETTER: 'dead-letter', } as const; export const JOB_NAMES = { // Email queue diff --git a/src/messaging/messaging.module.ts b/src/messaging/messaging.module.ts index 8918e28d..e355fb17 100644 --- a/src/messaging/messaging.module.ts +++ b/src/messaging/messaging.module.ts @@ -13,9 +13,6 @@ import { TracingService } from './tracing/tracing.service'; @Module({ imports: [ TypeOrmModule.forFeature([Message]), - BullModule.forRoot({ - redis: process.env.QUEUE_REDIS_URL || process.env.REDIS_URL || 'redis://127.0.0.1:6379', - }), BullModule.registerQueue({ name: QUEUE_NAMES.MESSAGE_QUEUE }), ], providers: [ diff --git a/src/monitoring/metrics/metrics-collection.service.ts b/src/monitoring/metrics/metrics-collection.service.ts index f37f692a..106f1c4b 100644 --- a/src/monitoring/metrics/metrics-collection.service.ts +++ b/src/monitoring/metrics/metrics-collection.service.ts @@ -87,6 +87,15 @@ export class MetricsCollectionService implements OnModuleInit { /** Queue job processing duration, labelled by queue_name and job_type */ public queueProcessingTime: Histogram; + /** Current number of waiting jobs per queue */ + public queueWaitingJobs: Gauge; + + /** Current number of active jobs per queue */ + public queueActiveJobs: Gauge; + + /** Total number of failed jobs per queue */ + public queueFailedJobs: Gauge; + // ── Business Metrics – Email ─────────────────────────────────────────────── /** Total email campaigns sent, labelled by campaign_type and status */ @@ -209,6 +218,18 @@ export class MetricsCollectionService implements OnModuleInit { this.queueProcessingTime.observe({ queue_name: queueName, job_type: jobType }, duration); } + updateQueueWaitingJobs(queueName: string, count: number): void { + this.queueWaitingJobs.set({ queue_name: queueName }, count); + } + + updateQueueActiveJobs(queueName: string, count: number): void { + this.queueActiveJobs.set({ queue_name: queueName }, count); + } + + updateQueueFailedJobs(queueName: string, count: number): void { + this.queueFailedJobs.set({ queue_name: queueName }, count); + } + // ── Recording helpers – Email ───────────────────────────────────────────── recordEmailCampaignSent(campaignType: string, status: string): void { @@ -388,6 +409,27 @@ export class MetricsCollectionService implements OnModuleInit { registers: [this.registry], }); + this.queueWaitingJobs = new Gauge({ + name: 'queue_waiting_jobs', + help: 'Current number of waiting jobs per queue', + labelNames: ['queue_name'], + registers: [this.registry], + }); + + this.queueActiveJobs = new Gauge({ + name: 'queue_active_jobs', + help: 'Current number of active jobs per queue', + labelNames: ['queue_name'], + registers: [this.registry], + }); + + this.queueFailedJobs = new Gauge({ + name: 'queue_failed_jobs_total', + help: 'Total number of failed jobs per queue', + labelNames: ['queue_name'], + registers: [this.registry], + }); + // Email this.emailCampaignsSent = new Counter({ name: 'email_campaigns_sent_total', diff --git a/src/queues/README.md b/src/queues/README.md index b2386418..6ca735fa 100644 --- a/src/queues/README.md +++ b/src/queues/README.md @@ -465,6 +465,71 @@ curl http://localhost:3000/queues/metrics curl http://localhost:3000/queues/health ``` +## Optimization Architecture + +### Centralized Queue Config (`QueueModule`) + +`src/queues/queue.module.ts` is a `@Global()` module that registers all 11 Bull queues in one place. It provides `QueueService`, `PrioritizationService`, `RetryStrategyService`, and `QueueMetricsService` globally. + +### Workers Bridge (`WorkersBridgeService`) + +`src/workers/bridge/workers-bridge.service.ts` bridges Bull queue consumers to the existing worker classes. On `onModuleInit`, it: +1. Binds each queue to its worker's `.handle()` method +2. Wraps processing with Prometheus metric recording (`queue_processing_duration_seconds`) +3. Registers `failed` event handlers that forward permanently failed jobs to the dead-letter queue + +### Priority Queue + +`PrioritizationService` maps `JobPriority` enum (CRITICAL=1, HIGH=2, NORMAL=3, LOW=4, BACKGROUND=5) to Bull's native priority (0-4, lower=higher). `QueueService.addJob()` defaults to `NORMAL` if no priority is specified, ensuring all jobs participate in Bull's priority sorting. + +### Dead Letter Queue + +`DeadLetterService` receives failed jobs from Bull's `failed` event and re-queues them to the `DEAD_LETTER` queue with the original job metadata, error reason, and stack trace. This replaces the in-process-only failure tracking. + +### Retry Strategies + +`RetryStrategyService` exposes `RETRY_STRATEGIES` (EMAIL, PAYMENT, NOTIFICATION, BACKUP, REPORT, DEFAULT) as injectable config. Pass a strategy key to `QueueService.addJob()` to apply automatic backoff and max attempts. + +### Monitoring + +- `MetricsCollectionService` records `queue_processing_duration_seconds` (histogram), `queue_waiting_jobs`, `queue_active_jobs`, `queue_failed_jobs_total` (gauges) +- `QueueMetricsService` polls all queues every 30s and updates the Prometheus gauges +- All queue metrics are available at `/metrics` + +### Using `QueueService` + +```ts +// Basic — default priority (NORMAL) +await queueService.addJob(QUEUE_NAMES.EMAIL, 'send-email', { to, subject }); + +// With explicit priority +await queueService.addJob(QUEUE_NAMES.WEBHOOKS, 'process-webhook', payload, { + priority: JobPriority.CRITICAL, +}); + +// With retry strategy +await queueService.addJob(QUEUE_NAMES.EMAIL, 'send-campaign', template, {}, 'EMAIL'); +``` + +### Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `REDIS_URL` / `QUEUE_REDIS_URL` | `redis://127.0.0.1:6379` | Redis connection | +| `QUEUE_CONCURRENCY_EMAIL` | `5` | Email worker concurrency | +| `QUEUE_CONCURRENCY_MEDIA` | `3` | Media processing concurrency | +| `QUEUE_CONCURRENCY_SYNC` | `4` | Data sync concurrency | +| `QUEUE_CONCURRENCY_BACKUP` | `1` | Backup concurrency | +| `QUEUE_CONCURRENCY_WEBHOOKS` | `10` | Webhooks concurrency | +| `QUEUE_CONCURRENCY_SUBSCRIPTIONS` | `5` | Subscriptions concurrency | + +### Load Testing + +```bash +# Queue throughput benchmark (requires Redis localhost) +npx ts-node tests/load/queue-throughput.benchmark.ts +``` + ## Production Considerations 1. **Redis High Availability**: Use Redis Sentinel or Cluster diff --git a/src/queues/dead-letter/dead-letter.module.ts b/src/queues/dead-letter/dead-letter.module.ts new file mode 100644 index 00000000..eea71938 --- /dev/null +++ b/src/queues/dead-letter/dead-letter.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { QueueModule } from '../queue.module'; +import { DeadLetterService } from './dead-letter.service'; + +@Module({ + imports: [QueueModule], + providers: [DeadLetterService], + exports: [DeadLetterService], +}) +export class DeadLetterModule {} diff --git a/src/queues/dead-letter/dead-letter.service.ts b/src/queues/dead-letter/dead-letter.service.ts new file mode 100644 index 00000000..b83a6024 --- /dev/null +++ b/src/queues/dead-letter/dead-letter.service.ts @@ -0,0 +1,45 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue, Job } from 'bull'; +import { QUEUE_NAMES } from '../../common/constants/queue.constants'; + +export interface DeadLetterJobData { + originalQueue: string; + originalJobId: string | number; + originalJobName: string; + originalData: any; + failedReason: string; + failedAt: string; + attemptsMade: number; + stackTrace?: string; +} + +@Injectable() +export class DeadLetterService { + private readonly logger = new Logger(DeadLetterService.name); + + constructor(@InjectQueue(QUEUE_NAMES.DEAD_LETTER) private readonly deadLetterQueue: Queue) {} + + async sendToDeadLetter(job: Job, queueName: string): Promise { + const data: DeadLetterJobData = { + originalQueue: queueName, + originalJobId: job.id, + originalJobName: job.name, + originalData: job.data, + failedReason: job.failedReason ?? 'Unknown error', + failedAt: new Date().toISOString(), + attemptsMade: job.attemptsMade, + stackTrace: job.stacktrace?.[0], + }; + + await this.deadLetterQueue.add(`${queueName}:${job.name || 'unknown'}`, data, { + attempts: 1, + removeOnComplete: true, + removeOnFail: true, + }); + + this.logger.warn( + `[DEAD-LETTER] Job ${job.id} from "${queueName}" moved to dead-letter queue (reason: ${data.failedReason})`, + ); + } +} diff --git a/src/queues/metrics/queue-metrics.service.ts b/src/queues/metrics/queue-metrics.service.ts new file mode 100644 index 00000000..5c7ea868 --- /dev/null +++ b/src/queues/metrics/queue-metrics.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Interval } from '@nestjs/schedule'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { QUEUE_NAMES } from '../../common/constants/queue.constants'; +import { MetricsCollectionService } from '../../monitoring/metrics/metrics-collection.service'; + +@Injectable() +export class QueueMetricsService { + private readonly logger = new Logger(QueueMetricsService.name); + + private readonly queueList: { name: string; queue: Queue }[] = []; + + constructor( + @InjectQueue(QUEUE_NAMES.EMAIL) emailQueue: Queue, + @InjectQueue(QUEUE_NAMES.EMAIL_MARKETING) emailMarketingQueue: Queue, + @InjectQueue(QUEUE_NAMES.SYNC_TASKS) syncTasksQueue: Queue, + @InjectQueue(QUEUE_NAMES.BACKUP_PROCESSING) backupProcessingQueue: Queue, + @InjectQueue(QUEUE_NAMES.MESSAGE_QUEUE) messageQueue: Queue, + @InjectQueue(QUEUE_NAMES.MEDIA_PROCESSING) mediaProcessingQueue: Queue, + @InjectQueue(QUEUE_NAMES.DEFAULT) defaultQueue: Queue, + @InjectQueue(QUEUE_NAMES.USER_DATA_EXPORT) userDataExportQueue: Queue, + @InjectQueue(QUEUE_NAMES.SUBSCRIPTIONS) subscriptionsQueue: Queue, + @InjectQueue(QUEUE_NAMES.WEBHOOKS) webhooksQueue: Queue, + @InjectQueue(QUEUE_NAMES.DEAD_LETTER) deadLetterQueue: Queue, + private readonly metrics: MetricsCollectionService, + ) { + this.queueList = [ + { name: QUEUE_NAMES.EMAIL, queue: emailQueue }, + { name: QUEUE_NAMES.EMAIL_MARKETING, queue: emailMarketingQueue }, + { name: QUEUE_NAMES.SYNC_TASKS, queue: syncTasksQueue }, + { name: QUEUE_NAMES.BACKUP_PROCESSING, queue: backupProcessingQueue }, + { name: QUEUE_NAMES.MESSAGE_QUEUE, queue: messageQueue }, + { name: QUEUE_NAMES.MEDIA_PROCESSING, queue: mediaProcessingQueue }, + { name: QUEUE_NAMES.DEFAULT, queue: defaultQueue }, + { name: QUEUE_NAMES.USER_DATA_EXPORT, queue: userDataExportQueue }, + { name: QUEUE_NAMES.SUBSCRIPTIONS, queue: subscriptionsQueue }, + { name: QUEUE_NAMES.WEBHOOKS, queue: webhooksQueue }, + { name: QUEUE_NAMES.DEAD_LETTER, queue: deadLetterQueue }, + ]; + } + + @Interval(30_000) + async recordQueueMetrics(): Promise { + for (const entry of this.queueList) { + try { + const counts = await entry.queue.getJobCounts(); + this.metrics.updateQueueWaitingJobs(entry.name, counts.waiting || 0); + this.metrics.updateQueueActiveJobs(entry.name, counts.active || 0); + this.metrics.updateQueueFailedJobs(entry.name, counts.failed || 0); + } catch (err) { + this.logger.warn(`Failed to record metrics for queue "${entry.name}": ${err}`); + } + } + } +} diff --git a/src/queues/prioritization/prioritization.service.ts b/src/queues/prioritization/prioritization.service.ts index 2f76cff1..907fdcc1 100644 --- a/src/queues/prioritization/prioritization.service.ts +++ b/src/queues/prioritization/prioritization.service.ts @@ -148,6 +148,10 @@ export class PrioritizationService { return optionsMap[priority]; } + toBullPriority(priority: JobPriority): number { + return Math.max(0, priority - 1); + } + /** * Adjust priority dynamically based on job age */ diff --git a/src/queues/queue.module.ts b/src/queues/queue.module.ts new file mode 100644 index 00000000..0ca7c137 --- /dev/null +++ b/src/queues/queue.module.ts @@ -0,0 +1,34 @@ +import { Module, Global } from '@nestjs/common'; +import { BullModule } from '@nestjs/bull'; +import { QUEUE_NAMES } from '../common/constants/queue.constants'; +import { QueueService } from './queue.service'; +import { PrioritizationService } from './prioritization/prioritization.service'; +import { RetryStrategyService } from './retry/retry-strategy.service'; +import { QueueMetricsService } from './metrics/queue-metrics.service'; +import { MonitoringModule } from '../monitoring/monitoring.module'; + +@Global() +@Module({ + imports: [ + MonitoringModule, + BullModule.forRoot({ + redis: process.env.QUEUE_REDIS_URL || process.env.REDIS_URL || 'redis://127.0.0.1:6379', + }), + BullModule.registerQueue( + { name: QUEUE_NAMES.EMAIL }, + { name: QUEUE_NAMES.EMAIL_MARKETING }, + { name: QUEUE_NAMES.SYNC_TASKS }, + { name: QUEUE_NAMES.BACKUP_PROCESSING }, + { name: QUEUE_NAMES.MESSAGE_QUEUE }, + { name: QUEUE_NAMES.MEDIA_PROCESSING }, + { name: QUEUE_NAMES.DEFAULT }, + { name: QUEUE_NAMES.USER_DATA_EXPORT }, + { name: QUEUE_NAMES.SUBSCRIPTIONS }, + { name: QUEUE_NAMES.WEBHOOKS }, + { name: QUEUE_NAMES.DEAD_LETTER }, + ), + ], + providers: [QueueService, PrioritizationService, RetryStrategyService, QueueMetricsService], + exports: [BullModule, QueueService, PrioritizationService, RetryStrategyService], +}) +export class QueueModule {} diff --git a/src/queues/queue.service.ts b/src/queues/queue.service.ts new file mode 100644 index 00000000..1e3fc1ee --- /dev/null +++ b/src/queues/queue.service.ts @@ -0,0 +1,111 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue, Job, JobOptions as BullJobOptions } from 'bull'; +import { QUEUE_NAMES } from '../common/constants/queue.constants'; +import { JobPriority } from './enums/job-priority.enum'; +import { IJobOptions } from './interfaces/queue.interfaces'; +import { PrioritizationService } from './prioritization/prioritization.service'; +import { RetryStrategyService, RetryStrategyKey } from './retry/retry-strategy.service'; + +export interface AddJobResult { + jobId: string | number; + queue: string; + name: string; +} + +@Injectable() +export class QueueService { + private readonly logger = new Logger(QueueService.name); + + constructor( + @InjectQueue(QUEUE_NAMES.EMAIL) private readonly emailQueue: Queue, + @InjectQueue(QUEUE_NAMES.EMAIL_MARKETING) private readonly emailMarketingQueue: Queue, + @InjectQueue(QUEUE_NAMES.SYNC_TASKS) private readonly syncTasksQueue: Queue, + @InjectQueue(QUEUE_NAMES.BACKUP_PROCESSING) private readonly backupProcessingQueue: Queue, + @InjectQueue(QUEUE_NAMES.MESSAGE_QUEUE) private readonly messageQueue: Queue, + @InjectQueue(QUEUE_NAMES.MEDIA_PROCESSING) private readonly mediaProcessingQueue: Queue, + @InjectQueue(QUEUE_NAMES.DEFAULT) private readonly defaultQueue: Queue, + @InjectQueue(QUEUE_NAMES.USER_DATA_EXPORT) private readonly userDataExportQueue: Queue, + @InjectQueue(QUEUE_NAMES.SUBSCRIPTIONS) private readonly subscriptionsQueue: Queue, + @InjectQueue(QUEUE_NAMES.WEBHOOKS) private readonly webhooksQueue: Queue, + @InjectQueue(QUEUE_NAMES.DEAD_LETTER) private readonly deadLetterQueue: Queue, + private readonly prioritizationService: PrioritizationService, + private readonly retryStrategyService: RetryStrategyService, + ) {} + + private readonly queueMap = new Map(); + + private getQueue(queueName: string): Queue { + let queue = this.queueMap.get(queueName); + if (!queue) { + const map: Record = { + [QUEUE_NAMES.EMAIL]: this.emailQueue, + [QUEUE_NAMES.EMAIL_MARKETING]: this.emailMarketingQueue, + [QUEUE_NAMES.SYNC_TASKS]: this.syncTasksQueue, + [QUEUE_NAMES.BACKUP_PROCESSING]: this.backupProcessingQueue, + [QUEUE_NAMES.MESSAGE_QUEUE]: this.messageQueue, + [QUEUE_NAMES.MEDIA_PROCESSING]: this.mediaProcessingQueue, + [QUEUE_NAMES.DEFAULT]: this.defaultQueue, + [QUEUE_NAMES.USER_DATA_EXPORT]: this.userDataExportQueue, + [QUEUE_NAMES.SUBSCRIPTIONS]: this.subscriptionsQueue, + [QUEUE_NAMES.WEBHOOKS]: this.webhooksQueue, + [QUEUE_NAMES.DEAD_LETTER]: this.deadLetterQueue, + }; + queue = map[queueName]; + if (!queue) { + throw new NotFoundException(`Queue "${queueName}" not found`); + } + this.queueMap.set(queueName, queue); + } + return queue; + } + + async addJob( + queueName: string, + jobName: string, + data: Record, + options?: Partial, + retryStrategy?: RetryStrategyKey, + ): Promise { + const queue = this.getQueue(queueName); + const priorityLevel = options?.priority ?? JobPriority.NORMAL; + const bullPriority = this.prioritizationService.toBullPriority(priorityLevel); + + const { priority: _, ...restOptions } = options ?? {}; + + let retryOpts: Record = {}; + if (retryStrategy) { + retryOpts = { + attempts: this.retryStrategyService.getBullAttempts(retryStrategy), + backoff: this.retryStrategyService.getBullBackoff(retryStrategy), + }; + } + + const jobOptions: BullJobOptions = { + ...restOptions, + ...retryOpts, + priority: bullPriority, + }; + + const job = await queue.add(jobName, data, jobOptions); + this.logger.debug(`Job ${job.id} added to "${queueName}" (name: ${jobName})`); + return { jobId: job.id, queue: queueName, name: jobName }; + } + + async getJob(queueName: string, jobId: string): Promise { + const queue = this.getQueue(queueName); + return queue.getJob(jobId); + } + + async getQueueCounts(queueName: string): Promise<{ + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + }> { + const queue = this.getQueue(queueName); + const counts = await queue.getJobCounts(); + return counts; + } +} diff --git a/src/queues/retry/retry-strategy.module.ts b/src/queues/retry/retry-strategy.module.ts new file mode 100644 index 00000000..7304c914 --- /dev/null +++ b/src/queues/retry/retry-strategy.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { RetryStrategyService } from './retry-strategy.service'; + +@Module({ + providers: [RetryStrategyService], + exports: [RetryStrategyService], +}) +export class RetryStrategyModule {} diff --git a/src/queues/retry/retry-strategy.service.ts b/src/queues/retry/retry-strategy.service.ts new file mode 100644 index 00000000..9425165a --- /dev/null +++ b/src/queues/retry/retry-strategy.service.ts @@ -0,0 +1,27 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { RETRY_STRATEGIES } from '../queues.constants'; +import { IRetryStrategy } from '../interfaces/queue.interfaces'; + +export type RetryStrategyKey = keyof typeof RETRY_STRATEGIES; + +@Injectable() +export class RetryStrategyService { + private readonly logger = new Logger(RetryStrategyService.name); + + getStrategy(key: RetryStrategyKey): IRetryStrategy { + return RETRY_STRATEGIES[key]; + } + + getBullBackoff(key: RetryStrategyKey): { type: 'fixed' | 'exponential'; delay: number } { + const strategy = this.getStrategy(key); + return { type: strategy.backoffType, delay: strategy.initialDelay }; + } + + getBullAttempts(key: RetryStrategyKey): number { + return this.getStrategy(key).maxAttempts; + } + + getAllStrategies(): Record { + return { ...RETRY_STRATEGIES }; + } +} diff --git a/src/workers/bridge/workers-bridge.module.ts b/src/workers/bridge/workers-bridge.module.ts new file mode 100644 index 00000000..5c87e99d --- /dev/null +++ b/src/workers/bridge/workers-bridge.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { EmailModule } from '../../email-marketing/email.module'; +import { WebhooksDeliveryModule } from '../../webhooks/webhooks-delivery.module'; +import { MonitoringModule } from '../../monitoring/monitoring.module'; +import { QueueModule } from '../../queues/queue.module'; +import { DeadLetterModule } from '../../queues/dead-letter/dead-letter.module'; +import { MessagingModule } from '../../messaging/messaging.module'; +import { EmailWorker } from '../processors/email.worker'; +import { MediaProcessingWorker } from '../processors/media-processing.worker'; +import { DataSyncWorker } from '../processors/data-sync.worker'; +import { BackupProcessingWorker } from '../processors/backup-processing.worker'; +import { WebhooksWorker } from '../processors/webhooks.worker'; +import { SubscriptionsWorker } from '../processors/subscriptions.worker'; +import { WorkersBridgeService } from './workers-bridge.service'; + +@Module({ + imports: [ + QueueModule, + DeadLetterModule, + EmailModule, + WebhooksDeliveryModule, + MonitoringModule, + MessagingModule, + ], + providers: [ + EmailWorker, + MediaProcessingWorker, + DataSyncWorker, + BackupProcessingWorker, + WebhooksWorker, + SubscriptionsWorker, + WorkersBridgeService, + ], + exports: [WorkersBridgeService], +}) +export class WorkersBridgeModule {} diff --git a/src/workers/bridge/workers-bridge.service.ts b/src/workers/bridge/workers-bridge.service.ts new file mode 100644 index 00000000..7eb52e5a --- /dev/null +++ b/src/workers/bridge/workers-bridge.service.ts @@ -0,0 +1,160 @@ +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue, Job } from 'bull'; +import { QUEUE_NAMES } from '../../common/constants/queue.constants'; +import { EmailWorker } from '../processors/email.worker'; +import { MediaProcessingWorker } from '../processors/media-processing.worker'; +import { DataSyncWorker } from '../processors/data-sync.worker'; +import { BackupProcessingWorker } from '../processors/backup-processing.worker'; +import { WebhooksWorker } from '../processors/webhooks.worker'; +import { SubscriptionsWorker } from '../processors/subscriptions.worker'; +import { MetricsCollectionService } from '../../monitoring/metrics/metrics-collection.service'; +import { DeadLetterService } from '../../queues/dead-letter/dead-letter.service'; +import { MessagingService } from '../../messaging/messaging.service'; + +interface QueueWorkerBinding { + queue: Queue; + handler: (job: Job) => Promise; + concurrency: number; + name: string; +} + +@Injectable() +export class WorkersBridgeService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(WorkersBridgeService.name); + private readonly bindings: QueueWorkerBinding[] = []; + + constructor( + @InjectQueue(QUEUE_NAMES.EMAIL) private readonly emailQueue: Queue, + @InjectQueue(QUEUE_NAMES.MEDIA_PROCESSING) private readonly mediaQueue: Queue, + @InjectQueue(QUEUE_NAMES.SYNC_TASKS) private readonly syncQueue: Queue, + @InjectQueue(QUEUE_NAMES.BACKUP_PROCESSING) private readonly backupQueue: Queue, + @InjectQueue(QUEUE_NAMES.WEBHOOKS) private readonly webhooksQueue: Queue, + @InjectQueue(QUEUE_NAMES.SUBSCRIPTIONS) private readonly subscriptionsQueue: Queue, + private readonly emailWorker: EmailWorker, + private readonly mediaWorker: MediaProcessingWorker, + private readonly dataSyncWorker: DataSyncWorker, + private readonly backupWorker: BackupProcessingWorker, + private readonly webhooksWorker: WebhooksWorker, + private readonly subscriptionsWorker: SubscriptionsWorker, + private readonly metrics?: MetricsCollectionService, + private readonly deadLetterService?: DeadLetterService, + private readonly messagingService?: MessagingService, + ) {} + + async onModuleInit(): Promise { + this.registerBinding( + this.emailQueue, + (job) => this.emailWorker.handle(job), + this.getConcurrency('email', 5), + 'email', + ); + this.registerBinding( + this.mediaQueue, + (job) => this.mediaWorker.handle(job), + this.getConcurrency('media', 3), + 'media-processing', + ); + this.registerBinding( + this.syncQueue, + (job) => this.dataSyncWorker.handle(job), + this.getConcurrency('sync', 4), + 'sync-tasks', + ); + this.registerBinding( + this.backupQueue, + (job) => this.backupWorker.handle(job), + this.getConcurrency('backup', 1), + 'backup-processing', + ); + this.registerBinding( + this.webhooksQueue, + (job) => this.webhooksWorker.handle(job), + this.getConcurrency('webhooks', 10), + 'webhooks', + ); + this.registerBinding( + this.subscriptionsQueue, + (job) => this.subscriptionsWorker.handle(job), + this.getConcurrency('subscriptions', 5), + 'subscriptions', + ); + + if (this.messagingService) { + await this.messagingService.processMessages(); + this.logger.log('Messaging queue processor registered via MessagingService'); + } + + await Promise.all(this.bindings.map((b) => this.startBinding(b))); + this.registerDeadLetterHandlers(); + this.logger.log(`Workers bridge initialized with ${this.bindings.length} queue bindings`); + } + + async onModuleDestroy(): Promise { + this.logger.log('Closing worker queue connections...'); + for (const binding of this.bindings) { + try { + await binding.queue.close(); + } catch (err) { + this.logger.warn(`Error closing queue "${binding.name}": ${err}`); + } + } + } + + private registerBinding( + queue: Queue, + handler: (job: Job) => Promise, + concurrency: number, + name: string, + ): void { + this.bindings.push({ queue, handler, concurrency, name }); + } + + private async startBinding(binding: QueueWorkerBinding): Promise { + const wrapped = async (job: Job): Promise => { + const start = Date.now(); + try { + this.logger.debug(`[${binding.name}] Processing job ${job.id} (${job.name})`); + return await binding.handler(job); + } finally { + const durationMs = Date.now() - start; + this.metrics?.recordQueueProcessingTime( + binding.name, + job.name || 'unknown', + durationMs / 1000, + ); + this.logger.debug(`[${binding.name}] Job ${job.id} completed in ${durationMs}ms`); + } + }; + + binding.queue.process(binding.concurrency, wrapped); + this.logger.log(`Queue "${binding.name}" bound with concurrency ${binding.concurrency}`); + } + + private registerDeadLetterHandlers(): void { + for (const binding of this.bindings) { + binding.queue.on('failed', async (job: Job, err: Error) => { + this.logger.warn(`[${binding.name}] Job ${job.id} failed: ${err.message}`); + if (this.deadLetterService) { + try { + await this.deadLetterService.sendToDeadLetter(job, binding.name); + } catch (dlqErr) { + this.logger.error( + `[DEAD-LETTER] Failed to forward job ${job.id} to dead-letter queue: ${dlqErr}`, + ); + } + } + }); + } + } + + private getConcurrency(key: string, fallback: number): number { + const envKey = `QUEUE_CONCURRENCY_${key.toUpperCase()}`; + const val = process.env[envKey]; + if (val !== undefined) { + const parsed = parseInt(val, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return fallback; + } +} diff --git a/tests/load/queue-throughput.benchmark.ts b/tests/load/queue-throughput.benchmark.ts new file mode 100644 index 00000000..76a55013 --- /dev/null +++ b/tests/load/queue-throughput.benchmark.ts @@ -0,0 +1,156 @@ +/** + * Queue Throughput Benchmark + * + * Measures Bull queue throughput with and without priority under load. + * + * Usage: + * npx ts-node tests/load/queue-throughput.benchmark.ts + * + * Prerequisites: + * - Redis running on REDIS_URL (default: redis://127.0.0.1:6379) + */ + +import Bull from 'bull'; +import { performance } from 'perf_hooks'; + +const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379'; +const JOB_COUNT = 5_000; +const CONCURRENCY = 10; + +interface BenchmarkResult { + label: string; + totalJobs: number; + durationMs: number; + throughput: number; + p50Ms: number; + p95Ms: number; + p99Ms: number; +} + +function createQueue(name: string): Bull.Queue { + return new Bull(name, REDIS_URL, { + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: true, + }, + }); +} + +async function runBenchmark( + label: string, + queue: Bull.Queue, + jobs: { name: string; data: any; opts?: Bull.JobOptions }[], + concurrency: number, +): Promise { + const latencies: number[] = []; + + const processPromise = new Promise((resolve, reject) => { + queue.process(concurrency, async (job: Bull.Job) => { + const start = performance.now(); + await job.progress(100); + latencies.push(performance.now() - start); + }); + + queue.on('completed', (job: Bull.Job) => { + if (latencies.length >= jobs.length) { + resolve(); + } + }); + + queue.on('failed', (job: Bull.Job, err: Error) => { + console.error(`Job ${job.id} failed: ${err.message}`); + }); + }); + + const addStart = performance.now(); + for (const job of jobs) { + await queue.add(job.name, job.data, job.opts); + } + const addDuration = performance.now() - addStart; + console.log(` Added ${jobs.length} jobs in ${addDuration.toFixed(0)}ms`); + + const processStart = performance.now(); + await processPromise; + const processDuration = performance.now() - processStart; + + latencies.sort((a, b) => a - b); + const p50 = latencies[Math.floor(latencies.length * 0.5)]; + const p95 = latencies[Math.floor(latencies.length * 0.95)]; + const p99 = latencies[Math.floor(latencies.length * 0.99)]; + + return { + label, + totalJobs: jobs.length, + durationMs: processDuration, + throughput: Math.round((jobs.length / processDuration) * 1000), + p50Ms: Math.round(p50 * 100) / 100, + p95Ms: Math.round(p95 * 100) / 100, + p99Ms: Math.round(p99 * 100) / 100, + }; +} + +function printResults(results: BenchmarkResult[]): void { + console.log('\n========== BENCHMARK RESULTS ==========\n'); + console.log( + `${'LABEL'.padEnd(30)} ${'JOBS'.padEnd(8)} ${'DURATION'.padEnd(12)} ${'THROUGHPUT'.padEnd(12)} ${'P50'.padEnd(10)} ${'P95'.padEnd(10)} ${'P99'.padEnd(10)}`, + ); + console.log('-'.repeat(92)); + for (const r of results) { + console.log( + `${r.label.padEnd(30)} ${String(r.totalJobs).padEnd(8)} ${`${r.durationMs.toFixed(0)}ms`.padEnd(12)} ${`${r.throughput} jobs/s`.padEnd(12)} ${`${r.p50Ms}ms`.padEnd(10)} ${`${r.p95Ms}ms`.padEnd(10)} ${`${r.p99Ms}ms`.padEnd(10)}`, + ); + } + console.log('\n======================================\n'); +} + +async function main(): Promise { + console.log(`Queue Throughput Benchmark`); + console.log(` Redis: ${REDIS_URL}`); + console.log(` Jobs per test: ${JOB_COUNT}`); + console.log(` Concurrency: ${CONCURRENCY}\n`); + + const jobs = Array.from({ length: JOB_COUNT }, (_, i) => ({ + name: 'benchmark', + data: { index: i, timestamp: Date.now() }, + })); + + const priorityJobs = Array.from({ length: JOB_COUNT }, (_, i) => ({ + name: 'benchmark', + data: { index: i, timestamp: Date.now() }, + opts: { priority: i % 5 } as Bull.JobOptions, + })); + + const results: BenchmarkResult[] = []; + + // Test 1: Without priority + const q1 = createQueue('benchmark-default'); + await q1.empty(); + console.log('Test 1: Without priority...'); + results.push(await runBenchmark('Without priority', q1, jobs, CONCURRENCY)); + await q1.close(); + + // Test 2: With priority + const q2 = createQueue('benchmark-priority'); + await q2.empty(); + console.log('Test 2: With priority...'); + results.push(await runBenchmark('With priority', q2, priorityJobs, CONCURRENCY)); + await q2.close(); + + // Test 3: Higher concurrency + const q3 = createQueue('benchmark-high-concurrency'); + await q3.empty(); + console.log('Test 3: Concurrency x2...'); + results.push( + await runBenchmark('Concurrency x2', q3, jobs, CONCURRENCY * 2), + ); + await q3.close(); + + printResults(results); + console.log('Benchmark complete. Clean up queues manually via Redis CLI:\n'); + console.log(' redis-cli KEYS "bull:benchmark-*" | xargs redis-cli DEL'); +} + +main().catch((err) => { + console.error('Benchmark failed:', err); + process.exit(1); +});