Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions errorHandlingGuide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# TeachLink Backend — Developer Guide

## Error Handling

All modules use the custom exceptions from `src/common/exceptions/app.exceptions.ts`.
**Never** throw NestJS built-in exceptions directly in services or controllers — use the custom classes below instead.

### Exception mapping

| Scenario | Class | HTTP |
|---|---|---|
| Resource not found (by id or field) | `ResourceNotFoundException(resource, id?)` | 404 |
| Business rule / state violation | `BusinessValidationException(message)` | 422 |
| Duplicate resource | `ResourceConflictException(resource, field?)` | 409 |
| Access denied (ownership/role) | `ForbiddenOperationException(message?)` | 403 |
| Bad credentials / user not found in JWT | `InvalidCredentialsException(message?)` | 401 |
| Token expired or already used | `InvalidTokenException(message?)` | 401 |
| External service down | `ServiceUnavailableException(service)` | 503 |
| Rate limit exceeded | `RateLimitExceededException(retryAfterSeconds?)` | 429 |

**Exceptions still using NestJS built-ins (by design):**
- `BadRequestException` — raw input / parse validation (400), e.g. invalid JSON, missing header
- `UnauthorizedException` — authentication context missing, e.g. no JWT, missing tenant context

### GlobalExceptionFilter

Registered globally in `AppModule`. It:
- Returns a consistent `{ success, statusCode, message, path, timestamp, correlationId }` envelope
- Logs all non-HTTP exceptions and 5xx responses via NestJS `Logger`

### Pattern examples

```typescript
// Not found
throw new ResourceNotFoundException('Course', courseId);

// Business rule violation
throw new BusinessValidationException('Cannot submit a PUBLISHED course for review.');

// Duplicate
throw new ResourceConflictException('Tenant', 'slug');

// Auth
throw new InvalidCredentialsException('User not found');

// Rate limit
throw new RateLimitExceededException(60); // retry in 60 s
```
9 changes: 7 additions & 2 deletions src/assessment/assessments.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { ResourceNotFoundException } from '../common/exceptions/app.exceptions';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssessmentStatus } from './enums/assessment-status.enum';
Expand Down Expand Up @@ -37,6 +38,10 @@ export class AssessmentsService {
relations: ['questions'],
});

if (!assessment) {
throw new ResourceNotFoundException('Assessment', assessmentId);
}

