Skip to content

Recipe — Adding a New Feature

This is the canonical, copy-pasteable recipe for adding a new vertical feature to the mr-mentor-backend codebase. It walks you through every layer — TypeORM entity → DataSource registration → service → controller → route class → mounting → middleware → optional async worker / socket events → tests → docs — using two real, recently-shipped features as the worked example: the Mr. Hire Pipeline Cost Ledger (src/services/PipelineCostService.ts) and the Talent Pool (src/routes/talentPool.routes.ts).

Status: documented from source on this branch.

Every code template below is lifted from or modeled exactly on files that exist in the repo today. Follow the patterns verbatim — the codebase is highly consistent and reviewers expect the conventions described here.


Overview

mr-mentor-backend is a single Express + TypeScript monolith (Node 20, TypeORM/PostgreSQL, Redis/BullMQ, Socket.IO, AWS S3, Razorpay) that hosts many products: Mr. Mentor (mentorship + meetings + tokens), Mr. Hire (recruitment/ATS), the MAS LMS, a Sales CRM, Finance, and an AI platform. Despite the breadth, every feature is built the same way: a layered slice from HTTP route down to a TypeORM repository, wired into one central Routes class.

Who uses this doc: any backend engineer adding an endpoint, an entity, or a background job. The recipe is deliberately mechanical so that features stay structurally uniform and the code-review-graph / impact analysis stays meaningful.

Where a feature sits in the suite:

flowchart LR
  Client["Frontend or external caller"] -->|"HTTP / WebSocket"| App["app.ts Express app"]
  App -->|"mounts at /"| RoutesIdx["routes/index.ts (Routes class)"]
  RoutesIdx -->|"this.router.use('/api', ...)"| RouteClass["Your FeatureRoutes class"]
  RouteClass -->|"authMiddleware then handler"| Ctrl["Your FeatureController"]
  Ctrl --> Svc["Your FeatureService"]
  Svc -->|"dataSource.getRepository"| Repo["TypeORM Repository"]
  Repo --> DB[("PostgreSQL (auto-sync)")]
  Svc -.->|"optional async"| Queue["BullMQ Queue via QueueService"]
  Queue --> Worker["BullMQ Worker"]
  Svc -.->|"optional realtime"| Socket["Socket.IO (socket.ts)"]

Key concepts & entities

