Stack Guide for New Developers¶
Audience: Junior developers joining the SIS backend team. Prerequisite knowledge: See Section 0 — Prerequisites if you're starting from scratch. Goal: Get you productive in this codebase within few weeks.
Related docs you should also read: everything in the docs folder.
Table of Contents¶
- Prerequisites
- The Big Picture
- NestJS Fundamentals
- TypeScript Patterns in This Project
- Prisma & the Database
- Authentication: How Login Works
- The Permission Model
- Multitenancy
- Walkthrough: Adding a New Feature
- Testing
- Common Mistakes & Troubleshooting
- Glossary
0. Prerequisites¶
Before diving into the codebase, make sure you're comfortable with the foundational topics below. You don't need to be an expert — a working understanding is enough. Each subsection explains why the topic matters here.
0.1 How the Web Works¶
Every feature in this project starts with an HTTP request and ends with an HTTP response. Understanding the client-server model, URLs, HTTP methods, status codes, headers, and the request/response cycle is essential for reasoning about anything the backend does.
Resources: - [add your resource here] - [add your resource here]
0.2 Terminal / Command Line¶
The dev server, database migrations, tests, linting, and git — all run from the terminal. You'll spend significant time there, so fluency with basic shell commands, navigating directories, and reading command output is non-negotiable.
Resources: - [add your resource here] - [add your resource here]
0.3 Git & Version Control¶
All code changes go through Git. We use feature branches, pull requests, and code review. You need to understand commits, branches, merges, diffs, and how to resolve conflicts.
Resources: - [add your resource here] - [add your resource here]
0.4 Programming Fundamentals (OOP)¶
NestJS is built on classes, interfaces, inheritance, and dependency injection. If you don't have a solid grasp of object-oriented programming concepts — classes, constructors, access modifiers, abstract classes, interfaces — the framework code will be hard to follow.
Resources: - [add your resource here] - [add your resource here]
0.5 TypeScript / JavaScript¶
The entire backend is TypeScript. You need to be comfortable with modern JavaScript (async/await, destructuring, arrow functions, Promises) and TypeScript's type system (interfaces, generics, type narrowing, union types).
Resources: - [add your resource here] - [add your resource here]
0.6 SQL & Relational Databases¶
All data lives in PostgreSQL. Even though Prisma abstracts most queries, you need a mental model for tables, rows, columns, primary keys, foreign keys, JOINs, and indexes to understand what Prisma generates and to debug data issues.
Resources: - [add your resource here] - [add your resource here]
0.7 Package Managers (npm)¶
npm install, npm run, and npx are used constantly. Understanding how package.json, node_modules, lock files, and scripts work will save you from common "it doesn't work on my machine" issues.
Resources: - [add your resource here] - [add your resource here]
0.8 REST APIs¶
This project exposes a REST API. You need to understand REST conventions: resources, HTTP methods (GET, POST, PATCH, DELETE), status codes (200, 201, 400, 401, 404), request/response bodies in JSON, and how clients interact with APIs.
Resources: - [add your resource here] - [add your resource here]
0.9 What a Web Framework Is¶
Express.js runs underneath NestJS. Understanding why web frameworks exist — routing, middleware, request parsing, error handling — helps you appreciate what NestJS adds on top (modules, DI, decorators, guards).
Resources: - [add your resource here] - [add your resource here]
0.10 Backend Design Patterns¶
This codebase uses Controller-Service architecture, dependency injection, and decorator-based metadata extensively. Knowing these patterns (and why they exist) makes the code structure intuitive rather than mysterious.
Resources: - [add your resource here] - [add your resource here]
0.11 Authentication & Authorization¶
The project implements JWT-based authentication and role-based authorization. Understanding the difference between authentication ("who are you?") and authorization ("what can you do?"), how tokens work, and why passwords are hashed will make Sections 5 and 6 much easier to follow.
Resources: - [add your resource here] - [add your resource here]
1. The Big Picture¶
SIS is a school management platform. Multiple schools (tenants) share the same database and API. Each school has its own users, students, roles, and permissions. The backend is a single NestJS application that handles all of them.
The Stack at a Glance¶
Each layer in the stack solves a specific problem. The table below maps each technology to its role:
┌──────────────────────────────────────────────────────────────────┐
│ A request arrives at POST /api/v1/students │
│ │
│ 1. Express receives the HTTP request │
│ 2. NestJS routes it to the right controller method │
│ 3. Passport.js validates the JWT token (is this user real?) │
│ 4. ScopeGuard checks permissions (can this user do this?) │
│ 5. class-validator validates the request body (is the data ok?) │
│ 6. Service runs business logic │
│ 7. Prisma talks to PostgreSQL (reads/writes data) │
│ 8. Interceptor filters response fields (hide sensitive data) │
│ 9. Express sends the JSON response back │
└──────────────────────────────────────────────────────────────────┘
| Technology | Role | Think of it as... |
|---|---|---|
| NestJS | Web framework | The skeleton of the app. Organizes code into modules, handles routing, dependency injection. |
| TypeScript | Language | JavaScript with types. Catches bugs at compile time. |
| Prisma | ORM (Object-Relational Mapper) | Translates between TypeScript objects and SQL queries. You write prisma.student.findMany(), it runs SELECT * FROM students. |
| PostgreSQL | Database | Where all data lives. Tables, rows, relationships. |
| Passport.js | Authentication library | Handles "who are you?" — validates passwords and JWT tokens. |
| class-validator | Validation library | Handles "is this input valid?" — checks DTOs with decorators like @IsEmail(). |
| Jest | Test framework | Runs your unit and e2e tests. |
| argon2 | Password hashing | Securely hashes passwords. Never store plain text passwords. |
2. NestJS Fundamentals¶
NestJS is a framework built on top of Express. It adds structure via modules, controllers, services, and dependency injection. If you've used Angular or Springboot the concepts are similar.
If these concepts are new, review Prerequisites 0.9 and 0.10 first.
2.1 Modules¶
A module is a self-contained unit of functionality. Every feature gets its own module.
// src/students/students.module.ts
@Module({
imports: [PrismaModule, PermissionsModule], // modules this one depends on
controllers: [StudentsController], // handles HTTP requests
providers: [StudentsService], // business logic
exports: [StudentsService], // what other modules can use
})
export class StudentsModule {}
Key rules:
- AppModule (in app.module.ts) is the root. It imports all other modules.
- A module can only use services from modules it explicitly imports.
- If module A needs something from module B, module A imports BModule (not BService directly).
2.2 Controllers¶
Controllers handle HTTP requests. They're thin — they validate input, call a service, and return the result. No business logic here.
// src/students/students.controller.ts
@Controller('students') // all routes start with /api/v1/students
export class StudentsController {
// NestJS automatically provides StudentsService (dependency injection)
constructor(private readonly studentsService: StudentsService) {}
@Get(':id') // GET /api/v1/students/:id
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.studentsService.findOne(id, tenantId);
}
}
Common decorators on controllers:
| Decorator | What it does | Example |
|---|---|---|
@Controller('path') |
Sets the base route | @Controller('students') |
@Get(), @Post(), @Patch(), @Delete() |
HTTP method | @Get(':id') |
@Param('name') |
Extracts a URL parameter | @Param('id') id: string |
@Body() |
Extracts the request body | @Body() dto: CreateStudentDto |
@Req() |
Gives you the raw request object | @Req() req: AuthenticatedRequest |
@UseGuards() |
Applies a guard (security check) | @UseGuards(JwtAuthGuard) |
@UseInterceptors() |
Applies an interceptor (transforms response) | @UseInterceptors(FieldFilterInterceptor) |
@HttpCode() |
Overrides the default status code | @HttpCode(HttpStatus.OK) on a POST |
2.3 Services¶
Services contain all business logic. They're injected into controllers (and into other services) via dependency injection (see Prerequisites 0.10).
// src/students/students.service.ts
@Injectable() // This decorator tells NestJS this class can be injected
export class StudentsService {
constructor(private readonly prisma: PrismaService) {}
async findOne(id: string, tenantId: string): Promise<Student> {
const student = await this.prisma.student.findFirst({
where: { id, tenantId }, // always filter by tenantId!
});
if (!student) {
throw new NotFoundException('Student not found');
}
return student;
}
}
Key rules:
- Services are the only place business logic live.
- Throw NestJS exceptions (NotFoundException, ForbiddenException, etc.) — they automatically become proper HTTP error responses.
- Don't catch exceptions just to re-throw them. Let them bubble up to the global error handler.
2.4 Dependency Injection (DI)¶
DI is how NestJS wires things together. You declare what you need in the constructor, and NestJS provides it.
// You write:
constructor(private readonly prisma: PrismaService) {}
// NestJS does this behind the scenes:
const prisma = new PrismaService();
const service = new StudentsService(prisma);
You never write new StudentsService() yourself. NestJS handles object creation. This is important because:
- It makes testing easy (you can swap real services for mocks).
- It manages the lifecycle (services are singletons by default — one instance shared everywhere).
2.5 The Request Pipeline¶
When a request arrives, it passes through a series of layers in order:
Request → Middleware → Guards → Interceptors (before) → Pipes → Controller → Service
↓
Response ← Interceptors (after) ← Exception Filters ←←←←←←←←←←←←←←←←←
| Layer | Purpose | Our examples |
|---|---|---|
| Middleware | Runs before routing. Can modify request. | helmet (security headers), cookieParser (parse cookies), express.json (body parsing) |
| Guards | Access control. Returns true/false. | JwtAuthGuard (is user authenticated?), ScopeGuard (does user have permission?) |
| Pipes | Input transformation and validation. | ValidationPipe (validates DTOs), ParseUUIDPipe (validates UUID format) |
| Interceptors | Transform the response or add logic around the handler. | FieldFilterInterceptor (strips unauthorized fields from response) |
| Exception Filters | Catch errors and format error responses. | AllExceptionsFilter (consistent error JSON format) |
2.6 Barrel Exports¶
Every module folder has an index.ts file that re-exports its public API:
// src/auth/index.ts
export { AuthModule } from './auth.module';
export { AuthService } from './auth.service';
export { JwtAuthGuard } from './guards/jwt-auth.guard';
// etc.
Rule: When importing from another module, always import from the folder, not from internal files.
// GOOD - import from the barrel
import { JwtAuthGuard } from '../auth';
// BAD - reaching into internal files
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
Within the same module, direct imports are fine.
3. TypeScript Patterns in This Project¶
3.1 Strict Null Checks¶
strictNullChecks is enabled. This means TypeScript won't let you use a value that might be null or undefined without checking first.
// This won't compile:
const student = await prisma.student.findFirst({ where: { id } });
return student.firstName; // Error: student might be null
// Do this instead:
const student = await prisma.student.findFirst({ where: { id } });
if (!student) {
throw new NotFoundException('Student not found');
}
return student.firstName; // OK — TypeScript knows it's not null here
3.2 DTOs (Data Transfer Objects)¶
DTOs define the shape of incoming request data. They use decorators from class-validator for validation.
// src/auth/dto/login.dto.ts
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'admin@demo-school.dev' })
@IsEmail()
email: string;
@ApiProperty({ example: 'changeme123' })
@IsString()
@IsNotEmpty()
password: string;
}
Things to know:
- @ApiProperty() / @ApiPropertyOptional() generate Swagger docs. Always include them.
- The global ValidationPipe (configured in main.ts) automatically validates incoming requests against DTOs.
- whitelist: true strips any fields not in the DTO. forbidNonWhitelisted: true returns an error if extra fields are sent.
- Create separate DTOs for create and update: CreateStudentDto, UpdateStudentDto. Use PartialType(CreateStudentDto) for the update DTO to make all fields optional.
3.3 Type Imports vs Value Imports¶
TypeScript has import type for importing types that don't exist at runtime. This matters here because of our compiler settings (isolatedModules: true + emitDecoratorMetadata: true).
// Use regular import for things that exist at runtime (classes, functions)
import { PrismaService } from '../prisma';
// Use `import type` for interfaces and type aliases
import type { Student } from '../generated/prisma/client';
import type { AuthenticatedRequest } from '../common';
Gotcha: If you use a type in a decorator position (like @Req() req: SomeType), TypeScript may warn about import type. In practice, import type works fine for @Req() params because NestJS doesn't use decorator metadata for @Req() resolution — TS emits Object in metadata, which is fine. The AuthenticatedRequest type uses import type throughout the codebase — follow that pattern.
3.4 ConfigService Types¶
The config service returns string | undefined by default:
// BAD — might be undefined
const secret = this.configService.get('JWT_SECRET'); // string | undefined
// GOOD — throws if not set
const secret = this.configService.getOrThrow<string>('JWT_SECRET'); // string
Use getOrThrow() for required configuration values. The app should crash at startup if a required env var is missing, not fail silently on the first request.
4. Prisma & the Database¶
Prisma is an ORM. It reads the schema file (prisma/schema.prisma), generates a TypeScript client, and lets you query the database with type-safe code.
4.1 The Schema¶
The schema file defines all database tables. Here's a simplified example:
model Student {
id String @id @default(uuid()) @db.Uuid // UUID primary key
tenantId String @map("tenant_id") @db.Uuid // FK to tenants
firstName String @map("first_name") // camelCase in TS, snake_case in DB
lastName String @map("last_name")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("students") // table name in PostgreSQL
}
Naming conventions in the schema:
- Model fields: camelCase (TypeScript convention)
- @map("snake_case"): maps to the actual column name in PostgreSQL
- @@map("table_name"): maps the model to the actual table name
- Relations: declared with @relation — Prisma generates JOINs for you
4.2 Common Queries¶
// Find many with a filter
const students = await this.prisma.student.findMany({
where: { tenantId },
});
// Find one by unique field (or combination)
const user = await this.prisma.user.findUnique({
where: { tenantId_email: { tenantId: tenant.id, email } },
});
// Find first matching record (for non-unique queries)
const student = await this.prisma.student.findFirst({
where: { id, tenantId },
});
// Create
const student = await this.prisma.student.create({
data: {
tenantId,
firstName: dto.firstName,
lastName: dto.lastName,
dateOfBirth: dto.dateOfBirth,
},
});
// Update
const student = await this.prisma.student.update({
where: { id },
data: { firstName: dto.firstName },
});
// Delete
await this.prisma.student.delete({
where: { id },
});
// Include related records (like SQL JOINs)
const userWithRoles = await this.prisma.userRole.findMany({
where: { userId },
include: {
role: {
include: { permissions: true },
},
},
});
4.3 findUnique vs findFirst¶
| Method | When to use | Returns |
|---|---|---|
findUnique |
Querying by a field (or combination) that has @unique or @@unique |
T \| null |
findFirst |
Querying by non-unique fields or when adding extra filters | T \| null |
When looking up a student by id AND tenantId, use findFirst — because the @@unique constraint is on (tenantId, email), not on id alone in combination with tenantId.
4.4 Migrations Workflow¶
- Edit
prisma/schema.prisma - Run
npx prisma migrate dev --name describe-the-change - Prisma generates a SQL file in
prisma/migrations/ - The migration is applied to your local database
- The TypeScript client is regenerated automatically
- Commit both the migration file and the schema change
Never edit a committed migration file. If you made a mistake, create a new migration to fix it.
4.5 Prisma Studio¶
Run npx prisma studio to open a web GUI for browsing and editing your database. Very useful for debugging — you can see exactly what data is in the tables.
5. Authentication: How Login Works¶
Authentication answers the question: "Who are you?"
5.1 The Login Flow (Password-First Two-Step)¶
The login flow validates credentials cross-tenant — the user only provides email + password, and the system finds all matching accounts across tenants.
Client sends POST /api/v1/auth/login
{
"email": "admin@demo-school.dev",
"password": "changeme123"
}
│
▼
┌─ AuthController.login() ──────────────────────────────────────────────┐
│ │
│ 1. AuthService.validateCredentials(email, password): │
│ a. Find ALL users with this email (across tenants) │
│ b. Verify password with argon2 against each match │
│ c. Return list of valid matches (userId, tenantId, tenantName) │
│ │
│ 2. Branch on number of matches: │
│ │
│ 0 matches → 401 "Invalid credentials" │
│ (dummy argon2 verify for timing consistency) │
│ │
│ 1 match → Auto-login: issue tokens, set cookies, return user │
│ │
│ 2+ matches → 200 { requiresTenantSelection: true, │
│ tenants: [...], selectionToken } │
│ Client must call POST /auth/login/select-tenant │
│ with { selectionToken, tenantId } within 60s │
│ │
└───────────────────────────────────────────────────────────────────────┘
│
▼
┌─ Token Issuance (on successful login) ────────────────────────────────┐
│ │
│ AuthService.login(): │
│ 1. Sign a JWT access token (15 min TTL): │
│ { sub: "user-uuid", tenantId: "tenant-uuid", │
│ roles: ["admin"], isPlatformAdmin: false } │
│ 2. Generate random refresh token (64 hex chars) │
│ 3. Hash refresh token (SHA-256), store hash in DB │
│ 4. Set HttpOnly cookies (access_token + refresh_token) │
│ 5. Return: { user: { id, email, firstName, lastName } } │
│ │
└───────────────────────────────────────────────────────────────────────┘
5.2 Subsequent Requests¶
After login, the client sends the access token in every request:
Request arrives
│
▼
JwtAuthGuard
│
▼
JwtStrategy.validate(payload):
- Extracts payload from token: { sub, tenantId, roles }
- Returns: { userId: payload.sub, tenantId, roles }
- This is set on request.user
│
▼
Controller can access req.user.userId, req.user.tenantId, req.user.roles
5.3 Token Refresh¶
Access tokens expire after 15 minutes. The client uses the refresh token to get a new pair:
The server: 1. Hashes the token, finds it in the database 2. Checks it's not revoked and not expired 3. Revokes the old refresh token (one-time use) 4. Issues a new access token + new refresh token 5. Returns the new pair
This is called refresh token rotation — each refresh token can only be used once, which limits the damage if one is stolen.
5.4 Key Files¶
| File | Purpose |
|---|---|
src/auth/auth.controller.ts |
HTTP endpoints: login, select-tenant, refresh, logout |
src/auth/auth.service.ts |
Cross-tenant credential validation, token generation, refresh logic |
src/auth/strategies/jwt.strategy.ts |
Passport strategy for validating JWT tokens (cookie + Bearer) |
src/auth/guards/jwt-auth.guard.ts |
Guard that requires a valid JWT |
src/auth/dto/select-tenant.dto.ts |
DTO for the tenant selection step (selectionToken + tenantId) |
src/auth/interfaces/authenticated-user.interface.ts |
Shape of request.user |
src/auth/interfaces/jwt-payload.interface.ts |
Shape of JWT payload |
6. The Permission Model¶
Authentication asks "who are you?" — authorization asks "what are you allowed to do?" Our permission model is called Entity-Scope.
6.1 The Core Concepts¶
Think of it like a filing cabinet:
Entity: "Students"
├── Scope: "Anagraphic Data"
│ ├── covers fields: first_name, last_name, date_of_birth, gender, address, tax_code
│ └── the admin role has: WRITE (read + write)
│ └── the teacher role has: READ (read only)
│
└── Scope: "Sensitive Data"
├── covers fields: disability_info, dietary_restrictions
└── the admin role has: WRITE (read + write)
└── the teacher role has: NONE (no access)
Entity = a domain object (students, teachers, attendance, etc.) Scope = a logical group of fields on that entity Role = a named set of permissions (admin, teacher, etc.)
6.2 Why Scopes Instead of Individual Fields?¶
A school admin doesn't want to manage 200 individual field permissions. They think in terms of:
- "The nurse can see medical data" (scope: sensitive)
- "The teacher can see student names and grades" (scope: anagraphic + scoring)
Scopes map to how schools actually think about data access.
6.3 How It Works at Runtime¶
When a teacher calls GET /api/v1/students/123:
1. JwtAuthGuard validates the JWT
→ request.user = { userId: "teacher-uuid", tenantId: "...", roles: ["teacher"] }
2. ScopeGuard reads the @RequireScopes('students', 'read') decorator
→ Calls PermissionsService.checkEntityAccess()
→ Loads teacher's roles, checks if any grant ANY read scope on 'students'
→ Teacher role has READ access on 'anagraphic' → allowed ✓
3. Controller calls service, which returns a scope-grouped response:
{ id, anagraphic: { firstName, lastName, dateOfBirth, gender },
sensitive: { disabilityInfo, dietaryRestrictions }, createdAt, updatedAt }
4. FieldFilterInterceptor runs AFTER the controller:
→ Reads the user's compiled permissions for the 'students' entity
→ Teacher has READ on 'anagraphic' but NONE on 'sensitive'
→ Strips entire scope groups the user can't access (the 'sensitive' key is removed)
→ id, createdAt, updatedAt are ALWAYS kept
5. Response:
{ id, anagraphic: { firstName, lastName, dateOfBirth, gender }, createdAt, updatedAt }
An admin making the same request would get all fields, because their role has READ or WRITE access on both anagraphic AND sensitive scopes.
6.4 Database Tables¶
The permission model uses these tables:
permission_entities System-level. Seeded. Defines entities like "students", "teachers".
│
▼
permission_scopes System-level. Seeded. Defines scopes like "anagraphic", "sensitive".
│
├──▶ scope_field_mappings System-level. Maps scopes to actual DB columns.
│
▼
role_permissions Tenant-level. Assigns ScopeAccess (NONE/READ/WRITE) per scope per role.
│
▼
roles Tenant-level. Named permission sets ("admin", "teacher").
│
▼
user_roles Tenant-level. Assigns roles to users. Has valid_from/valid_until
for temporal permissions (e.g., substitute teachers).
"System-level" tables are shared across all tenants and seeded from the code. "Tenant-level" tables have a tenant_id and are specific to each school.
6.5 Temporal Permissions¶
Substitute teachers get temporary access. The user_roles table has valid_from and valid_until columns:
-- This role assignment expires on March 21
INSERT INTO user_roles (user_id, role_id, tenant_id, valid_from, valid_until)
VALUES ('sub-teacher-id', 'ext-teacher-role-id', 'tenant-id',
'2026-03-10 08:00', '2026-03-21 18:00');
The PermissionsService query automatically filters to only active roles:
After valid_until passes, the role is simply ignored — no cleanup job needed.
6.6 Key Files¶
| File | Purpose |
|---|---|
src/permissions/permissions.service.ts |
Core logic: compiles user permissions from roles, checks entity access via checkEntityAccess() |
src/permissions/guards/scope.guard.ts |
Guard that reads @RequireScopes() metadata and checks entity-level access |
src/permissions/interceptors/field-filter.interceptor.ts |
Strips unauthorized scope groups from responses |
src/permissions/decorators/require-scope.decorator.ts |
@RequireScopes(entity, action) decorator |
prisma/seed/ |
Modular seed: rbac-catalogue.ts (entities, scopes, actions, field mappings), roles.ts (role grants), tenants.ts, users.ts, e2e-fixtures.ts |
7. Multitenancy¶
Multitenancy means multiple schools (tenants) share the same application and database. Each school's data is isolated — a teacher at School A can't see students from School B.
7.1 How It Works¶
Every tenant-scoped table has a tenant_id column:
This filtering happens in every service method:
async findAll(tenantId: string): Promise<Student[]> {
return this.prisma.student.findMany({
where: { tenantId }, // ALWAYS include this
});
}
If you forget the tenantId filter, data from ALL schools will be returned. This is the most critical rule in the codebase.
7.2 Where Does tenantId Come From?¶
- User logs in → JWT is created with
tenantIdembedded - On each request,
JwtStrategyextractstenantIdfrom the JWT tenantIdis available onrequest.user.tenantId- Controllers pass it to services:
this.service.findAll(req.user.tenantId)
7.3 Tenant Resolution (Login)¶
At login, the system does not rely on subdomains or a Host header. Instead, it validates the user's email + password cross-tenant — finding all matching accounts across all tenants:
- 1 match → the tenant is known automatically, user is logged in
- 2+ matches → the user picks which tenant to log into (tenant selection step)
After login, the tenantId is embedded in the signed JWT. All subsequent requests use the JWT payload for tenant context — no middleware or Host header resolution needed.
7.4 Rules for New Tenant-Scoped Tables¶
When creating a new table that holds tenant-specific data:
- Add
tenantId String @map("tenant_id") @db.Uuidcolumn - Add
tenant Tenant @relation(...)relationship - Add the model to
Tenant's relations list - In the service, always filter by
tenantIdin every query - Get
tenantIdfrom the authenticated request, never from the request body
8. Walkthrough: Adding a New Feature¶
Let's say you need to add a Teachers module. Here's the step-by-step process.
Step 1: Schema¶
Add the model to prisma/schema.prisma:
model Teacher {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
firstName String @map("first_name")
lastName String @map("last_name")
email String
subject String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, email])
@@map("teachers")
}
Don't forget to add teachers Teacher[] to the Tenant model.
Run the migration:
Step 2: Module Files¶
Create the folder structure:
src/teachers/
├── teachers.module.ts
├── teachers.controller.ts
├── teachers.service.ts
├── dto/
│ ├── create-teacher.dto.ts
│ └── update-teacher.dto.ts
└── index.ts
Step 3: Service¶
Note: The canonical pattern for domain CRUD services is to extend
BaseTenantedCrudServicefromsrc/common/services/. It provides sharedfindAll,findOne,update,remove, andtoScopedResponsemethods — subclasses implementcreate(),getPrismaDelegate(),getScopeFieldMappings(), andextractNativeUpdates(). Seesrc/students/students.service.tsfor the reference implementation. The example below is simplified for learning purposes.
// src/teachers/teachers.service.ts (simplified — production code extends BaseTenantedCrudService)
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Teacher } from '../generated/prisma/client';
import { PrismaService } from '../prisma';
import { CreateTeacherDto } from './dto/create-teacher.dto';
@Injectable()
export class TeachersService {
constructor(private readonly prisma: PrismaService) {}
async findAll(tenantId: string): Promise<Teacher[]> {
return this.prisma.teacher.findMany({
where: { tenantId },
});
}
async findOne(id: string, tenantId: string): Promise<Teacher> {
const teacher = await this.prisma.teacher.findFirst({
where: { id, tenantId },
});
if (!teacher) {
throw new NotFoundException('Teacher not found');
}
return teacher;
}
async create(tenantId: string, dto: CreateTeacherDto): Promise<Teacher> {
return this.prisma.teacher.create({
data: { tenantId, ...dto },
});
}
}
Step 4: DTOs¶
// src/teachers/dto/create-teacher.dto.ts
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateTeacherDto {
@ApiProperty({ example: 'Mario' })
@IsString()
@IsNotEmpty()
firstName: string;
@ApiProperty({ example: 'Rossi' })
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty({ example: 'mario.rossi@school.dev' })
@IsEmail()
email: string;
@ApiPropertyOptional({ example: 'Mathematics' })
@IsString()
@IsOptional()
subject?: string;
}
// src/teachers/dto/update-teacher.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateTeacherDto } from './create-teacher.dto';
export class UpdateTeacherDto extends PartialType(CreateTeacherDto) {}
Step 5: Controller¶
// src/teachers/teachers.controller.ts
import {
Controller, Get, Post, Patch, Param, Body,
ParseUUIDPipe, Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import type { AuthenticatedRequest } from '../common';
import { ProtectedResource } from '../common';
import { RequireScopes } from '../permissions';
import { TeachersService } from './teachers.service';
import { CreateTeacherDto } from './dto/create-teacher.dto';
@ApiTags('teachers')
@Controller('teachers')
@ProtectedResource() // applies JwtAuthGuard, ScopeGuard, ActionGuard, FieldWriteGuard, FieldFilterInterceptor, ApiBearerAuth
export class TeachersController {
constructor(private readonly teachersService: TeachersService) {}
@Get()
@RequireScopes('teachers', 'read')
@ApiOperation({ summary: 'List all teachers' })
async findAll(@Req() req: AuthenticatedRequest) {
return this.teachersService.findAll(req.user.tenantId);
}
@Get(':id')
@RequireScopes('teachers', 'read')
@ApiOperation({ summary: 'Get a teacher by ID' })
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@Req() req: AuthenticatedRequest,
) {
return this.teachersService.findOne(id, req.user.tenantId);
}
@Post()
@RequireAction('teachers', 'create')
@ApiOperation({ summary: 'Create a teacher' })
async create(
@Body() dto: CreateTeacherDto,
@Req() req: AuthenticatedRequest,
) {
return this.teachersService.create(req.user.tenantId, dto);
}
}
Step 6: Module + Barrel Export¶
// src/teachers/teachers.module.ts
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma';
import { PermissionsModule } from '../permissions';
import { TeachersController } from './teachers.controller';
import { TeachersService } from './teachers.service';
@Module({
imports: [PrismaModule, PermissionsModule],
controllers: [TeachersController],
providers: [TeachersService],
exports: [TeachersService],
})
export class TeachersModule {}
// src/teachers/index.ts
export { TeachersModule } from './teachers.module';
export { TeachersService } from './teachers.service';
Step 7: Register in AppModule¶
// src/app.module.ts
import { TeachersModule } from './teachers';
@Module({
imports: [
// ... existing modules
TeachersModule,
],
})
export class AppModule {}
Step 8: Seed Permission Data¶
If teachers.anagraphic scope doesn't already exist in the RBAC catalogue, add it to prisma/seed/rbac-catalogue.ts so that the permission system knows about the new entity and its fields.
Step 9: Test¶
Write unit tests for the service (mock Prisma). See section 9 for details.
9. Testing¶
9.1 Unit Tests¶
Unit tests test a single class in isolation. All dependencies are mocked.
// src/teachers/teachers.service.spec.ts
/* eslint-disable */ // <-- required at top of test files
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { TeachersService } from './teachers.service';
import { PrismaService } from '../prisma';
describe('TeachersService', () => {
let service: TeachersService;
let prisma: PrismaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TeachersService,
{
provide: PrismaService,
useValue: {
teacher: {
findMany: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
},
},
},
],
}).compile();
service = module.get<TeachersService>(TeachersService);
prisma = module.get<PrismaService>(PrismaService);
});
describe('findOne', () => {
it('should return a teacher when found', async () => {
const mockTeacher = { id: 'uuid', firstName: 'Mario', lastName: 'Rossi' };
(prisma.teacher.findFirst as jest.Mock).mockResolvedValue(mockTeacher);
const result = await service.findOne('uuid', 'tenant-uuid');
expect(result).toEqual(mockTeacher);
});
it('should throw NotFoundException when not found', async () => {
(prisma.teacher.findFirst as jest.Mock).mockResolvedValue(null);
await expect(service.findOne('uuid', 'tenant-uuid'))
.rejects.toThrow(NotFoundException);
});
});
});
Key points:
- /* eslint-disable */ at the top — mocks are any typed and trigger eslint errors.
- PrismaService is mocked with useValue — only mock the methods you actually call.
- Use jest.fn() to create mock functions, then use mockResolvedValue() to set return values.
- Test both the happy path and error cases.
9.2 Running Tests¶
npm test # Run all unit tests
npm run test:watch # Re-run on file changes (great for development)
npm test -- --testPathPatterns=teachers # Run only tests matching "teachers"
npm run test:cov # Generate coverage report
9.3 What to Test¶
| Layer | What to test | What to mock |
|---|---|---|
| Service | Business logic, error cases, data transformations | PrismaService, other services |
| Guard | Allow/deny decisions | Reflector, PermissionsService |
| Interceptor | Response transformation | PermissionsService, Reflector |
| Controller | Usually skip — covered by e2e tests | — |
9.4 E2E Tests¶
E2E tests spin up the full NestJS application and send real HTTP requests:
// test/teachers.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Teachers (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it('/teachers (GET) should require authentication', () => {
return request(app.getHttpServer())
.get('/api/v1/teachers')
.expect(401);
});
});
E2e tests live in the test/ folder and use a real database (or a test database). Run them with npm run test:e2e.
10. Common Mistakes & Troubleshooting¶
Forgetting tenantId in queries¶
Symptom: Data from other schools appears in responses.
Fix: Every findMany, findFirst, create, update, and delete call on a tenant-scoped table must include where: { tenantId } (or data: { tenantId } for creates).
Importing from internal module files¶
Symptom: Circular dependency warnings.
Fix: Import from the barrel ('../auth') not from internal files ('../auth/auth.service').
prisma generate not run after schema changes¶
Symptom: TypeScript errors like "Property 'teacher' does not exist on type 'PrismaClient'".
Fix: Run npx prisma generate. If you ran npx prisma migrate dev, it does this automatically.
Test files failing with eslint errors¶
Symptom: @typescript-eslint/no-unsafe-assignment errors in test files.
Fix: Add /* eslint-disable */ at the very top of the test file.
ConfigService.get() returns undefined¶
Symptom: JWT signing fails or app behaves unexpectedly.
Fix: Use configService.getOrThrow('KEY') instead of configService.get('KEY').
import type in a decorated parameter¶
Symptom: TS1272 error about type-only imports in decorated positions.
Fix: Use a class or regular import instead of import type for types used with decorators like @Req().
Migration drift¶
Symptom: prisma migrate dev says there's drift between the schema and migrations.
Fix: If you're on a local branch and haven't shared the migration, run npx prisma migrate reset to start fresh. If the migration has been committed, create a new migration to reconcile.
Port already in use¶
Symptom: Error: listen EADDRINUSE :::8080
Fix: Another process is using port 8080. Kill it, or change PORT in .env.
Swagger not showing a field¶
Symptom: A DTO field doesn't appear in the Swagger UI.
Fix: Add @ApiProperty() or @ApiPropertyOptional() decorator to the field.
11. Glossary¶
| Term | Definition |
|---|---|
| BaseTenantedCrudService | Generic base service in src/common/services/ for domain CRUD. Subclasses implement entity-specific create, scope mappings, and update extraction |
| Barrel export | An index.ts file that re-exports a module's public API |
| Custom fields | Admin-configurable fields per entity. CRUD in src/custom-fields/, stored as JSONB, assigned to existing scopes |
| DTO | Data Transfer Object — a class that defines the shape of request/response data |
| Entity | In our permission model, a domain object like "students" or "teachers" |
| Guard | A NestJS class that decides whether a request should proceed (returns true/false) |
| Interceptor | A NestJS class that can transform the request or response |
| JWT | JSON Web Token — a signed token containing user identity data |
| Middleware | Code that runs before the route handler (like Express middleware) |
| ORM | Object-Relational Mapper — translates between code objects and database tables |
| Pipe | A NestJS class that transforms or validates input data |
| RLS | Row-Level Security — PostgreSQL feature that restricts which rows a user can see |
| Scope | In our permission model, a logical group of fields within an entity |
| ProtectedResource | Composed decorator (src/common/decorators/) that applies JwtAuthGuard, ScopeGuard, ActionGuard, FieldWriteGuard, FieldFilterInterceptor, and ApiBearerAuth in one decorator |
| Seed | Pre-loading the database with initial data (demo users, permission configs, etc.) |
| Setup wizard | Tenant first-time configuration flow in src/setup/. State machine: SCHOOL→YEAR→DEPARTMENTS→GRADES→...→COMPLETE |
| Tenant | A school/organization in our multi-tenant system |
| Temporal permission | A role assignment with a start and end date (e.g., substitute teacher) |