return this.attemptRepo.save({
studentId,
assessment,
Expand Down Expand Up @@ -133,7 +138,7 @@ export class AssessmentsService {
});

if (!attempt?.assessment?.questions) {
throw new NotFoundException(`Attempt ${attemptId} not found`);
throw new ResourceNotFoundException('AssessmentAttempt', attemptId);
}

const endTime =
Expand Down
5 changes: 3 additions & 2 deletions src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { InvalidCredentialsException } from '../common/exceptions/app.exceptions';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { InjectRepository } from '@nestjs/typeorm';
Expand Down Expand Up @@ -36,7 +37,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
async validate(payload: JwtPayload): Promise<any> {
const user = await this.userRepository.findOneBy({ id: payload.sub });
if (!user) {
throw new UnauthorizedException('User not found');
throw new InvalidCredentialsException('User not found');
}

// Fetch roles and permissions for the user
Expand Down
137 changes: 137 additions & 0 deletions src/common/exceptions/app.exceptions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { HttpStatus } from '@nestjs/common';
import {
ResourceNotFoundException,
ForbiddenOperationException,
ResourceConflictException,
BusinessValidationException,
ServiceUnavailableException,
InvalidCredentialsException,
InvalidTokenException,
RateLimitExceededException,
} from './app.exceptions';

describe('Custom Exceptions', () => {
describe('ResourceNotFoundException', () => {
it('returns 404 with resource name only', () => {
const ex = new ResourceNotFoundException('Course');
expect(ex.getStatus()).toBe(HttpStatus.NOT_FOUND);
const body = ex.getResponse() as any;
expect(body.message).toBe('Course was not found');
expect(body.error).toBe('Not Found');
expect(body.statusCode).toBe(HttpStatus.NOT_FOUND);
});

it('returns 404 with resource name and id', () => {
const ex = new ResourceNotFoundException('Course', 'abc-123');
const body = ex.getResponse() as any;
expect(body.message).toBe("Course with id 'abc-123' was not found");
});

it('accepts numeric id', () => {
const ex = new ResourceNotFoundException('User', 42);
const body = ex.getResponse() as any;
expect(body.message).toBe("User with id '42' was not found");
});
});

describe('ForbiddenOperationException', () => {
it('returns 403 with default message', () => {
const ex = new ForbiddenOperationException();
expect(ex.getStatus()).toBe(HttpStatus.FORBIDDEN);
const body = ex.getResponse() as any;
expect(body.message).toBe('You do not have permission to perform this action');
expect(body.error).toBe('Forbidden');
});

it('accepts a custom message', () => {
const ex = new ForbiddenOperationException('Only the owner may do this');
const body = ex.getResponse() as any;
expect(body.message).toBe('Only the owner may do this');
});
});

describe('ResourceConflictException', () => {
it('returns 409 without field', () => {
const ex = new ResourceConflictException('Tenant');
expect(ex.getStatus()).toBe(HttpStatus.CONFLICT);
const body = ex.getResponse() as any;
expect(body.message).toBe('Tenant already exists');
expect(body.error).toBe('Conflict');
});

it('returns 409 with field', () => {
const ex = new ResourceConflictException('User', 'email');
const body = ex.getResponse() as any;
expect(body.message).toBe('User with this email already exists');
});
});

describe('BusinessValidationException', () => {
it('returns 422 with message', () => {
const ex = new BusinessValidationException('Workflow must be inactive before editing');
expect(ex.getStatus()).toBe(HttpStatus.UNPROCESSABLE_ENTITY);
const body = ex.getResponse() as any;
expect(body.message).toBe('Workflow must be inactive before editing');
expect(body.error).toBe('Unprocessable Entity');
});
});

describe('ServiceUnavailableException', () => {
it('returns 503 with service name', () => {
const ex = new ServiceUnavailableException('PaymentService');
expect(ex.getStatus()).toBe(HttpStatus.SERVICE_UNAVAILABLE);
const body = ex.getResponse() as any;
expect(body.message).toContain('PaymentService');
expect(body.error).toBe('Service Unavailable');
});
});

describe('InvalidCredentialsException', () => {
it('returns 401 with default message', () => {
const ex = new InvalidCredentialsException();
expect(ex.getStatus()).toBe(HttpStatus.UNAUTHORIZED);
const body = ex.getResponse() as any;
expect(body.message).toBe('Invalid credentials');
expect(body.error).toBe('Unauthorized');
});

it('accepts a custom message', () => {
const ex = new InvalidCredentialsException('User not found');
const body = ex.getResponse() as any;
expect(body.message).toBe('User not found');
});
});

describe('InvalidTokenException', () => {
it('returns 401 with default message', () => {
const ex = new InvalidTokenException();
expect(ex.getStatus()).toBe(HttpStatus.UNAUTHORIZED);
const body = ex.getResponse() as any;
expect(body.message).toBe('Invalid or expired token');
expect(body.error).toBe('Unauthorized');
});

it('accepts a custom message', () => {
const ex = new InvalidTokenException('Token has expired');
const body = ex.getResponse() as any;
expect(body.message).toBe('Token has expired');
});
});

describe('RateLimitExceededException', () => {
it('returns 429 without retry info', () => {
const ex = new RateLimitExceededException();
expect(ex.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS);
const body = ex.getResponse() as any;
expect(body.message).toContain('rate limit');
expect(body.error).toBe('Too Many Requests');
expect(body.retryAfterSeconds).toBeUndefined();
});

it('includes retryAfterSeconds when provided', () => {
const ex = new RateLimitExceededException(60);
const body = ex.getResponse() as any;
expect(body.retryAfterSeconds).toBe(60);
});
});
});
45 changes: 38 additions & 7 deletions src/common/exceptions/app.exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,46 @@ export class ServiceUnavailableException extends HttpException {
}

/**
* Thrown when a request exceeds the configured timeout.
* Maps to HTTP 504 Gateway Timeout.
* Thrown when authentication credentials are invalid or the user cannot be found.
* Maps to HTTP 401 Unauthorized.
*/
export class RequestTimeoutException extends HttpException {
constructor(timeoutMs: number) {
const message = `Request timeout after ${timeoutMs}ms`;
export class InvalidCredentialsException extends HttpException {
constructor(message = 'Invalid credentials') {
super(
{ message, error: 'Gateway Timeout', statusCode: HttpStatus.GATEWAY_TIMEOUT },
HttpStatus.GATEWAY_TIMEOUT,
{ message, error: 'Unauthorized', statusCode: HttpStatus.UNAUTHORIZED },
HttpStatus.UNAUTHORIZED,
);
}
}

/**
* Thrown when a token (e.g. email verification, password reset) is invalid, used, or expired.
* Maps to HTTP 401 Unauthorized.
*/
export class InvalidTokenException extends HttpException {
constructor(message = 'Invalid or expired token') {
super(
{ message, error: 'Unauthorized', statusCode: HttpStatus.UNAUTHORIZED },
HttpStatus.UNAUTHORIZED,
);
}
}

/**
* Thrown when a client exceeds the allowed request rate.
* Maps to HTTP 429 Too Many Requests.
*/
export class RateLimitExceededException extends HttpException {
constructor(retryAfterSeconds?: number) {
const message = 'You have exceeded the request rate limit. Please wait before retrying.';
super(
{
message,
error: 'Too Many Requests',
statusCode: HttpStatus.TOO_MANY_REQUESTS,
...(retryAfterSeconds !== undefined && { retryAfterSeconds }),
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
13 changes: 3 additions & 10 deletions src/common/guards/throttle.guard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, ExecutionContext, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { Injectable, ExecutionContext, Logger } from '@nestjs/common';
import { RateLimitExceededException } from '../exceptions/app.exceptions';
import { ThrottlerGuard, ThrottlerLimitDetail } from '@nestjs/throttler';
import { Request, Response } from 'express';
/**
Expand Down Expand Up @@ -29,15 +30,7 @@ export class CustomThrottleGuard extends ThrottlerGuard {
response.setHeader('X-RateLimit-Limit', throttlerLimitDetail.limit);
response.setHeader('X-RateLimit-Remaining', 0);
response.setHeader('X-RateLimit-Reset', Math.floor(Date.now() / 1000) + ttlSeconds);
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
error: 'Too Many Requests',
message: 'You have exceeded the request rate limit. Please wait before retrying.',
retryAfterSeconds: ttlSeconds,
},
HttpStatus.TOO_MANY_REQUESTS,
);
throw new RateLimitExceededException(ttlSeconds);
}
private resolveClientIp(request: Request): string {
const forwarded = request.headers['x-forwarded-for'];
Expand Down
Loading