diff --git a/errorHandlingGuide.md b/errorHandlingGuide.md new file mode 100644 index 00000000..29bd8668 --- /dev/null +++ b/errorHandlingGuide.md @@ -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 +``` diff --git a/src/assessment/assessments.service.ts b/src/assessment/assessments.service.ts index 00800cd2..86ef3a0f 100644 --- a/src/assessment/assessments.service.ts +++ b/src/assessment/assessments.service.ts @@ -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'; @@ -37,6 +38,10 @@ export class AssessmentsService { relations: ['questions'], }); + if (!assessment) { + throw new ResourceNotFoundException('Assessment', assessmentId); + } + return this.attemptRepo.save({ studentId, assessment, @@ -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 = diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index f645a0b8..e8f3cdde 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -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'; @@ -36,7 +37,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { async validate(payload: JwtPayload): Promise { 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 diff --git a/src/common/exceptions/app.exceptions.spec.ts b/src/common/exceptions/app.exceptions.spec.ts new file mode 100644 index 00000000..a1a34dc3 --- /dev/null +++ b/src/common/exceptions/app.exceptions.spec.ts @@ -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); + }); + }); +}); diff --git a/src/common/exceptions/app.exceptions.ts b/src/common/exceptions/app.exceptions.ts index 84618023..c210dd2c 100644 --- a/src/common/exceptions/app.exceptions.ts +++ b/src/common/exceptions/app.exceptions.ts @@ -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, ); } } diff --git a/src/common/guards/throttle.guard.ts b/src/common/guards/throttle.guard.ts index a7e56e52..597098ac 100644 --- a/src/common/guards/throttle.guard.ts +++ b/src/common/guards/throttle.guard.ts @@ -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'; /** @@ -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']; diff --git a/src/common/interceptors/global-exception.filter.spec.ts b/src/common/interceptors/global-exception.filter.spec.ts new file mode 100644 index 00000000..fe735137 --- /dev/null +++ b/src/common/interceptors/global-exception.filter.spec.ts @@ -0,0 +1,108 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { GlobalExceptionFilter } from './global-exception.filter'; +import { + ResourceNotFoundException, + ForbiddenOperationException, +} from '../exceptions/app.exceptions'; + +jest.mock('../utils/correlation.utils', () => ({ + getCorrelationId: () => 'test-correlation-id', +})); + +function buildMockHost(overrides: { url?: string; method?: string } = {}) { + const json = jest.fn(); + const status = jest.fn().mockReturnValue({ json }); + const response = { status }; + const request = { url: overrides.url ?? '/test', method: overrides.method ?? 'GET' }; + + return { + switchToHttp: () => ({ + getResponse: () => response, + getRequest: () => request, + }), + json, + status, + response, + }; +} + +describe('GlobalExceptionFilter', () => { + let filter: GlobalExceptionFilter; + + beforeEach(() => { + filter = new GlobalExceptionFilter(); + }); + + it('maps HttpException to its status and message', () => { + const mock = buildMockHost(); + filter.catch(new HttpException('bad request', HttpStatus.BAD_REQUEST), mock as any); + + expect(mock.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(mock.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + statusCode: HttpStatus.BAD_REQUEST, + correlationId: 'test-correlation-id', + }), + ); + }); + + it('maps unknown errors to 500', () => { + const mock = buildMockHost(); + filter.catch(new Error('db crashed'), mock as any); + + expect(mock.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(mock.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'db crashed', + }), + ); + }); + + it('maps ResourceNotFoundException to 404', () => { + const mock = buildMockHost(); + filter.catch(new ResourceNotFoundException('Course', 'abc'), mock as any); + + expect(mock.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(mock.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.NOT_FOUND, + message: "Course with id 'abc' was not found", + }), + ); + }); + + it('maps ForbiddenOperationException to 403', () => { + const mock = buildMockHost(); + filter.catch(new ForbiddenOperationException('Only owners may delete'), mock as any); + + expect(mock.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN); + expect(mock.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.FORBIDDEN, + message: 'Only owners may delete', + }), + ); + }); + + it('includes path and timestamp in every response', () => { + const mock = buildMockHost({ url: '/api/courses/1' }); + filter.catch(new HttpException('not found', 404), mock as any); + + const call = mock.json.mock.calls[0][0]; + expect(call.path).toBe('/api/courses/1'); + expect(call.timestamp).toBeDefined(); + }); + + it('handles non-Error thrown objects', () => { + const mock = buildMockHost(); + filter.catch('a plain string error', mock as any); + + expect(mock.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(mock.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Internal server error' }), + ); + }); +}); diff --git a/src/common/interceptors/global-exception.filter.ts b/src/common/interceptors/global-exception.filter.ts index 6e1334b8..84b372e2 100644 --- a/src/common/interceptors/global-exception.filter.ts +++ b/src/common/interceptors/global-exception.filter.ts @@ -1,9 +1,18 @@ -import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; import { Request, Response } from 'express'; import { getCorrelationId } from '../utils/correlation.utils'; @Catch() export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); const response = ctx.getResponse(); @@ -20,6 +29,18 @@ export class GlobalExceptionFilter implements ExceptionFilter { ? exception.message : 'Internal server error'; + if (!isHttpException) { + this.logger.error( + `Unhandled exception on ${request.method} ${request.url}`, + exception instanceof Error ? exception.stack : String(exception), + ); + } else if (status >= 500) { + this.logger.error( + `Server error ${status} on ${request.method} ${request.url}`, + exception.stack, + ); + } + response.status(status).json({ success: false, statusCode: status, diff --git a/src/common/interceptors/idempotency.interceptor.ts b/src/common/interceptors/idempotency.interceptor.ts index dc02e159..685a823c 100644 --- a/src/common/interceptors/idempotency.interceptor.ts +++ b/src/common/interceptors/idempotency.interceptor.ts @@ -7,6 +7,7 @@ import { ConflictException, BadRequestException, } from '@nestjs/common'; +import { ResourceConflictException } from '../exceptions/app.exceptions'; import { Reflector } from '@nestjs/core'; import { Observable, of, from } from 'rxjs'; import { finalize, map, mergeMap } from 'rxjs/operators'; diff --git a/src/common/utils/user.utils.ts b/src/common/utils/user.utils.ts index 55d91e9e..976d2e92 100644 --- a/src/common/utils/user.utils.ts +++ b/src/common/utils/user.utils.ts @@ -1,9 +1,9 @@ import { - NotFoundException, - UnauthorizedException, - BadRequestException, - ConflictException, -} from '@nestjs/common'; + ResourceNotFoundException, + InvalidCredentialsException, + InvalidTokenException, + ResourceConflictException, +} from '../exceptions/app.exceptions'; import { User } from '../../users/entities/user.entity'; /** * Ensures a user exists, throwing a NotFoundException otherwise. @@ -13,7 +13,7 @@ import { User } from '../../users/entities/user.entity'; */ export function ensureUserExists(user: User | null | undefined, message = 'User not found'): User { if (!user) { - throw new NotFoundException(message); + throw new ResourceNotFoundException('User'); } return user; } @@ -28,7 +28,7 @@ export function ensureValidCredentials( message = 'Invalid credentials', ): User { if (!user) { - throw new UnauthorizedException(message); + throw new InvalidCredentialsException(message); } return user; } @@ -39,7 +39,7 @@ export function ensureValidCredentials( */ export function ensureUserIsActive(user: User, message = 'Account is not active'): void { if (user.status !== 'active') { - throw new UnauthorizedException(message); + throw new InvalidCredentialsException(message); } } /** @@ -57,11 +57,11 @@ export function ensureValidUserToken( message = 'Invalid or expired token', ): User { if (!user || !user[tokenField] || !user[expiresField]) { - throw new BadRequestException(message); + throw new InvalidTokenException(message); } const expireDate = user[expiresField] as Date; if (new Date() > expireDate) { - throw new BadRequestException(message); + throw new InvalidTokenException(message); } return user; } @@ -76,6 +76,6 @@ export function ensureUserDoesNotExist( message = 'User already exists', ): void { if (user) { - throw new ConflictException(message); + throw new ResourceConflictException('User'); } } diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index 647ef947..be81977e 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -1,9 +1,4 @@ -import { - Injectable, - NotFoundException, - ForbiddenException, - BadRequestException, -} from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; @@ -16,11 +11,16 @@ import { BulkOperationStatus, BulkOperationType, } from './entities/bulk-operation.entity'; -import { User } from '../users/entities/user.entity'; +import { User, UserRole } from '../users/entities/user.entity'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; import { SubmitForReviewDto } from './dto/submit-for-review.dto'; import { ReviewCourseDto } from './dto/review-course.dto'; +import { + ResourceNotFoundException, + ForbiddenOperationException, + BusinessValidationException, +} from '../common/exceptions/app.exceptions'; import { BulkCategoryUpdateDto, BulkPriceUpdateDto, @@ -63,7 +63,7 @@ export class CoursesService { where: { id: dto.prerequisiteCourseId }, }); if (!prerequisite) { - throw new NotFoundException(`Prerequisite course ${dto.prerequisiteCourseId} not found`); + throw new ResourceNotFoundException('Prerequisite course', dto.prerequisiteCourseId); } } @@ -86,8 +86,7 @@ export class CoursesService { */ async findAll(requestingUser?: User): Promise { const isPrivileged = - requestingUser && - requestingUser.roles.some(role => ['admin', 'moderator'].includes(role.name)); + requestingUser && [UserRole.ADMIN, UserRole.MODERATOR].includes(requestingUser.role); if (isPrivileged) { return this.courseRepo.find({ order: { createdAt: 'DESC' } }); @@ -107,7 +106,7 @@ export class CoursesService { relations: ['instructor', 'reviews', 'reviews.reviewer', 'prerequisite'], }); if (!course) { - throw new NotFoundException(`Course ${id} not found`); + throw new ResourceNotFoundException('Course', id); } return course; } @@ -127,7 +126,7 @@ export class CoursesService { where: { id: dto.prerequisiteCourseId }, }); if (!prerequisite) { - throw new NotFoundException(`Prerequisite course ${dto.prerequisiteCourseId} not found`); + throw new ResourceNotFoundException('Prerequisite course', dto.prerequisiteCourseId); } course.prerequisite = prerequisite; } @@ -158,11 +157,8 @@ export class CoursesService { const course = await this.findOne(id); this.assertCourseOwner(course, instructor); - if ( - course.status !== CourseStatus.DRAFT && - course.status !== CourseStatus.CHANGES_REQUESTED - ) { - throw new BadRequestException( + if (course.status !== CourseStatus.DRAFT && course.status !== CourseStatus.CHANGES_REQUESTED) { + throw new BusinessValidationException( `Cannot submit a course with status "${course.status}" for review. ` + `Only DRAFT or CHANGES_REQUESTED courses may be submitted.`, ); @@ -183,7 +179,7 @@ export class CoursesService { const course = await this.findOne(id); if (course.status !== CourseStatus.PENDING_REVIEW) { - throw new BadRequestException( + throw new BusinessValidationException( `Course "${id}" is not pending review (current status: "${course.status}").`, ); } @@ -231,21 +227,21 @@ export class CoursesService { private assertCourseOwner(course: Course, user: User): void { if (course.instructorId !== user.id) { - throw new ForbiddenException('Only the course owner may perform this action.'); + throw new ForbiddenOperationException('Only the course owner may perform this action.'); } } private assertPrivileged(user: User): void { - if (!user.roles.some(role => ['admin', 'moderator'].includes(role.name))) { - throw new ForbiddenException('Only admins or moderators may perform this action.'); + if (![UserRole.ADMIN, UserRole.MODERATOR].includes(user.role)) { + throw new ForbiddenOperationException('Only admins or moderators may perform this action.'); } } private assertOwnerOrPrivileged(course: Course, user: User): void { const isOwner = course.instructorId === user.id; - const isPrivileged = user.roles.some(role => ['admin', 'moderator'].includes(role.name)); + const isPrivileged = [UserRole.ADMIN, UserRole.MODERATOR].includes(user.role); if (!isOwner && !isPrivileged) { - throw new ForbiddenException('Insufficient permissions.'); + throw new ForbiddenOperationException('Insufficient permissions.'); } } @@ -338,17 +334,17 @@ export class CoursesService { async undoBulkOperation(operationId: string, user: User): Promise { const op = await this.bulkOpRepo.findOne({ where: { id: operationId } }); if (!op) { - throw new NotFoundException(`Bulk operation ${operationId} not found`); + throw new ResourceNotFoundException('Bulk operation', operationId); } const isInitiator = op.initiatedById === user.id; - const isPrivileged = user.roles.some(role => ['admin', 'moderator'].includes(role)); + const isPrivileged = [UserRole.ADMIN, UserRole.MODERATOR].includes(user.role); if (!isInitiator && !isPrivileged) { - throw new ForbiddenException('Only the initiator or an admin/moderator may undo this operation.'); + throw new ForbiddenOperationException('Only the initiator or an admin/moderator may undo this operation.'); } if (op.status === BulkOperationStatus.UNDONE) { - throw new BadRequestException('This bulk operation has already been undone.'); + throw new BusinessValidationException('This bulk operation has already been undone.'); } const appliedSnapshots = (op.snapshots ?? []).filter(s => s.applied); @@ -365,7 +361,7 @@ export class CoursesService { const restored: Course[] = []; for (const snap of appliedSnapshots) { const course = courseById.get(snap.courseId); - if (!course) continue; // course removed since the op; skip silently + if (!course) continue; if (snap.previous.status !== undefined) { course.status = snap.previous.status as CourseStatus; @@ -404,7 +400,7 @@ export class CoursesService { apply: (course: Course) => BulkCourseSnapshot['previous']; }): Promise { const { type, payload, courseIds, user, apply } = args; - const isPrivileged = user.roles.some(role => ['admin', 'moderator'].includes(role)); + const isPrivileged = [UserRole.ADMIN, UserRole.MODERATOR].includes(user.role); const courses = await this.courseRepo.find({ where: { id: In(courseIds) } }); const found = new Map(courses.map(c => [c.id, c])); diff --git a/src/courses/lessons/lessons.service.ts b/src/courses/lessons/lessons.service.ts index 417a2077..732e8684 100644 --- a/src/courses/lessons/lessons.service.ts +++ b/src/courses/lessons/lessons.service.ts @@ -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 { Lesson } from '../entities/lesson.entity'; @@ -25,7 +26,7 @@ export class LessonsService { async create(createLessonDto: CreateLessonDto): Promise { const module = await this.modulesRepository.findOneBy({ id: createLessonDto.moduleId }); if (!module) { - throw new NotFoundException(`Module with ID ${createLessonDto.moduleId} not found`); + throw new ResourceNotFoundException('CourseModule', createLessonDto.moduleId); } const lesson = this.lessonsRepository.create({ @@ -43,7 +44,7 @@ export class LessonsService { async findOne(id: string): Promise { const lesson = await this.lessonsRepository.findOneBy({ id }); if (!lesson) { - throw new NotFoundException(`Lesson with ID ${id} not found`); + throw new ResourceNotFoundException('Lesson', id); } return lesson; } diff --git a/src/courses/modules/modules.service.ts b/src/courses/modules/modules.service.ts index f1524e57..3ea56f1d 100644 --- a/src/courses/modules/modules.service.ts +++ b/src/courses/modules/modules.service.ts @@ -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 { CourseModule } from '../entities/course-module.entity'; @@ -26,7 +27,7 @@ export class ModulesService { async create(createModuleDto: CreateModuleDto): Promise { const course = await this.coursesRepository.findOneBy({ id: createModuleDto.courseId }); if (!course) { - throw new NotFoundException(`Course with ID ${createModuleDto.courseId} not found`); + throw new ResourceNotFoundException('Course', createModuleDto.courseId); } const module = this.modulesRepository.create({ @@ -52,7 +53,7 @@ export class ModulesService { }, }); if (!module) { - throw new NotFoundException(`Module with ID ${id} not found`); + throw new ResourceNotFoundException('CourseModule', id); } return module; } diff --git a/src/debugging/debug.controller.ts b/src/debugging/debug.controller.ts index fd419dce..50740061 100644 --- a/src/debugging/debug.controller.ts +++ b/src/debugging/debug.controller.ts @@ -1,14 +1,5 @@ -import { - Body, - Controller, - Delete, - Get, - NotFoundException, - Param, - Post, - Query, - UseGuards, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { ResourceNotFoundException } from '../common/exceptions/app.exceptions'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -82,7 +73,7 @@ export class DebugController { @ApiResponse({ status: 404, description: 'Captured request not found' }) inspect(@Param('id') id: string) { const record = this.capture.get(id); - if (!record) throw new NotFoundException(`No captured request "${id}"`); + if (!record) throw new ResourceNotFoundException('CapturedRequest', id); return record; } @@ -93,7 +84,7 @@ export class DebugController { @ApiResponse({ status: 404, description: 'Captured request not found' }) timeline(@Param('id') id: string) { const record = this.capture.get(id); - if (!record) throw new NotFoundException(`No captured request "${id}"`); + if (!record) throw new ResourceNotFoundException('CapturedRequest', id); return { ...record.timeline, hotspots: this.timelines.hotspots(record.timeline), @@ -107,7 +98,7 @@ export class DebugController { @ApiResponse({ status: 404, description: 'Captured request not found' }) trace(@Param('id') id: string) { const record = this.capture.get(id); - if (!record) throw new NotFoundException(`No captured request "${id}"`); + if (!record) throw new ResourceNotFoundException('CapturedRequest', id); if (!record.error) { return { message: 'Request completed without an error' }; } diff --git a/src/debugging/services/request-replay.service.ts b/src/debugging/services/request-replay.service.ts index 561fa3bf..2cd9aecf 100644 --- a/src/debugging/services/request-replay.service.ts +++ b/src/debugging/services/request-replay.service.ts @@ -1,4 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ResourceNotFoundException } from '../../common/exceptions/app.exceptions'; import { RequestCaptureService } from './request-capture.service'; import { IReplayOptions, IReplayResult } from '../interfaces/debug.interfaces'; @@ -13,8 +14,7 @@ export class RequestReplayService { /** Default target — the locally running instance. */ private readonly selfBaseUrl = - process.env.DEBUG_REPLAY_BASE_URL ?? - `http://127.0.0.1:${process.env.PORT ?? 3000}`; + process.env.DEBUG_REPLAY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 3000}`; // Headers that must never be replayed verbatim because they describe the // original transport, not the logical request. @@ -34,7 +34,7 @@ export class RequestReplayService { async replay(id: string, options: IReplayOptions = {}): Promise { const record = this.capture.get(id); if (!record) { - throw new NotFoundException(`No captured request with id "${id}"`); + throw new ResourceNotFoundException('CapturedRequest', id); } const baseUrl = options.baseUrl ?? this.selfBaseUrl; @@ -55,8 +55,7 @@ export class RequestReplayService { headers, body: hasBody ? this.serialiseBody(body, headers) : undefined, }); - const durationMs = - Math.round(Number(process.hrtime.bigint() - start) / 1e3) / 1e3; + const durationMs = Math.round(Number(process.hrtime.bigint() - start) / 1e3) / 1e3; const responseBody = await this.readBody(response); const responseHeaders: Record = {}; @@ -77,8 +76,7 @@ export class RequestReplayService { diff: { replayedStatus: response.status, originalStatus, - statusChanged: - originalStatus !== undefined && originalStatus !== response.status, + statusChanged: originalStatus !== undefined && originalStatus !== response.status, }, }; } diff --git a/src/email-marketing/automation/automation.service.ts b/src/email-marketing/automation/automation.service.ts index c9b0fdb0..fa493358 100644 --- a/src/email-marketing/automation/automation.service.ts +++ b/src/email-marketing/automation/automation.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { + ResourceNotFoundException, + BusinessValidationException, +} from '../../common/exceptions/app.exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { InjectQueue } from '@nestjs/bull'; @@ -98,7 +102,7 @@ export class AutomationService { relations: ['triggers', 'actions'], }); if (!workflow) { - throw new NotFoundException(`Automation workflow with ID ${id} not found`); + throw new ResourceNotFoundException('AutomationWorkflow', id); } return workflow; } @@ -108,7 +112,7 @@ export class AutomationService { async update(id: string, updateAutomationDto: UpdateAutomationDto): Promise { const workflow = await this.findOne(id); if (workflow.status === WorkflowStatus.ACTIVE) { - throw new BadRequestException('Deactivate workflow before making changes'); + throw new BusinessValidationException('Deactivate workflow before making changes'); } Object.assign(workflow, { name: updateAutomationDto.name ?? workflow.name, @@ -146,7 +150,7 @@ export class AutomationService { async remove(id: string): Promise { const workflow = await this.findOne(id); if (workflow.status === WorkflowStatus.ACTIVE) { - throw new BadRequestException('Deactivate workflow before deleting'); + throw new BusinessValidationException('Deactivate workflow before deleting'); } await this.workflowRepository.manager.transaction(async (manager) => { await manager.getRepository(AutomationTrigger).softDelete({ workflowId: id }); @@ -160,10 +164,10 @@ export class AutomationService { async activate(id: string): Promise { const workflow = await this.findOne(id); if (!workflow.triggers?.length) { - throw new BadRequestException('Workflow must have at least one trigger'); + throw new BusinessValidationException('Workflow must have at least one trigger'); } if (!workflow.actions?.length) { - throw new BadRequestException('Workflow must have at least one action'); + throw new BusinessValidationException('Workflow must have at least one action'); } workflow.status = WorkflowStatus.ACTIVE; workflow.activatedAt = new Date(); diff --git a/src/email-marketing/templates/template-management.service.ts b/src/email-marketing/templates/template-management.service.ts index 29c73df5..044a3e84 100644 --- a/src/email-marketing/templates/template-management.service.ts +++ b/src/email-marketing/templates/template-management.service.ts @@ -1,4 +1,5 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ResourceNotFoundException } from '../../common/exceptions/app.exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as Handlebars from 'handlebars'; @@ -57,7 +58,7 @@ export class TemplateManagementService { async findOne(id: string): Promise { const template = await this.templateRepository.findOne({ where: { id } }); if (!template) { - throw new NotFoundException(`Template with ID ${id} not found`); + throw new ResourceNotFoundException('EmailTemplate', id); } return template; } diff --git a/src/email-unsubscribe/email-unsubscribe.service.ts b/src/email-unsubscribe/email-unsubscribe.service.ts index de4fa733..69777e66 100644 --- a/src/email-unsubscribe/email-unsubscribe.service.ts +++ b/src/email-unsubscribe/email-unsubscribe.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { + ResourceNotFoundException, + BusinessValidationException, + InvalidTokenException, +} from '../common/exceptions/app.exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { randomBytes } from 'crypto'; @@ -18,7 +23,11 @@ export class EmailUnsubscribeService { private readonly subscriptionRepository: Repository, ) {} - async generateUnsubscribeToken(email: string, userId?: string, emailType?: string): Promise { + async generateUnsubscribeToken( + email: string, + userId?: string, + emailType?: string, + ): Promise { const token = randomBytes(32).toString('hex'); const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + this.TOKEN_TTL_HOURS); @@ -33,22 +42,27 @@ export class EmailUnsubscribeService { async unsubscribe(dto: UnsubscribeDto): Promise { const record = await this.tokenRepository.findOne({ where: { token: dto.token } }); - if (!record) throw new NotFoundException('Invalid unsubscribe token'); - if (record.used) throw new BadRequestException('Token already used'); - if (record.expiresAt < new Date()) throw new BadRequestException('Token has expired'); + if (!record) throw new ResourceNotFoundException('UnsubscribeToken'); + if (record.used) throw new BusinessValidationException('Token already used'); + if (record.expiresAt < new Date()) throw new InvalidTokenException('Token has expired'); await this.tokenRepository.update(record.id, { used: true }); - let subscription = await this.subscriptionRepository.findOne({ where: { email: record.email } }); + let subscription = await this.subscriptionRepository.findOne({ + where: { email: record.email }, + }); if (!subscription) { - subscription = this.subscriptionRepository.create({ email: record.email, userId: record.userId }); + subscription = this.subscriptionRepository.create({ + email: record.email, + userId: record.userId, + }); } subscription.isSubscribed = false; subscription.unsubscribedAt = new Date(); if (record.emailType && subscription.preferences) { - subscription.preferences = subscription.preferences.filter(p => p !== record.emailType); + subscription.preferences = subscription.preferences.filter((p) => p !== record.emailType); } await this.subscriptionRepository.save(subscription); @@ -86,4 +100,4 @@ export class EmailUnsubscribeService { async getSubscriptionStatus(email: string): Promise { return this.subscriptionRepository.findOne({ where: { email } }); } -} \ No newline at end of file +} diff --git a/src/middleware/tenant/tenant-access-validation.guard.ts b/src/middleware/tenant/tenant-access-validation.guard.ts index 32b37d47..28b9ece4 100644 --- a/src/middleware/tenant/tenant-access-validation.guard.ts +++ b/src/middleware/tenant/tenant-access-validation.guard.ts @@ -2,10 +2,10 @@ import { CanActivate, ExecutionContext, Injectable, - ForbiddenException, UnauthorizedException, Logger, } from '@nestjs/common'; +import { ForbiddenOperationException } from '../../common/exceptions/app.exceptions'; import { Reflector } from '@nestjs/core'; import { IsolationService } from '../../tenancy/isolation/isolation.service'; import { TENANT_KEY } from '../../tenancy/decorators/requires-tenant.decorator'; @@ -48,7 +48,7 @@ export class TenantAccessValidationGuard implements CanActivate { this.logger.warn( `Access denied: tenant ${tenant?.id} is not active (status=${tenant?.status})`, ); - throw new ForbiddenException('Tenant is not active'); + throw new ForbiddenOperationException('Tenant is not active'); } // 3. Authenticated user must belong to the active tenant @@ -59,7 +59,7 @@ export class TenantAccessValidationGuard implements CanActivate { this.logger.warn( `Access denied: user tenantId=${user.tenantId} does not match active tenant=${currentTenantId}`, ); - throw new ForbiddenException('User does not belong to this tenant'); + throw new ForbiddenOperationException('User does not belong to this tenant'); } } diff --git a/src/middleware/tenant/tenant-rls.subscriber.ts b/src/middleware/tenant/tenant-rls.subscriber.ts index 39b56ce2..15ba3657 100644 --- a/src/middleware/tenant/tenant-rls.subscriber.ts +++ b/src/middleware/tenant/tenant-rls.subscriber.ts @@ -6,7 +6,8 @@ import { UpdateEvent, RemoveEvent, } from 'typeorm'; -import { Injectable, Logger, ForbiddenException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ForbiddenOperationException } from '../../common/exceptions/app.exceptions'; import { IsolationService } from '../../tenancy/isolation/isolation.service'; /** @@ -74,7 +75,7 @@ export class TenantRlsSubscriber implements EntitySubscriberInterface { this.logger.error( `RLS violation on ${operation}: entity tenantId=${entity.tenantId}, current tenant=${currentTenantId}`, ); - throw new ForbiddenException('Cross-tenant data access is not allowed'); + throw new ForbiddenOperationException('Cross-tenant data access is not allowed'); } } diff --git a/src/middleware/throttle/throttle.middleware.ts b/src/middleware/throttle/throttle.middleware.ts index 99ea0a9e..7749c84f 100644 --- a/src/middleware/throttle/throttle.middleware.ts +++ b/src/middleware/throttle/throttle.middleware.ts @@ -1,4 +1,5 @@ -import { Injectable, NestMiddleware, HttpException, HttpStatus } from '@nestjs/common'; +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { RateLimitExceededException } from '../../common/exceptions/app.exceptions'; import { Request, Response, NextFunction } from 'express'; const hits = new Map(); @@ -18,7 +19,7 @@ export class ThrottleMiddleware implements NestMiddleware { } if (entry.count >= LIMIT) { res.setHeader('Retry-After', Math.ceil((entry.reset - now) / 1000)); - throw new HttpException('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS); + throw new RateLimitExceededException(Math.ceil((entry.reset - now) / 1000)); } entry.count++; next(); diff --git a/src/rate-limiting/guards/quota.guard.ts b/src/rate-limiting/guards/quota.guard.ts index 734486fa..81628831 100644 --- a/src/rate-limiting/guards/quota.guard.ts +++ b/src/rate-limiting/guards/quota.guard.ts @@ -1,11 +1,5 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - HttpException, - HttpStatus, - Logger, -} from '@nestjs/common'; +import { Injectable, CanActivate, ExecutionContext, Logger } from '@nestjs/common'; +import { RateLimitExceededException } from '../../common/exceptions/app.exceptions'; import { Reflector } from '@nestjs/core'; import { Request, Response } from 'express'; import { QuotaTrackingService } from '../services/quota-tracking.service'; @@ -63,20 +57,7 @@ export class QuotaGuard implements CanActivate { `Quota exceeded userId=${userId} tier=${tier} retryAfter=${result.retryAfter}s`, ); - throw new HttpException( - { - statusCode: HttpStatus.TOO_MANY_REQUESTS, - error: 'Quota Exceeded', - message: 'You have exceeded your API quota. Please wait before retrying.', - retryAfterSeconds: result.retryAfter, - quota: { - minuteRemaining: result.remaining.minute, - hourRemaining: result.remaining.hour, - dayRemaining: result.remaining.day, - }, - }, - HttpStatus.TOO_MANY_REQUESTS, - ); + throw new RateLimitExceededException(result.retryAfter); } return true; diff --git a/src/rate-limiting/services/quota-definition.service.ts b/src/rate-limiting/services/quota-definition.service.ts index 5532126e..318fbcb8 100644 --- a/src/rate-limiting/services/quota-definition.service.ts +++ b/src/rate-limiting/services/quota-definition.service.ts @@ -1,4 +1,5 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; +import { ResourceNotFoundException } from '../../common/exceptions/app.exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { QuotaDefinition } from '../entities/quota-definition.entity'; @@ -50,7 +51,7 @@ export class QuotaDefinitionService { async findOne(id: string): Promise { const def = await this.repo.findOne({ where: { id } }); - if (!def) throw new NotFoundException(`Quota definition ${id} not found`); + if (!def) throw new ResourceNotFoundException('QuotaDefinition', id); return def; } diff --git a/src/security/threats/threat-detection.service.ts b/src/security/threats/threat-detection.service.ts index 0e5605ff..5023dad6 100644 --- a/src/security/threats/threat-detection.service.ts +++ b/src/security/threats/threat-detection.service.ts @@ -1,4 +1,5 @@ -import { Injectable, ForbiddenException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { ForbiddenOperationException } from '../../common/exceptions/app.exceptions'; /** * Provides threat Detection operations. @@ -9,7 +10,7 @@ export class ThreatDetectionService { analyzeRequest(ip: string): void { const attempts = this.failedAttempts.get(ip) || 0; if (attempts > 10) { - throw new ForbiddenException('Suspicious activity detected'); + throw new ForbiddenOperationException('Suspicious activity detected'); } } recordFailure(ip: string): void { diff --git a/src/tenancy/admin/tenant-admin.service.ts b/src/tenancy/admin/tenant-admin.service.ts index 76643ef2..d5ebc971 100644 --- a/src/tenancy/admin/tenant-admin.service.ts +++ b/src/tenancy/admin/tenant-admin.service.ts @@ -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 { sanitizeSqlLike } from '../../common/utils/sanitization.utils'; @@ -41,7 +42,7 @@ export class TenantAdminService { async getTenantStatistics(tenantId: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { - throw new NotFoundException(`Tenant ${tenantId} not found`); + throw new ResourceNotFoundException('Tenant', tenantId); } return { @@ -56,7 +57,7 @@ export class TenantAdminService { async suspendTenant(tenantId: string, reason?: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { - throw new NotFoundException(`Tenant ${tenantId} not found`); + throw new ResourceNotFoundException('Tenant', tenantId); } tenant.status = TenantStatus.SUSPENDED; tenant.metadata = { @@ -70,7 +71,7 @@ export class TenantAdminService { async activateTenant(tenantId: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { - throw new NotFoundException(`Tenant ${tenantId} not found`); + throw new ResourceNotFoundException('Tenant', tenantId); } tenant.status = TenantStatus.ACTIVE; tenant.metadata = { @@ -85,7 +86,7 @@ export class TenantAdminService { async upgradePlan(tenantId: string, newPlan: TenantPlan): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { - throw new NotFoundException(`Tenant ${tenantId} not found`); + throw new ResourceNotFoundException('Tenant', tenantId); } const oldPlan = tenant.plan; tenant.plan = newPlan; @@ -109,7 +110,7 @@ export class TenantAdminService { async checkTenantHealth(tenantId: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { - throw new NotFoundException(`Tenant ${tenantId} not found`); + throw new ResourceNotFoundException('Tenant', tenantId); } const issues: string[] = []; @@ -164,7 +165,7 @@ export class TenantAdminService { async resetTenantData(tenantId: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { - throw new NotFoundException(`Tenant ${tenantId} not found`); + throw new ResourceNotFoundException('Tenant', tenantId); } tenant.currentUserCount = 0; diff --git a/src/tenancy/billing/tenant-billing.service.ts b/src/tenancy/billing/tenant-billing.service.ts index e594e972..ebf5088c 100644 --- a/src/tenancy/billing/tenant-billing.service.ts +++ b/src/tenancy/billing/tenant-billing.service.ts @@ -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 { TenantBilling, BillingCycle } from '../entities/tenant-billing.entity'; @@ -37,7 +38,7 @@ export class TenantBillingService { async getBillingInfo(tenantId: string): Promise { const billing = await this.billingRepository.findOne({ where: { tenantId } }); if (!billing) { - throw new NotFoundException(`Billing info not found for tenant ${tenantId}`); + throw new ResourceNotFoundException(`TenantBilling for tenant '${tenantId}'`); } return billing; } @@ -50,7 +51,7 @@ export class TenantBillingService { ): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { - throw new NotFoundException(`Tenant ${tenantId} not found`); + throw new ResourceNotFoundException('Tenant', tenantId); } const billing = this.billingRepository.create({ tenantId, diff --git a/src/tenancy/customization/customization.service.ts b/src/tenancy/customization/customization.service.ts index 23fb3c39..9c278066 100644 --- a/src/tenancy/customization/customization.service.ts +++ b/src/tenancy/customization/customization.service.ts @@ -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 { TenantCustomization } from '../entities/tenant-customization.entity'; @@ -19,7 +20,7 @@ export class CustomizationService { async getCustomization(tenantId: string): Promise { const customization = await this.customizationRepository.findOne({ where: { tenantId } }); if (!customization) { - throw new NotFoundException(`Customization not found for tenant ${tenantId}`); + throw new ResourceNotFoundException(`TenantCustomization for tenant '${tenantId}'`); } return customization; } diff --git a/src/tenancy/isolation/isolation.service.ts b/src/tenancy/isolation/isolation.service.ts index c9416d5c..d7a37b2a 100644 --- a/src/tenancy/isolation/isolation.service.ts +++ b/src/tenancy/isolation/isolation.service.ts @@ -1,4 +1,5 @@ -import { Injectable, Scope, NotFoundException } from '@nestjs/common'; +import { Injectable, Scope } from '@nestjs/common'; +import { ResourceNotFoundException } from '../common/exceptions/app.exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, SelectQueryBuilder } from 'typeorm'; import { Tenant, TenantStatus } from '../entities/tenant.entity'; @@ -20,7 +21,7 @@ export class IsolationService { async setTenant(tenantId: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id: tenantId } }); if (!tenant) { - throw new NotFoundException(`Tenant with ID ${tenantId} not found`); + throw new ResourceNotFoundException('Tenant', tenantId); } this.currentTenantId = tenantId; this.currentTenant = tenant; @@ -31,7 +32,7 @@ export class IsolationService { async setTenantBySlug(slug: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { slug } }); if (!tenant) { - throw new NotFoundException(`Tenant with slug ${slug} not found`); + throw new ResourceNotFoundException(`Tenant with slug '${slug}'`); } this.currentTenantId = tenant.id; this.currentTenant = tenant; @@ -42,7 +43,7 @@ export class IsolationService { async setTenantByDomain(domain: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { domain } }); if (!tenant) { - throw new NotFoundException(`Tenant with domain ${domain} not found`); + throw new ResourceNotFoundException(`Tenant with domain '${domain}'`); } this.currentTenantId = tenant.id; this.currentTenant = tenant; diff --git a/src/tenancy/tenancy.service.ts b/src/tenancy/tenancy.service.ts index 3183041b..92b2ddb8 100644 --- a/src/tenancy/tenancy.service.ts +++ b/src/tenancy/tenancy.service.ts @@ -1,9 +1,9 @@ +import { Injectable } from '@nestjs/common'; import { - Injectable, - NotFoundException, - ConflictException, - BadRequestException, -} from '@nestjs/common'; + ResourceNotFoundException, + ResourceConflictException, + BusinessValidationException, +} from '../common/exceptions/app.exceptions'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Tenant } from './entities/tenant.entity'; @@ -39,7 +39,7 @@ export class TenancyService { withDeleted: true, }); if (existingTenant) { - throw new ConflictException('Tenant with this slug already exists'); + throw new ResourceConflictException('Tenant', 'slug'); } const tenant = this.tenantRepository.create({ @@ -80,7 +80,7 @@ export class TenancyService { async findOne(id: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { id } }); if (!tenant) { - throw new NotFoundException(`Tenant with ID ${id} not found`); + throw new ResourceNotFoundException('Tenant', id); } return tenant; } @@ -88,7 +88,7 @@ export class TenancyService { async findBySlug(slug: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { slug } }); if (!tenant) { - throw new NotFoundException(`Tenant with slug ${slug} not found`); + throw new ResourceNotFoundException(`Tenant with slug '${slug}'`); } return tenant; } @@ -96,7 +96,7 @@ export class TenancyService { async findByDomain(domain: string): Promise { const tenant = await this.tenantRepository.findOne({ where: { domain } }); if (!tenant) { - throw new NotFoundException(`Tenant with domain ${domain} not found`); + throw new ResourceNotFoundException(`Tenant with domain '${domain}'`); } return tenant; } @@ -120,7 +120,7 @@ export class TenancyService { async getConfig(tenantId: string): Promise { const config = await this.configRepository.findOne({ where: { tenantId } }); if (!config) { - throw new NotFoundException(`Config not found for tenant ${tenantId}`); + throw new ResourceNotFoundException(`TenantConfig for tenant '${tenantId}'`); } return config; } @@ -229,7 +229,7 @@ export class TenancyService { if (tenant) return tenant.id; } - throw new BadRequestException('Tenant context could not be resolved from the request'); + throw new BusinessValidationException('Tenant context could not be resolved from the request'); } async validateTenantExists(tenantId: string): Promise {