Skip to content

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

  1. Prerequisites
  2. The Big Picture
  3. NestJS Fundamentals
  4. TypeScript Patterns in This Project
  5. Prisma & the Database
  6. Authentication: How Login Works
  7. The Permission Model
  8. Multitenancy
  9. Walkthrough: Adding a New Feature
  10. Testing
  11. Common Mistakes & Troubleshooting
  12. 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

  1. Edit prisma/schema.prisma
  2. Run npx prisma migrate dev --name describe-the-change
  3. Prisma generates a SQL file in prisma/migrations/
  4. The migration is applied to your local database
  5. The TypeScript client is regenerated automatically
  6. 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:

Authorization: Bearer eyJhbGciOiJIUzI1NiI...
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:

POST /api/v1/auth/refresh
{ "refreshToken": "a1b2c3..." }

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:

where: {
  validFrom: { lte: now },
  OR: [{ validUntil: null }, { validUntil: { gt: now } }],
}

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:

SELECT * FROM students WHERE tenant_id = 'school-a-uuid';

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?

  1. User logs in → JWT is created with tenantId embedded
  2. On each request, JwtStrategy extracts tenantId from the JWT
  3. tenantId is available on request.user.tenantId
  4. 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:

  1. Add tenantId String @map("tenant_id") @db.Uuid column
  2. Add tenant Tenant @relation(...) relationship
  3. Add the model to Tenant's relations list
  4. In the service, always filter by tenantId in every query
  5. Get tenantId from 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:

npx prisma migrate dev --name add-teachers-table

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 BaseTenantedCrudService from src/common/services/. It provides shared findAll, findOne, update, remove, and toScopedResponse methods — subclasses implement create(), getPrismaDelegate(), getScopeFieldMappings(), and extractNativeUpdates(). See src/students/students.service.ts for 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)