Concept What it is Where
Entity A TypeORM @Entity() class = one Postgres table. Auto-synced. src/entities/*.ts
DataSource Single TypeORM connection. Every entity MUST be in its entities: [...] array. src/config/database.ts
Service Business logic + all repository access. Constructed with the DataSource. src/services/*.ts
Controller Thin HTTP layer: reads req, calls a service, shapes the JSON response, catches errors. src/controllers/*.ts
Route class A class that builds an Express Router, binds paths to controller methods + middleware. src/routes/*.ts
Routes aggregator The one class that instantiates every route class and mounts each router. src/routes/index.ts
Middleware authMiddleware (JWT → req.user), adminMiddleware, expertMiddleware. src/middleware/*.ts
QueueService Singleton that owns all BullMQ Queue objects and addXJob(...) helpers. src/services/QueueService.ts
Worker A new Worker('queueName', handler) process, imported for side-effect at startup. src/workers/*.ts

Worked-example entities used throughout this doc: - src/entities/PipelineCostLedger.entity.ts — append-only cost ledger (CostStage, CostProvider enums). - src/entities/TalentPoolEntry.entity.ts and src/entities/TalentPoolMatch.entity.ts.


Architecture

The end-to-end shape of a feature slice. Note that services never touch req/res and controllers never touch repositories directly — that separation is enforced by convention across all 46+ services.

flowchart TD
  subgraph HTTP["HTTP layer"]
    R["FeatureRoutes (src/routes)"]
    M["authMiddleware / adminMiddleware"]
    C["FeatureController (src/controllers)"]
  end
  subgraph Logic["Business layer"]
    S["FeatureService (src/services)"]
  end
  subgraph Data["Persistence"]
    E["Entity (src/entities)"]
    DS["DataSource (config/database.ts)"]
    PG[("PostgreSQL")]
  end
  subgraph Async["Optional async + realtime"]
    Q["QueueService (BullMQ queues)"]
    W["Worker (src/workers)"]
    IO["Socket.IO (socket.ts)"]
    EXT["External APIs (mr-hire, OpenAI, S3, Razorpay)"]
  end

  R --> M --> C --> S
  S -->|"getRepository(Entity)"| DS
  DS --> E --> PG
  S -.->|"addXJob"| Q --> W
  W --> S
  W -.-> EXT
  S -.-> IO
  S -.-> EXT

Data model

The worked-example entities and their relationships. TalentPoolEntry has a unique applicationId (one pool row per candidate application); TalentPoolMatch joins a pool entry to a job post. PipelineCostLedger is a standalone append-only ledger keyed by jobPostId (and optionally applicationId).

erDiagram
  JOB_POST ||--o{ JOB_APPLICATION : "receives"
  JOB_APPLICATION ||--|| TALENT_POOL_ENTRY : "may enter pool"
  TALENT_POOL_ENTRY ||--o{ TALENT_POOL_MATCH : "matched to jobs"
  JOB_POST ||--o{ TALENT_POOL_MATCH : "suggests"
  JOB_POST ||--o{ PIPELINE_COST_LEDGER : "accrues cost"
  JOB_APPLICATION ||--o{ PIPELINE_COST_LEDGER : "per-candidate cost"

  TALENT_POOL_ENTRY {
    uuid id PK
    uuid applicationId UK
    uuid jobPostId FK
    uuid belongsTo
    float score
    varchar reason
    json tags
    text notes
    json parsedSkills
  }
  TALENT_POOL_MATCH {
    uuid id PK
    uuid talentPoolEntryId FK
    uuid jobPostId FK
    float matchScore
    text matchReason
    varchar status
  }
  PIPELINE_COST_LEDGER {
    uuid id PK
    uuid jobPostId
    uuid applicationId
    varchar stage
    varchar provider
    varchar model
    decimal costUsd
    int inputTokens
    int outputTokens
    timestamp recordedAt
  }

Notable enums (src/entities/PipelineCostLedger.entity.ts): - CostStage: jd_generation, salary_benchmark, quiz_generation, resume_parsing, resume_embedding, resume_scoring, ai_voice_call, transcript_analysis, other. - CostProvider: openai, groq, vapi, elevenlabs, twilio, nvidia, internal.

Notable status fields: TalentPoolMatch.status = suggested | invited | dismissed; TalentPoolEntry.reason = auto_rejected | not_shortlisted | manual_add.


The recipe — step by step

flowchart TD
  S1["1. Create entity in src/entities"] --> S2["2. Register entity in DataSource entities array"]
  S2 --> S3["3. Write service in src/services"]
  S3 --> S4["4. Write controller in src/controllers"]
  S4 --> S5["5. Write route class in src/routes"]
  S5 --> S6["6. Mount in routes/index.ts (import, field, construct, use)"]
  S6 --> S7["7. Attach auth / role middleware"]
  S7 --> S8{"Needs async work?"}
  S8 -->|"yes"| S9["8a. Add queue + addXJob in QueueService"]
  S9 --> S10["8b. Add worker in src/workers + import in index.ts"]
  S8 -->|"no"| S11
  S10 --> S11{"Needs realtime?"}
  S11 -->|"yes"| S12["9. Add socket events in socket.ts"]
  S11 -->|"no"| S13
  S12 --> S13["10. Add tests (bun) in __tests__"]
  S13 --> S14["11. Document the feature under docs/features"]

Step 1 — Create the entity (src/entities/)

TypeORM auto-sync is ON (synchronize: true in src/config/database.ts), so a new @Entity() class auto-creates its table on next boot — no migration files in dev. Name the file <Thing>.entity.ts (most newer entities use the .entity.ts suffix; some legacy ones do not). Use explicit column types, declare enums in the same file, and add @Index(...) for every column you will filter on.

Template (modeled on src/entities/PipelineCostLedger.entity.ts):

import {
  Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index,
} from 'typeorm';

export enum WidgetStatus {
  DRAFT = 'draft',
  ACTIVE = 'active',
  ARCHIVED = 'archived',
}

@Index(['ownerId'])
@Index(['status'])
@Entity('widgets')               // explicit snake_case table name
export class Widget {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'uuid', name: 'owner_id' })
  ownerId: string;

  @Column({ type: 'varchar', length: 50, default: WidgetStatus.DRAFT })
  status: WidgetStatus;

  @Column({ type: 'jsonb', nullable: true, default: null })
  metadata: Record<string, any> | null;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}

Conventions to copy: - UUID primary keys (@PrimaryGeneratedColumn('uuid')) for new entities. - Quote table names: @Entity('widgets'). - nullable: true, default: null for optional columns; default: 0 / default: '[]' for scalars/json. - Relations via @ManyToOne(() => Other, { onDelete: 'CASCADE' }) + @JoinColumn(...) — see TalentPoolEntry.entity.ts for the application/jobPost pattern.

Gotcha: auto-sync will happily DROP/ALTER columns to match your class. Never rename a column casually on a shared DB. See data-and-typeorm-guidelines.

Step 2 — Register the entity in the DataSource (src/config/database.ts)

This is the step people forget. An entity that is not in the entities: [...] array is invisible to TypeORM — getRepository(Widget) throws at runtime. Add the import near the other entity imports and add the class to the array inside new DataSource({ ... }).

// near the top of src/config/database.ts, with the other imports
import { Widget } from '../entities/Widget.entity';

// inside DatabaseService, this.dataSource = new DataSource({ ... entities: [ ... ] })
entities: [
  // ...all existing entities...
  TalentPoolEntry, TalentPoolMatch, PipelineCostLedger,
  Widget,                                   // <-- add yours here
],

The array currently lists ~150 entities on one block of lines (e.g. line ~200 has TalentPoolEntry, TalentPoolMatch,PipelineCostLedger,). synchronize: true is set unconditionally (line ~182), so on the next npm run dev the widgets table appears.

Note: entities that live in a non-public Postgres schema (e.g. the mrlearn/* sync entities) need that schema pre-created — auto-sync does not create non-default schemas. For a normal public table you do nothing extra.

Step 3 — Write the service (src/services/)

The service owns all repository access and business logic. It is constructed with the DataSource and calls dataSource.getRepository(Entity) in its constructor. Keep methods focused; return plain DTOs/entities, never res.

Template (modeled on src/services/PipelineCostService.ts):

import { DataSource, Repository } from 'typeorm';
import { Widget, WidgetStatus } from '../entities/Widget.entity';

export interface CreateWidgetDto {
  ownerId: string;
  metadata?: Record<string, any> | null;
}

export class WidgetService {
  private readonly repo: Repository<Widget>;

  constructor(dataSource: DataSource) {
    this.repo = dataSource.getRepository(Widget);
  }

  async create(dto: CreateWidgetDto): Promise<Widget> {
    const widget = this.repo.create({
      ownerId: dto.ownerId,
      status: WidgetStatus.DRAFT,
      metadata: dto.metadata ?? null,
    });
    return this.repo.save(widget);
  }

  async listForOwner(ownerId: string, page = 1, limit = 20) {
    const [data, total] = await this.repo.findAndCount({
      where: { ownerId },
      order: { createdAt: 'DESC' },
      skip: (page - 1) * limit,
      take: limit,
    });
    return { data, total, page, limit };
  }
}

Patterns to copy from PipelineCostService: - Constructor caches repositories (this.repo = dataSource.getRepository(...)). - Pagination returns { data, total, page, limit } and the controller spreads it into the response. - For non-critical writes (analytics, ledgers), wrap save in try/catch and return null instead of throwing so the write is fire-and-forget safe (see PipelineCostService.record).

Step 4 — Write the controller (src/controllers/)

Controllers are thin: pull params/query/body, call the service, return { success: true, data } (or spread a paginated result), and try/catch with a 500 fallback. Use arrow-function class properties so this is bound when the method is passed as an Express handler (this.controller.method — no .bind). Type the request as AuthenticatedRequest to get req.user.

Template (modeled on src/controllers/PipelineCostController.ts):

import { Response } from 'express';
import { DataSource } from 'typeorm';
import { WidgetService } from '../services/WidgetService';
import { AuthenticatedRequest } from '../middleware/auth.middleware';

export class WidgetController {
  private service: WidgetService;

  constructor(dataSource: DataSource) {
    this.service = new WidgetService(dataSource);
  }

  // POST /api/widgets
  create = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
    try {
      const ownerId = req.user?.id;
      if (!ownerId) {
        res.status(401).json({ success: false, message: 'User not found' });
        return;
      }
      const data = await this.service.create({ ownerId, metadata: req.body?.metadata });
      res.json({ success: true, data });
    } catch (error) {
      console.error('[Widget] create error:', error);
      res.status(500).json({ success: false, message: 'Failed to create widget' });
    }
  };

  // GET /api/widgets
  list = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
    try {
      const ownerId = req.user?.id;
      if (!ownerId) { res.status(401).json({ success: false, message: 'User not found' }); return; }
      const page = parseInt(req.query.page as string) || 1;
      const limit = parseInt(req.query.limit as string) || 20;
      const result = await this.service.listForOwner(ownerId, page, limit);
      res.json({ success: true, ...result });
    } catch (error) {
      console.error('[Widget] list error:', error);
      res.status(500).json({ success: false, message: 'Failed to list widgets' });
    }
  };
}

Conventions: prefix log lines with a [Feature] tag; req.user?.id guarded with a 401 when the route needs ownership; return early after writing a response. See api-design-and-errors for the response envelope conventions.

Step 5 — Write the route class (src/routes/)

The route file is a class that owns a public router: Router, instantiates its controller in the constructor (passing the DataSource), and binds paths in a private initializeRoutes(). This is the exact shape of PipelineCostRoutes and TalentPoolRoutes.

Template (verbatim shape of src/routes/pipelineCost.routes.ts):

import { Router } from 'express';
import { DataSource } from 'typeorm';
import { WidgetController } from '../controllers/WidgetController';
import { authMiddleware } from '../middleware/auth.middleware';
import { adminMiddleware } from '../middleware/admin.middleware';

export class WidgetRoutes {
  public router: Router;
  private controller: WidgetController;

  constructor(dataSource: DataSource) {
    this.router = Router();
    this.controller = new WidgetController(dataSource);
    this.initializeRoutes();
  }

  private initializeRoutes(): void {
    // User-scoped (own widgets)
    this.router.get('/widgets', authMiddleware, this.controller.list);
    this.router.post('/widgets', authMiddleware, this.controller.create);

    // Admin-scoped (system-wide)
    this.router.get('/admin/widgets', authMiddleware, adminMiddleware, this.controller.list);
  }
}

The path you write here is relative to the mount prefix chosen in Step 6. Because TalentPoolRoutes and PipelineCostRoutes are both mounted at /api, their route files use full sub-paths like /mr-hire/talent-pool and /hr/costs/dashboard. Pick one style and be consistent within your feature.

Step 6 — Mount it in src/routes/index.ts

src/routes/index.ts defines the single Routes class that app.ts mounts at / (this.app.use("/", this.routes.router)). Wiring a new route class is four edits in this one file:

// 1) import (top of file, with the other route imports)
import { WidgetRoutes } from './widget.routes';

// 2) declare a readonly field on the class
private readonly widgetRoutes: WidgetRoutes;

// 3) construct it in the constructor (pass the dataSource)
this.widgetRoutes = new WidgetRoutes(dataSource);

// 4) mount its router (in the routes-mounting method)
this.router.use('/api', this.widgetRoutes.router);

Real reference lines: the import at line 77 (PipelineCostRoutes), the field at line 152, the construction at line 226 (this.pipelineCostRoutes = new PipelineCostRoutes(dataSource)), and the mount at line 455 (this.router.use('/api', this.pipelineCostRoutes.router)).

Choosing the mount prefix: - this.router.use('/api', ...) — when the route file already carries full sub-paths (Talent Pool, Pipeline Cost). - this.router.use('/api/widgets', ...) — when the route file uses bare paths like / and /:id (e.g. Voice Interview routes mount at /api/voice-interviews). - Some routers are deliberately public (no auth) — e.g. /api/candidate-quiz and /api/judge0. Comment why, as the existing code does.

Step 7 — Attach auth / role middleware

Middleware lives in src/middleware/ and is composed positionally before the handler:

this.router.get('/widgets',        authMiddleware,                 this.controller.list);
this.router.get('/admin/widgets',  authMiddleware, adminMiddleware, this.controller.create);
this.router.get('/expert/widgets', authMiddleware, expertMiddleware, this.controller.list);

What each does (src/middleware/auth.middleware.ts, admin.middleware.ts): - authMiddleware — extracts the JWT from Authorization: Bearer, or the token / authToken cookie; verifies it (HS256, JWT_SECRET); then re-loads the user from the DB to check isActive, password-change invalidation (passwordChangedAt vs token iat), and per-session revoke (sid against RefreshToken). On success it sets req.user to the real User entity. Returns 401/403 otherwise. - adminMiddleware — requires req.user.role === UserRole.ADMIN (ADMIN is the universal-access role); returns 403 otherwise. Must run after authMiddleware. - expertMiddleware — EXPERT role check (src/middleware/expert.middleware.ts).

Because authMiddleware does a DB lookup on every request, never re-fetch the user in your controller — read req.user. See security-architecture and request-lifecycle-and-middleware.

Step 8 — Add a queue + worker if the work is async

If a request kicks off slow or external work (LLM calls, S3 processing, email, sync), do not block the HTTP response. Enqueue a BullMQ job and let a worker process it.

8a. Add a queue + helper to QueueService (src/services/QueueService.ts). QueueService is a singleton; declare a public widgetQueue: Queue, create it in the constructor with the shared Redis connection block, and add an addWidgetJob(...) helper:

// field
public widgetQueue: Queue;

// in the private constructor
this.widgetQueue = new Queue('widgetQueue', {
  connection: {
    host: process.env.REDIS_HOST || 'localhost',
    port: Number.parseInt(process.env.REDIS_PORT || '6379'),
  },
  defaultJobOptions: { removeOnComplete: 50, removeOnFail: 50 },
});

// helper (mirrors addResumeAnalysisJob / addEmailJob)
public async addWidgetJob(data: { widgetId: string }, options?: any) {
  return this.widgetQueue.add('processWidget', data, options);
}

Then in a service/controller: await QueueService.getInstance().addWidgetJob({ widgetId }).

8b. Add the worker (src/workers/widget.worker.ts) and register it. A worker initializes the DataSource, then constructs a new Worker('widgetQueue', handler):

import { Worker, Job } from 'bullmq';
import { DatabaseService } from '../config/database';
import { Widget } from '../entities/Widget.entity';

const dbService = DatabaseService.getInstance();
dbService.initialize().then(() => console.debug('[WidgetWorker] ready'));

const widgetWorker = new Worker(
  'widgetQueue',
  async (job: Job<{ widgetId: string }>) => {
    const ds = dbService.dataSource;
    const repo = ds.getRepository(Widget);
    const widget = await repo.findOne({ where: { id: job.data.widgetId } });
    // ...do the slow work, update the entity...
  },
  { connection: { host: process.env.REDIS_HOST || 'localhost', port: Number.parseInt(process.env.REDIS_PORT || '6379') } },
);

Register it for side-effect at startup in src/index.ts, next to the others:

await import('./workers/widget.worker');
console.info('🧩 Widget worker initialized');

The existing 9+ workers are all imported this way (./workers/email.worker, ./workers/resumeAnalysis.worker, ./workers/workflow.worker, etc., lines ~82–121 of index.ts). See background-jobs-and-queues.

Step 9 — Add socket events if the feature is realtime

If the feature pushes live updates (meeting state, presence, recording, chat), add events in src/socket.ts (the 1300+-line Socket.IO setup). Follow the existing socket.on('event', handler) + io.to(room).emit('event-updated', payload) pattern. Realtime is rarely needed for CRUD features — skip this step unless your feature is genuinely live. See realtime-and-socketio.

Step 10 — Add tests (Bun)

Tests live next to their target in __tests__/ folders and are run with bun test (npm test, npm run test:unit, npm run test:integration). Services are tested with mocked repositories (see src/services/__tests__/TokenService.test.ts).

import { WidgetService } from '../WidgetService';
import { DataSource, Repository } from 'typeorm';
import { Widget } from '../../entities/Widget.entity';

describe('WidgetService', () => {
  let service: WidgetService;
  let mockRepo: jest.Mocked<Repository<Widget>>;
  let mockDataSource: jest.Mocked<DataSource>;

  beforeEach(() => {
    mockRepo = {
      create: jest.fn(),
      save: jest.fn(),
      findAndCount: jest.fn(),
    } as unknown as jest.Mocked<Repository<Widget>>;
    mockDataSource = { getRepository: jest.fn().mockReturnValue(mockRepo) } as unknown as jest.Mocked<DataSource>;
    service = new WidgetService(mockDataSource);
  });

  it('creates a draft widget', async () => {
    mockRepo.create.mockReturnValue({ id: 'w1' } as Widget);
    mockRepo.save.mockResolvedValue({ id: 'w1' } as Widget);
    const result = await service.create({ ownerId: 'u1' });
    expect(result.id).toBe('w1');
    expect(mockRepo.create).toHaveBeenCalled();
  });
});

Existing service test suites to copy: TokenService.test.ts, UserService.test.ts, AuthService.test.ts, CourseEnrollmentService.test.ts. Integration tests against real Postgres/Redis live in src/integration/__tests__/.

Step 11 — Document the feature

Add a feature doc under docs/features/<your-feature>.md (this repo's docs tree), and cross-link it from related architecture/data-model docs. Include: an architecture flowchart, the API table, the data model, and at least one user-journey sequence diagram (the same format used in this doc).


API surface (worked examples)

Derived directly from the route files; all are mounted at /api in routes/index.ts (lines 452 + 455). Auth column reflects the middleware stack on each route.

Talent Pool — src/routes/talentPool.routes.ts

Method Path Auth/role Purpose
GET /api/mr-hire/talent-pool authMiddleware List talent-pool entries
POST /api/mr-hire/talent-pool authMiddleware Add a candidate to the pool
DELETE /api/mr-hire/talent-pool/:id authMiddleware Remove a pool entry
PUT /api/mr-hire/talent-pool/:id/tags authMiddleware Update tags on a pool entry
GET /api/mr-hire/talent-pool/matches authMiddleware List suggested matches
POST /api/mr-hire/talent-pool/matches/:matchId/invite authMiddleware Invite a matched candidate
POST /api/mr-hire/talent-pool/matches/:matchId/dismiss authMiddleware Dismiss a match
POST /api/mr-hire/talent-pool/run-matching/:jobPostId authMiddleware Run matching for a job post

Pipeline Cost — src/routes/pipelineCost.routes.ts

Method Path Auth/role Purpose
GET /api/hr/costs/dashboard authMiddleware HR cost dashboard (own jobs)
GET /api/hr/costs/jobs/:jobPostId/summary authMiddleware Cost summary for one job
GET /api/hr/costs/jobs/:jobPostId/candidates authMiddleware Per-candidate cost for a job (paged)
GET /api/hr/costs/jobs authMiddleware All-jobs cost summary for the user
GET /api/hr/costs/candidates/:applicationId/summary authMiddleware Cost summary for one candidate
GET /api/hr/costs/ledger authMiddleware Raw cost ledger with filters (paged)
GET /api/admin/costs/dashboard authMiddleware + adminMiddleware System-wide cost dashboard
GET /api/admin/costs/by-hr authMiddleware + adminMiddleware Cost broken down by HR user
GET /api/admin/costs/hr/:userId/jobs authMiddleware + adminMiddleware One HR user's job-level costs

User journeys

Journey 1 — Adding a synchronous CRUD endpoint (the common case)

The everyday flow: a developer adds a route, it hits a controller, the controller calls a service, the service uses a repository, and the response goes back. No queue, no socket.

sequenceDiagram
  participant FE as Frontend
  participant RT as FeatureRoutes router
  participant MW as authMiddleware
  participant C as Controller
  participant S as Service
  participant DB as PostgreSQL

  FE->>RT: POST /api/widgets with bearer token
  RT->>MW: run authMiddleware first
  MW->>DB: verify user is active and session valid
  DB-->>MW: user record
  MW->>C: next with req.user set
  C->>S: create with ownerId from req.user
  S->>DB: repo.save new widget
  DB-->>S: saved widget
  S-->>C: widget entity
  C-->>FE: 200 success true with data
  Note over MW,C: on bad token authMiddleware returns 401 and C never runs

Journey 2 — Request that enqueues async work (Pipeline Cost recording)

A recruitment stage finishes (resume scored, AI call done) and the screening worker records its cost into the ledger without blocking. The HTTP caller that triggered screening already got its response; the worker writes cost rows fire-and-forget.

sequenceDiagram
  participant FE as HR Frontend
  participant API as JobApplication endpoint
  participant Q as QueueService resumeAnalysisQueue
  participant W as resumeAnalysis worker
  participant EXT as mr-hire-backend and OpenAI
  participant PCS as PipelineCostService
  participant DB as PostgreSQL

  FE->>API: submit candidate for screening
  API->>Q: addResumeAnalysisJob with applicationId
  API-->>FE: 200 accepted screening queued
  Q->>W: deliver job
  W->>EXT: call resume parse and scoring
  EXT-->>W: scores and token usage
  W->>PCS: recordLlmCost with stage provider and usage
  PCS->>DB: insert pipeline_cost_ledger row
  Note over PCS,DB: record wraps save in try catch and returns null on failure so cost logging never breaks screening
  W->>DB: update ScreeningResult with final scores

Journey 3 — HR reads aggregated costs

The HR dashboard reads cost summaries. Pure read path through the standard layers, with pagination spread into the response.

sequenceDiagram
  participant FE as HR Dashboard
  participant RT as PipelineCostRoutes
  participant MW as authMiddleware
  participant C as PipelineCostController
  participant S as PipelineCostService
  participant DB as PostgreSQL

  FE->>RT: GET /api/hr/costs/ledger with filters and page
  RT->>MW: authMiddleware
  MW-->>C: req.user set
  C->>S: getLedger with filters page and limit
  S->>DB: query pipeline_cost_ledger with where and skip take
  DB-->>S: rows and total count
  S-->>C: data total page limit
  C-->>FE: 200 success true spread with data and total
  Note over C: admin endpoints add adminMiddleware so a non admin gets 403 before the service runs

Journey 4 — Talent pool run-matching

An HR user asks to match pooled candidates against a job post. The controller delegates to the service which scores entries and writes TalentPoolMatch rows with status suggested.

sequenceDiagram
  participant FE as HR Frontend
  participant C as TalentPoolController
  participant S as TalentPool service logic
  participant DB as PostgreSQL

  FE->>C: POST /api/mr-hire/talent-pool/run-matching/:jobPostId
  C->>S: runMatching for jobPostId
  S->>DB: load talent_pool_entries and the job post
  S->>S: score each entry against job requirements
  S->>DB: upsert talent_pool_matches with status suggested
  DB-->>S: saved matches
  S-->>C: match list
  C-->>FE: 200 success true with matches
  Note over FE,C: HR then invites or dismisses each match which flips status to invited or dismissed

Background jobs & async

When your feature needs async processing, you add a queue to QueueService and a worker in src/workers/. Existing infrastructure you slot into:

Queue (QueueService field) Worker file Purpose
emailQueue email.worker.ts OTP, password reset, notifications
whatsappQueue whatsapp.worker.ts WhatsApp messages
databaseQueue database.worker.ts Heavy DB ops, user deletion, mentor multipliers
cleanupQueue cleanup.worker.ts Scheduled cleanup (~24h)
kpiQueue kpi.worker.ts Dashboard KPI recompute (~15min)
resumeAnalysisQueue resumeAnalysis.worker.ts AI resume screening pipeline
salaryBenchmarkQueue salaryBenchmark.worker.ts Salary benchmarking
warningQueue warning.worker.ts Student warning automation
workflowQueue workflow.worker.ts Sales automation workflow steps
aaryaSyncQueue aaryaSync.worker.ts Poll ElevenLabs call status (~15min)

Mechanics: - All queues share the same Redis connection block (REDIS_HOST / REDIS_PORT) and defaultJobOptions: { removeOnComplete, removeOnFail }. - QueueService is a singleton — call QueueService.getInstance().addXJob(...). - Workers are started by side-effect import in src/index.ts (startup sequence step 7). Forgetting that import means jobs queue up but never process. - Bull Board UI is mounted at /admin/queues for inspecting queues.

Socket events (only if realtime): added in src/socket.ts. Webhooks (e.g. Exotel status callbacks) are their own route classes such as ExotelWebhookRoutes.


External integrations

A feature that calls a third party should isolate it behind a service and degrade gracefully. Examples in the codebase your feature may touch:

Integration Used by Env vars Failure / fallback
mr-hire-backend resume worker, AI screening MR_HIRE_BACKEND_URL, INTERNAL_SERVICE_TOKEN 401 if token unset; worker logs and skips
OpenAI / Groq / NVIDIA screening, JD/quiz generation provider keys costs logged to ledger; errors caught per-stage
ElevenLabs / VAPI / Twilio AI voice interviews / calls provider keys call cost recorded via recordCallCost
AWS S3 resumes, recordings, banners AWS_*, bucket vars use S3Service, never the SDK directly
Razorpay token purchase, payments RAZORPAY_KEY_ID/SECRET mocked in tests
Exotel CRM click-to-call + SMS EXOTEL_* feature self-disables to tel: links when unset

Rule: read keys from process.env, never hardcode. Many integrations are feature-flagged off by absence of env vars (Exotel is the canonical example). See external-integrations.


Status lifecycles

TalentPoolMatch.status is the clearest lifecycle in the worked example (src/entities/TalentPoolMatch.entity.ts, default suggested):

stateDiagram-v2
  [*] --> suggested: run-matching creates the match
  suggested --> invited: HR invites the candidate
  suggested --> dismissed: HR dismisses the match
  invited --> [*]
  dismissed --> [*]

A generic entity with a status enum (like the Widget template) typically follows:

stateDiagram-v2
  [*] --> draft: create
  draft --> active: publish or activate
  active --> archived: archive
  archived --> active: restore
  active --> [*]

Edge cases, limits & gotchas

  • Forgetting Step 2 (DataSource registration) is the #1 mistake. The entity class compiles fine but getRepository(Widget) throws EntityMetadataNotFound at runtime. Always add the import AND the array entry in src/config/database.ts.
  • Auto-sync is a loaded gun. synchronize: true runs on every boot against the shared DB. Renaming/removing a column can drop data. Treat schema changes carefully; coordinate on shared environments. See data-and-typeorm-guidelines.
  • Arrow-function controller methods. If you write a normal method (async create() {}) and pass this.controller.create to a route, this is undefined at call time. Use class-property arrow functions, as every controller here does.
  • authMiddleware already hits the DB and attaches the full User. Don't re-query the user in your controller — use req.user. It also enforces blocked accounts, password-change logout-everywhere, and per-session revoke, so a valid-looking JWT can still 401/403.
  • adminMiddleware must come after authMiddleware (it reads req.user). ADMIN is the universal-access role; there is no separate superadmin middleware in this file (SUPERADMIN was collapsed into ADMIN — see the pre-sync migration note in database.ts).
  • Mount-prefix vs route-path coupling. A path renders as mountPrefix + routePath. If your route paths are bare (/, /:id) mount at a specific prefix (/api/widgets); if they already carry the full sub-path, mount at /api. Mixing the two double-prefixes your URLs.
  • Public routes are intentional. /api/candidate-quiz and /api/judge0 skip auth on purpose (token-in-URL is the auth). Don't blanket-add authMiddleware without checking.
  • Multi-platform. Requests may carry an x-platform header (mr-mentor / my-analytics-school); a Platform entity + gateway route by it. If your feature is platform-specific, read the header rather than hardcoding behavior. See multi-platform-architecture.
  • Fire-and-forget writes. For analytics/ledger rows that must never break the main flow, follow PipelineCostService.record — catch the error, log a warning, return null.
  • Worker side-effect imports. A new worker that is not await import(...)-ed in src/index.ts will silently never run; jobs pile up in Redis.
  • Pagination contract. Services return { data, total, page, limit }; controllers spread it (res.json({ success: true, ...result })). Keep that shape so frontends stay consistent.