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-
publicPostgres schema (e.g. themrlearn/*sync entities) need that schema pre-created — auto-sync does not create non-default schemas. For a normalpublictable 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
authMiddlewaredoes a DB lookup on every request, never re-fetch the user in your controller — readreq.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:
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)throwsEntityMetadataNotFoundat runtime. Always add the import AND the array entry insrc/config/database.ts. - Auto-sync is a loaded gun.
synchronize: trueruns 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 passthis.controller.createto a route,thisis undefined at call time. Use class-property arrow functions, as every controller here does. authMiddlewarealready hits the DB and attaches the fullUser. Don't re-query the user in your controller — usereq.user. It also enforces blocked accounts, password-change logout-everywhere, and per-session revoke, so a valid-looking JWT can still 401/403.adminMiddlewaremust come afterauthMiddleware(it readsreq.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 indatabase.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-quizand/api/judge0skip auth on purpose (token-in-URL is the auth). Don't blanket-addauthMiddlewarewithout checking. - Multi-platform. Requests may carry an
x-platformheader (mr-mentor/my-analytics-school); aPlatformentity + 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, returnnull. - Worker side-effect imports. A new worker that is not
await import(...)-ed insrc/index.tswill 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.