README
NestJS Task Manager: Complete Step-by-Step Production Guide
This guide contains the complete, production-ready codebase, setup commands, and explanation for building a highly scalable NestJS application. It is organized into 5 parts:
- Part 1: Setup, Folder Structure, Prisma Schema, Configuration, and Infrastructure Services
- Part 2: Common Shared Layer (Guards, Filters, Interceptors, Decorators, Pipes, and Utilities)
- Part 3: Authentication and Security Module
- Part 4: Users & Tasks Modules (CRUD, Pagination, Filters)
- Part 5: Root Application Wiring, Seeding, and Running
Part 1: Setup, Configuration & Infrastructure Services
1. Installation & Bootstrap
# 1. Install NestJS CLI globally
npm i -g @nestjs/cli
# 2. Create project
nest new todo-app
cd todo-app
# 3. Core dependencies
npm install \
@nestjs/config \
@nestjs/jwt \
@nestjs/passport \
@nestjs/throttler \
@nestjs/cache-manager \
@nestjs/swagger \
passport \
passport-jwt \
passport-local \
@prisma/client \
prisma \
redis \
ioredis \
cache-manager-ioredis-yet \
bcryptjs \
nodemailer \
class-validator \
class-transformer \
http-status-codes \
uuid \
dayjs
# 4. Dev dependencies
npm install -D \
@types/passport-jwt \
@types/passport-local \
@types/bcryptjs \
@types/nodemailer \
@types/uuid \
prisma
# 5. Init Prisma
npx prisma init
2. Folder Structure
src/
├── common/
│ ├── decorators/
│ │ ├── current-user.decorator.ts
│ │ ├── roles.decorator.ts
│ │ └── public.decorator.ts
│ ├── dto/
│ │ └── pagination.dto.ts
│ ├── enums/
│ │ └── roles.enum.ts
│ ├── filters/
│ │ └── global-exception.filter.ts
│ ├── guards/
│ │ ├── jwt-auth.guard.ts
│ │ ├── roles.guard.ts
│ │ └── permissions.guard.ts
│ ├── interceptors/
│ │ └── response.interceptor.ts
│ ├── pipes/
│ │ └── validation.pipe.ts
│ └── utils/
│ ├── pagination.util.ts
│ └── date-range.util.ts
├── config/
│ ├── app.config.ts
│ ├── jwt.config.ts
│ ├── redis.config.ts
│ └── mail.config.ts
├── modules/
│ ├── auth/
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── auth.module.ts
│ │ ├── strategies/
│ │ │ ├── jwt.strategy.ts
│ │ │ └── local.strategy.ts
│ │ └── dto/
│ │ ├── register.dto.ts
│ │ ├── login.dto.ts
│ │ ├── verify-otp.dto.ts
│ │ └── refresh-token.dto.ts
│ ├── users/
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ ├── users.module.ts
│ │ └── dto/
│ │ ├── update-user.dto.ts
│ │ └── query-user.dto.ts
│ └── tasks/
│ ├── tasks.controller.ts
│ ├── tasks.service.ts
│ ├── tasks.module.ts
│ └── dto/
│ ├── create-task.dto.ts
│ ├── update-task.dto.ts
│ └── query-task.dto.ts
├── prisma/
│ ├── prisma.module.ts
│ └── prisma.service.ts
├── mail/
│ ├── mail.module.ts
│ ├── mail.service.ts
│ └── templates/
│ └── otp.template.ts
├── redis/
│ ├── redis.module.ts
│ └── redis.service.ts
├── seed/
│ └── seed.ts
├── app.module.ts
└── main.ts
3. Prisma Schema (prisma/schema.prisma)
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
ADMIN
USER
}
enum UserStatus {
ACTIVE
SUSPENDED
DELETED
}
enum TaskStatus {
PENDING
IN_PROGRESS
COMPLETED
}
enum TaskPriority {
LOW
MEDIUM
HIGH
}
model User {
id String @id @default(uuid())
email String @unique
password String
name String
role Role @default(USER)
status UserStatus @default(ACTIVE)
isVerified Boolean @default(false)
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Task {
id String @id @default(uuid())
title String
description String?
status TaskStatus @default(PENDING)
priority TaskPriority @default(MEDIUM)
dueDate DateTime?
completedAt DateTime?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
@@index([priority])
@@index([dueDate])
@@map("tasks")
}
npx prisma migrate dev --name init
npx prisma generate
4. Environment Variables (.env)
# App
NODE_ENV=development
PORT=3000
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/todo_db?schema=public"
# JWT
JWT_SECRET=your_jwt_secret_here_min_32_chars
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=your_refresh_secret_here_min_32_chars
JWT_REFRESH_EXPIRES_IN=7d
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
# Mail (SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
SMTP_USER=your@gmail.com
SMTP_PASS=your_app_password
SMTP_FROM="Todo App <your@gmail.com>"
# Admin Seed
ADMIN_EMAIL=admin@todo.com
ADMIN_PASSWORD=Admin@123456
# OTP
OTP_EXPIRES_MINUTES=5
5. Config Files
src/config/app.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
adminEmail: process.env.ADMIN_EMAIL,
adminPassword: process.env.ADMIN_PASSWORD,
}));
src/config/jwt.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => ({
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN || '15m',
refreshSecret: process.env.JWT_REFRESH_SECRET,
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
}));
src/config/redis.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('redis', () => ({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
}));
src/config/mail.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('mail', () => ({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '465', 10),
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
from: process.env.SMTP_FROM,
otpExpireMinutes: parseInt(process.env.OTP_EXPIRES_MINUTES || '5', 10),
}));
6. Prisma Service (src/prisma/prisma.service.ts)
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'error' },
{ emit: 'stdout', level: 'warn' },
],
});
}
async onModuleInit() {
await this.$connect();
this.logger.log('Database connected successfully');
}
async onModuleDestroy() {
await this.$disconnect();
this.logger.log('Database disconnected');
}
}
src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global() // Makes PrismaService available everywhere without re-importing
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
7. Redis Service (src/redis/redis.service.ts)
import { Injectable, OnModuleDestroy, Logger, Inject } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
@Injectable()
export class RedisService implements OnModuleDestroy {
private readonly logger = new Logger(RedisService.name);
private readonly client: Redis;
constructor(private readonly configService: ConfigService) {
this.client = new Redis({
host: this.configService.get<string>('redis.host'),
port: this.configService.get<number>('redis.port'),
password: this.configService.get<string>('redis.password') || undefined,
});
this.client.on('connect', () => this.logger.log('Redis connected'));
this.client.on('error', (err) => this.logger.error('Redis error', err));
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
if (ttlSeconds) {
await this.client.setex(key, ttlSeconds, value);
} else {
await this.client.set(key, value);
}
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async del(key: string): Promise<void> {
await this.client.del(key);
}
async exists(key: string): Promise<boolean> {
const result = await this.client.exists(key);
return result === 1;
}
onModuleDestroy() {
this.client.quit();
}
}
src/redis/redis.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}
8. Mail Service (src/mail/mail.service.ts)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import { Transporter } from 'nodemailer';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private transporter: Transporter;
constructor(private readonly configService: ConfigService) {
this.transporter = nodemailer.createTransport({
host: this.configService.get<string>('mail.host'),
port: this.configService.get<number>('mail.port'),
secure: true,
auth: {
user: this.configService.get<string>('mail.user'),
pass: this.configService.get<string>('mail.pass'),
},
});
}
async sendOtp(email: string, otp: string, name: string): Promise<void> {
const from = this.configService.get<string>('mail.from');
const expireMin = this.configService.get<number>('mail.otpExpireMinutes');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 480px; margin: auto;
border: 1px solid #e2e8f0; border-radius: 8px; padding: 32px;">
<h2 style="color: #1a202c;">Hello, ${name}!</h2>
<p style="color: #4a5568;">Your One-Time Password (OTP) for login is:</p>
<div style="background: #edf2f7; border-radius: 6px; padding: 20px; text-align: center; margin: 24px 0;">
<span style="font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #2d3748;">
${otp}
</span>
</div>
<p style="color: #718096; font-size: 14px;">
This OTP is valid for <strong>${expireMin} minutes</strong>. Do not share it with anyone.
</p>
<p style="color: #a0aec0; font-size: 12px; margin-top: 24px;">
If you didn't request this, please ignore this email.
</p>
</div>
`;
try {
await this.transporter.sendMail({
from,
to: email,
subject: 'Your Login OTP — Todo App',
html,
});
this.logger.log(`OTP email sent to ${email}`);
} catch (error) {
this.logger.error(`Failed to send OTP to ${email}`, error);
throw new Error('Failed to send OTP email');
}
}
}
src/mail/mail.module.ts
import { Global, Module } from '@nestjs/common';
import { MailService } from './mail.service';
@Global()
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
9. Seeder (src/seed/seed.ts)
import { PrismaClient, Role } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.join(process.cwd(), '.env') });
const prisma = new PrismaClient();
async function seedAdmin() {
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) {
throw new Error('ADMIN_EMAIL and ADMIN_PASSWORD must be set in .env');
}
const existing = await prisma.user.findFirst({ where: { role: Role.ADMIN } });
if (existing) {
console.log('✅ Super Admin already exists, skipping seed.');
return;
}
const hashedPassword = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: {
email,
password: hashedPassword,
name: 'Super Admin',
role: Role.ADMIN,
isVerified: true,
status: 'ACTIVE',
},
});
console.log('🌱 Super Admin seeded:', admin.email);
}
seedAdmin()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
Add to package.json:
{
"scripts": {
"seed": "ts-node src/seed/seed.ts"
}
}
Part 2: Common Shared Layer
1. Enums (src/common/enums/roles.enum.ts)
export enum Role {
ADMIN = 'ADMIN',
USER = 'USER',
}
export enum Permission {
// User permissions
READ_OWN_TASKS = 'READ_OWN_TASKS',
CREATE_TASK = 'CREATE_TASK',
UPDATE_OWN_TASK = 'UPDATE_OWN_TASK',
DELETE_OWN_TASK = 'DELETE_OWN_TASK',
READ_OWN_PROFILE = 'READ_OWN_PROFILE',
UPDATE_OWN_PROFILE = 'UPDATE_OWN_PROFILE',
// Admin permissions
READ_ALL_TASKS = 'READ_ALL_TASKS',
READ_ALL_USERS = 'READ_ALL_USERS',
UPDATE_ANY_USER = 'UPDATE_ANY_USER',
DELETE_ANY_USER = 'DELETE_ANY_USER',
SUSPEND_USER = 'SUSPEND_USER',
}
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
[Role.USER]: [
Permission.READ_OWN_TASKS,
Permission.CREATE_TASK,
Permission.UPDATE_OWN_TASK,
Permission.DELETE_OWN_TASK,
Permission.READ_OWN_PROFILE,
Permission.UPDATE_OWN_PROFILE,
],
[Role.ADMIN]: Object.values(Permission), // Admin has all permissions
};
2. Decorators
src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user: JwtPayload = request.user;
return data ? user?.[data] : user;
},
);
src/common/decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/roles.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
src/common/decorators/permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Permission } from '../enums/roles.enum';
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
src/common/decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
src/common/decorators/api-paginated-response.decorator.ts
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import { PaginatedResponseDto } from '../dto/paginated-response.dto';
export const ApiPaginatedResponse = <TModel extends Type<any>>(model: TModel) =>
applyDecorators(
ApiExtraModels(PaginatedResponseDto, model),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(PaginatedResponseDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(model) },
},
},
},
],
},
}),
);
3. Interfaces (src/common/interfaces/)
jwt-payload.interface.ts
import { Role } from '../enums/roles.enum';
export interface JwtPayload {
sub: string; // userId
email: string;
role: Role;
iat?: number;
exp?: number;
}
paginated-result.interface.ts
export interface PaginatedResult<T> {
data: T[];
meta: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
4. DTOs
src/common/dto/pagination.dto.ts
import { IsOptional, IsPositive, IsString, IsIn, IsInt, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class PaginationDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 10, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@IsPositive()
limit?: number = 10;
@ApiPropertyOptional()
@IsOptional()
@IsString()
sortBy?: string = 'createdAt';
@ApiPropertyOptional({ enum: ['asc', 'desc'], default: 'desc' })
@IsOptional()
@IsIn(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
@ApiPropertyOptional({ description: 'Search term' })
@IsOptional()
@IsString()
search?: string;
}
src/common/dto/date-range.dto.ts
import { IsOptional, IsDateString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class DateRangeDto {
@ApiPropertyOptional({ example: '2024-01-01' })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ example: '2024-12-31' })
@IsOptional()
@IsDateString()
endDate?: string;
}
src/common/dto/paginated-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export class PaginationMeta {
@ApiProperty() page: number;
@ApiProperty() limit: number;
@ApiProperty() total: number;
@ApiProperty() totalPages: number;
@ApiProperty() hasNextPage: boolean;
@ApiProperty() hasPreviousPage: boolean;
}
export class PaginatedResponseDto<T> {
@ApiProperty() success: boolean;
@ApiProperty() statusCode: number;
@ApiProperty() message: string;
data: T[];
@ApiProperty({ type: PaginationMeta }) meta: PaginationMeta;
}
5. Filters — Global Exception Filter
src/common/filters/global-exception.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Prisma } from '@prisma/client';
interface ErrorResponse {
success: false;
statusCode: number;
message: string;
error: {
code?: string;
details?: unknown;
path?: string;
timestamp: string;
};
}
@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<Response>();
const request = ctx.getRequest<Request>();
const { statusCode, message, code, details } = this.resolveException(exception);
const errorResponse: ErrorResponse = {
success: false,
statusCode,
message,
error: {
code,
details,
path: request.url,
timestamp: new Date().toISOString(),
},
};
this.logger.error(
`[${request.method}] ${request.url} → ${statusCode}: ${message}`,
exception instanceof Error ? exception.stack : undefined,
);
response.status(statusCode).json(errorResponse);
}
private resolveException(exception: unknown): {
statusCode: number;
message: string;
code?: string;
details?: unknown;
} {
// NestJS HTTP Exception
if (exception instanceof HttpException) {
const status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
const res = exceptionResponse as Record<string, unknown>;
return {
statusCode: status,
message: (Array.isArray(res.message)
? res.message.join(', ')
: res.message as string) || exception.message,
details: Array.isArray(res.message) ? res.message : undefined,
};
}
return { statusCode: status, message: exception.message };
}
// Prisma Known Errors
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
return this.handlePrismaError(exception);
}
// Prisma Validation Error
if (exception instanceof Prisma.PrismaClientValidationError) {
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Database validation error',
code: 'PRISMA_VALIDATION',
details: exception.message,
};
}
// Prisma Init Error
if (exception instanceof Prisma.PrismaClientInitializationError) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Database connection failed',
code: 'DB_CONNECTION_ERROR',
};
}
// JWT errors
if (exception instanceof Error) {
if (exception.name === 'JsonWebTokenError') {
return { statusCode: HttpStatus.UNAUTHORIZED, message: 'Invalid token', code: 'INVALID_TOKEN' };
}
if (exception.name === 'TokenExpiredError') {
return { statusCode: HttpStatus.UNAUTHORIZED, message: 'Token expired', code: 'TOKEN_EXPIRED' };
}
}
// Fallback
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
code: 'INTERNAL_ERROR',
};
}
private handlePrismaError(err: Prisma.PrismaClientKnownRequestError): {
statusCode: number;
message: string;
code: string;
details?: unknown;
} {
switch (err.code) {
case 'P2002':
return {
statusCode: HttpStatus.CONFLICT,
message: `Duplicate value for field: ${(err.meta?.target as string[])?.join(', ')}`,
code: 'DUPLICATE_ENTRY',
details: err.meta,
};
case 'P2025':
return {
statusCode: HttpStatus.NOT_FOUND,
message: 'Record not found',
code: 'NOT_FOUND',
};
case 'P2003':
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Related record not found (foreign key constraint)',
code: 'FOREIGN_KEY_VIOLATION',
};
case 'P2014':
return {
statusCode: HttpStatus.BAD_REQUEST,
message: 'Relation violation',
code: 'RELATION_VIOLATION',
};
default:
return {
statusCode: HttpStatus.BAD_REQUEST,
message: `Database error: ${err.code}`,
code: err.code,
details: err.meta,
};
}
}
}
6. Interceptors — Centralized Response
src/common/interceptors/response.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface SuccessResponse<T> {
success: true;
statusCode: number;
message: string;
data: T | null;
meta?: unknown;
timestamp: string;
}
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, SuccessResponse<T>> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<SuccessResponse<T>> {
const httpContext = context.switchToHttp();
const response = httpContext.getResponse();
const statusCode: number = response.statusCode;
return next.handle().pipe(
map((result) => {
// Controllers can return { message, data, meta } objects
const isStructured =
result && typeof result === 'object' && ('data' in result || 'message' in result);
return {
success: true,
statusCode,
message: isStructured ? result.message || 'Success' : 'Success',
data: isStructured ? (result.data ?? null) : result ?? null,
meta: isStructured ? result.meta : undefined,
timestamp: new Date().toISOString(),
};
}),
);
}
}
7. Guards
src/common/guards/jwt-auth.guard.ts
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
const message =
info?.name === 'TokenExpiredError'
? 'Access token has expired'
: info?.message || 'Unauthorized';
throw err || new UnauthorizedException(message);
}
return user;
}
}
src/common/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { Role } from '../enums/roles.enum';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) return true;
const { user } = context.switchToHttp().getRequest();
if (!user) throw new ForbiddenException('No user found in request');
const hasRole = requiredRoles.some((role) => user.role === role);
if (!hasRole) {
throw new ForbiddenException(
`Access denied. Required roles: ${requiredRoles.join(', ')}`,
);
}
return true;
}
}
src/common/guards/permissions.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import { Permission, ROLE_PERMISSIONS, Role } from '../enums/roles.enum';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredPermissions = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredPermissions || requiredPermissions.length === 0) return true;
const { user } = context.switchToHttp().getRequest();
if (!user) throw new ForbiddenException('No user found in request');
const userPermissions = ROLE_PERMISSIONS[user.role as Role] || [];
const hasAllPermissions = requiredPermissions.every((perm) =>
userPermissions.includes(perm),
);
if (!hasAllPermissions) {
throw new ForbiddenException('You do not have permission to perform this action');
}
return true;
}
}
8. Utils
src/common/utils/pagination.util.ts
import { PaginationDto } from '../dto/pagination.dto';
import { PaginatedResult } from '../interfaces/paginated-result.interface';
export interface PaginationParams {
page: number;
limit: number;
skip: number;
sortBy: string;
sortOrder: 'asc' | 'desc';
}
export function buildPaginationParams(dto: PaginationDto): PaginationParams {
const page = Math.max(1, dto.page || 1);
const limit = Math.min(100, Math.max(1, dto.limit || 10));
const skip = (page - 1) * limit;
const sortBy = dto.sortBy || 'createdAt';
const sortOrder = (dto.sortOrder || 'desc') as 'asc' | 'desc';
return { page, limit, skip, sortBy, sortOrder };
}
export function buildPaginatedResult<T>(
data: T[],
total: number,
params: PaginationParams,
): PaginatedResult<T> {
const totalPages = Math.ceil(total / params.limit);
return {
data,
meta: {
page: params.page,
limit: params.limit,
total,
totalPages,
hasNextPage: params.page < totalPages,
hasPreviousPage: params.page > 1,
},
};
}
src/common/utils/date-range.util.ts
import { DateRangeDto } from '../dto/date-range.dto';
export function buildDateRangeFilter(
field: string,
dateRange?: DateRangeDto,
): Record<string, unknown> {
if (!dateRange?.startDate && !dateRange?.endDate) return {};
const filter: Record<string, Date> = {};
if (dateRange.startDate) {
filter.gte = new Date(dateRange.startDate);
}
if (dateRange.endDate) {
// Include full end day
const end = new Date(dateRange.endDate);
end.setHours(23, 59, 59, 999);
filter.lte = end;
}
return { [field]: filter };
}
src/common/utils/hash.util.ts
import * as bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, SALT_ROUNDS);
}
export async function comparePassword(plain: string, hashed: string): Promise<boolean> {
return bcrypt.compare(plain, hashed);
}
export function generateOtp(length = 6): string {
const digits = '0123456789';
let otp = '';
for (let i = 0; i < length; i++) {
otp += digits[Math.floor(Math.random() * 10)];
}
return otp;
}
9. Validation Pipe (src/common/pipes/validation.pipe.ts)
import {
PipeTransform,
Injectable,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class CustomValidationPipe implements PipeTransform<any> {
async transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || !this.toValidate(metatype)) {
return value;
}
const object = plainToInstance(metatype, value);
const errors = await validate(object, {
whitelist: true, // strip unknown fields
forbidNonWhitelisted: true, // throw on unknown fields
skipMissingProperties: false,
});
if (errors.length > 0) {
const messages = errors.flatMap((err) =>
Object.values(err.constraints || {}),
);
throw new BadRequestException({
message: messages,
error: 'Validation Failed',
});
}
return object;
}
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}
10. Throttler (Windowed Rate Limiting) Setup
NestJS @nestjs/throttler implements a sliding window / fixed window rate limiter.
Custom throttler guard (src/common/guards/throttler.guard.ts)
import { ThrottlerGuard, ThrottlerException } from '@nestjs/throttler';
import { Injectable, ExecutionContext } from '@nestjs/common';
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
protected async throwThrottlingException(
context: ExecutionContext,
throttlerLimitDetail: any,
): Promise<void> {
throw new ThrottlerException(
`Rate limit exceeded. Try again in ${Math.ceil(
throttlerLimitDetail.timeToExpire / 1000,
)} seconds.`,
);
}
// Use IP + user ID as key when authenticated
protected async getTracker(req: Record<string, any>): Promise<string> {
const userId = req.user?.sub;
const ip = req.ip || req.headers['x-forwarded-for'] || 'unknown';
return userId ? `${userId}:${ip}` : ip;
}
}
Part 3: Authentication & Security Module
1. Auth DTOs
src/modules/auth/dto/register.dto.ts
import {
IsEmail,
IsString,
MinLength,
MaxLength,
Matches,
IsNotEmpty,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export class RegisterDto {
@ApiProperty({ example: 'John Doe' })
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(60)
name: string;
@ApiProperty({ example: 'john@example.com' })
@IsEmail({}, { message: 'Invalid email address' })
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
@ApiProperty({
example: 'StrongPass@123',
description:
'Min 8 chars, must have uppercase, lowercase, number, and special character',
})
@IsString()
@MinLength(8)
@MaxLength(128)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$/, {
message:
'Password must contain uppercase, lowercase, number, and special character',
})
password: string;
}
src/modules/auth/dto/login.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export class LoginDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
@ApiProperty({ example: 'StrongPass@123' })
@IsString()
@MinLength(6)
password: string;
}
src/modules/auth/dto/verify-otp.dto.ts
import { IsEmail, IsString, Length } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export class VerifyOtpDto {
@ApiProperty({ example: 'john@example.com' })
@IsEmail()
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
@ApiProperty({ example: '123456' })
@IsString()
@Length(6, 6, { message: 'OTP must be exactly 6 digits' })
otp: string;
}
src/modules/auth/dto/refresh-token.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
refreshToken: string;
}
2. JWT Strategy (src/modules/auth/strategies/jwt.strategy.ts)
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../../prisma/prisma.service';
import { JwtPayload } from '../../../common/interfaces/jwt-payload.interface';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('jwt.secret'),
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
// Validate user still exists and is active
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, role: true, status: true },
});
if (!user) throw new UnauthorizedException('User no longer exists');
if (user.status !== 'ACTIVE') {
throw new UnauthorizedException(`Account is ${user.status.toLowerCase()}`);
}
return { sub: user.id, email: user.email, role: user.role as any };
}
}
3. Auth Service (src/modules/auth/auth.service.ts)
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service';
import { MailService } from '../../mail/mail.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { comparePassword, generateOtp, hashPassword } from '../../common/utils/hash.util';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { Role } from '../../common/enums/roles.enum';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
// Redis key namespaces
private readonly OTP_PREFIX = 'otp:';
private readonly OTP_ATTEMPTS_PREFIX = 'otp_attempts:';
private readonly REFRESH_PREFIX = 'refresh:';
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
private readonly mail: MailService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
// ─── REGISTER ────────────────────────────────────────────────────────────────
async register(dto: RegisterDto) {
const exists = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (exists) {
throw new ConflictException('An account with this email already exists');
}
const hashedPassword = await hashPassword(dto.password);
const user = await this.prisma.user.create({
data: {
email: dto.email,
name: dto.name,
password: hashedPassword,
role: Role.USER,
isVerified: false,
},
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
// Send OTP for email verification on first login
this.logger.log(`New user registered: ${user.email}`);
return {
message: 'Registration successful. Please login with your credentials.',
data: user,
};
}
// ─── LOGIN (step 1: validate credentials → send OTP) ─────────────────────────
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (!user) {
throw new UnauthorizedException('Invalid email or password');
}
if (user.status === 'DELETED') {
throw new UnauthorizedException('This account has been deleted');
}
if (user.status === 'SUSPENDED') {
throw new UnauthorizedException('Your account is suspended. Contact support.');
}
const isPasswordValid = await comparePassword(dto.password, user.password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid email or password');
}
// Generate and store OTP in Redis
const otp = generateOtp(6);
const otpKey = `${this.OTP_PREFIX}${user.email}`;
const attemptsKey = `${this.OTP_ATTEMPTS_PREFIX}${user.email}`;
const expireMinutes = this.config.get<number>('mail.otpExpireMinutes', 5);
await this.redis.set(otpKey, otp, expireMinutes * 60);
await this.redis.set(attemptsKey, '0', expireMinutes * 60);
// Send OTP via email
await this.mail.sendOtp(user.email, otp, user.name);
return {
message: `OTP sent to ${user.email}. Valid for ${expireMinutes} minutes.`,
data: { email: user.email },
};
}
// ─── VERIFY OTP (step 2: validate OTP → issue JWT) ──────────────────────────
async verifyOtp(dto: VerifyOtpDto) {
const otpKey = `${this.OTP_PREFIX}${dto.email}`;
const attemptsKey = `${this.OTP_ATTEMPTS_PREFIX}${dto.email}`;
const storedOtp = await this.redis.get(otpKey);
if (!storedOtp) {
throw new BadRequestException('OTP has expired or was never sent. Please login again.');
}
// Max 5 wrong attempts
const attemptsStr = await this.redis.get(attemptsKey);
const attempts = parseInt(attemptsStr || '0', 10);
if (attempts >= 5) {
await this.redis.del(otpKey);
await this.redis.del(attemptsKey);
throw new BadRequestException('Too many failed OTP attempts. Please login again.');
}
if (storedOtp !== dto.otp) {
await this.redis.set(attemptsKey, String(attempts + 1), 300);
throw new BadRequestException(
`Invalid OTP. ${4 - attempts} attempt(s) remaining.`,
);
}
// OTP valid → clean up Redis
await this.redis.del(otpKey);
await this.redis.del(attemptsKey);
const user = await this.prisma.user.findUnique({
where: { email: dto.email },
select: { id: true, email: true, name: true, role: true },
});
if (!user) throw new NotFoundException('User not found');
// Mark as verified if first login
await this.prisma.user.update({
where: { id: user.id },
data: { isVerified: true },
});
const tokens = await this.generateTokens(user.id, user.email, user.role as Role);
return {
message: 'Login successful',
data: {
user: { id: user.id, email: user.email, name: user.name, role: user.role },
...tokens,
},
};
}
// ─── REFRESH TOKEN ────────────────────────────────────────────────────────────
async refreshTokens(refreshToken: string) {
let payload: JwtPayload;
try {
payload = this.jwtService.verify(refreshToken, {
secret: this.config.get<string>('jwt.refreshSecret'),
});
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
// Check if refresh token is blacklisted / rotated
const storedToken = await this.redis.get(`${this.REFRESH_PREFIX}${payload.sub}`);
if (storedToken && storedToken !== refreshToken) {
// Token reuse detected → invalidate all tokens (security measure)
await this.redis.del(`${this.REFRESH_PREFIX}${payload.sub}`);
throw new UnauthorizedException('Refresh token reuse detected. Please login again.');
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, role: true, status: true },
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException('User not found or inactive');
}
const tokens = await this.generateTokens(user.id, user.email, user.role as Role);
return {
message: 'Tokens refreshed successfully',
data: tokens,
};
}
// ─── LOGOUT ───────────────────────────────────────────────────────────────────
async logout(userId: string) {
await this.redis.del(`${this.REFRESH_PREFIX}${userId}`);
return { message: 'Logged out successfully', data: null };
}
// ─── PRIVATE HELPERS ──────────────────────────────────────────────────────────
private async generateTokens(userId: string, email: string, role: Role) {
const payload: JwtPayload = { sub: userId, email, role };
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: this.config.get<string>('jwt.secret'),
expiresIn: this.config.get<string>('jwt.expiresIn'),
}),
this.jwtService.signAsync(payload, {
secret: this.config.get<string>('jwt.refreshSecret'),
expiresIn: this.config.get<string>('jwt.refreshExpiresIn'),
}),
]);
// Store refresh token in Redis for rotation validation
const expireSeconds = 7 * 24 * 60 * 60; // 7 days
await this.redis.set(`${this.REFRESH_PREFIX}${userId}`, refreshToken, expireSeconds);
return { accessToken, refreshToken };
}
}
4. Auth Controller (src/modules/auth/auth.controller.ts)
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { Public } from '../../common/decorators/public.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
@ApiTags('Auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// POST /api/v1/auth/register
@Public()
@Post('register')
@Throttle({ default: { limit: 5, ttl: 60000 } }) // 5 per minute
@ApiOperation({ summary: 'Register a new user account' })
@ApiResponse({ status: 201, description: 'User registered successfully' })
@ApiResponse({ status: 409, description: 'Email already exists' })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
// POST /api/v1/auth/login
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 60000 } }) // 10 per minute (windowed)
@ApiOperation({ summary: 'Login with credentials (sends OTP)' })
async login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
// POST /api/v1/auth/verify-otp
@Public()
@Post('verify-otp')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({ summary: 'Verify OTP and receive JWT tokens' })
async verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto);
}
// POST /api/v1/auth/refresh
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token using refresh token' })
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refreshTokens(dto.refreshToken);
}
// POST /api/v1/auth/logout
@UseGuards(JwtAuthGuard)
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@ApiOperation({ summary: 'Logout and invalidate refresh token' })
async logout(@CurrentUser() user: JwtPayload) {
return this.authService.logout(user.sub);
}
}
5. Auth Module (src/modules/auth/auth.module.ts)
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({}), // Configured dynamically in service via JwtService.signAsync options
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
Part 4: Users & Tasks Modules
══════════════════ USERS MODULE ══════════════════
1. User DTOs
src/modules/users/dto/query-user.dto.ts
import { IsOptional, IsEnum, IsString } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { UserStatus } from '@prisma/client';
import { PaginationDto } from '../../../common/dto/pagination.dto';
import { DateRangeDto } from '../../../common/dto/date-range.dto';
import { IntersectionType } from '@nestjs/mapped-types';
export class QueryUserDto extends IntersectionType(PaginationDto, DateRangeDto) {
@ApiPropertyOptional({ enum: UserStatus })
@IsOptional()
@IsEnum(UserStatus)
status?: UserStatus;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string; // Inherited from PaginationDto; documented again for clarity
}
src/modules/users/dto/update-user.dto.ts
import {
IsString,
IsOptional,
IsEnum,
MinLength,
MaxLength,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { UserStatus } from '@prisma/client';
export class UpdateUserDto {
@ApiPropertyOptional({ example: 'Jane Doe' })
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(60)
name?: string;
}
// Admin-only update DTO
export class AdminUpdateUserDto extends UpdateUserDto {
@ApiPropertyOptional({ enum: UserStatus })
@IsOptional()
@IsEnum(UserStatus)
status?: UserStatus;
}
2. Users Service (src/modules/users/users.service.ts)
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { QueryUserDto } from './dto/query-user.dto';
import { AdminUpdateUserDto, UpdateUserDto } from './dto/update-user.dto';
import { buildPaginationParams, buildPaginatedResult } from '../../common/utils/pagination.util';
import { buildDateRangeFilter } from '../../common/utils/date-range.util';
import { Prisma } from '@prisma/client';
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
// ─── ADMIN: GET ALL USERS ──────────────────────────────────────────────────
async findAll(query: QueryUserDto) {
const params = buildPaginationParams(query);
const dateFilter = buildDateRangeFilter('createdAt', query);
// Build where clause
const where: Prisma.UserWhereInput = {
role: 'USER', // Admins only see user accounts (not other admins)
...(query.status && { status: query.status }),
...(query.search && {
OR: [
{ name: { contains: query.search, mode: 'insensitive' } },
{ email: { contains: query.search, mode: 'insensitive' } },
],
}),
...dateFilter,
};
// Validate sortBy field against allowed columns
const allowedSortFields = ['name', 'email', 'createdAt', 'status'];
const sortBy = allowedSortFields.includes(params.sortBy) ? params.sortBy : 'createdAt';
const [users, total] = await this.prisma.$transaction([
this.prisma.user.findMany({
where,
select: {
id: true,
name: true,
email: true,
role: true,
status: true,
isVerified: true,
createdAt: true,
updatedAt: true,
_count: { select: { tasks: true } },
},
skip: params.skip,
take: params.limit,
orderBy: { [sortBy]: params.sortOrder },
}),
this.prisma.user.count({ where }),
]);
return buildPaginatedResult(users, total, params);
}
// ─── GET ONE USER ──────────────────────────────────────────────────────────
async findOne(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
role: true,
status: true,
isVerified: true,
createdAt: true,
updatedAt: true,
_count: { select: { tasks: true } },
},
});
if (!user) throw new NotFoundException(`User with ID "${id}" not found`);
return user;
}
// ─── USER: UPDATE OWN PROFILE ──────────────────────────────────────────────
async updateProfile(userId: string, dto: UpdateUserDto) {
await this.findOne(userId); // Ensure exists
const updated = await this.prisma.user.update({
where: { id: userId },
data: { name: dto.name },
select: {
id: true, name: true, email: true, role: true,
status: true, updatedAt: true,
},
});
return { message: 'Profile updated successfully', data: updated };
}
// ─── ADMIN: UPDATE ANY USER ────────────────────────────────────────────────
async adminUpdateUser(id: string, dto: AdminUpdateUserDto) {
await this.findOne(id); // Ensure exists
const updated = await this.prisma.user.update({
where: { id },
data: {
...(dto.name && { name: dto.name }),
...(dto.status && { status: dto.status }),
},
select: {
id: true, name: true, email: true, role: true,
status: true, updatedAt: true,
},
});
return { message: 'User updated successfully', data: updated };
}
// ─── ADMIN: SOFT DELETE (MARK AS DELETED) ─────────────────────────────────
async deleteUser(id: string) {
const user = await this.findOne(id);
if (user.role === 'ADMIN') {
throw new ForbiddenException('Cannot delete admin accounts');
}
await this.prisma.user.update({
where: { id },
data: { status: 'DELETED' },
});
return { message: 'User deleted successfully', data: null };
}
// ─── ADMIN: SUSPEND / ACTIVATE ────────────────────────────────────────────
async suspendUser(id: string) {
await this.findOne(id);
await this.prisma.user.update({ where: { id }, data: { status: 'SUSPENDED' } });
return { message: 'User suspended', data: null };
}
async activateUser(id: string) {
await this.findOne(id);
await this.prisma.user.update({ where: { id }, data: { status: 'ACTIVE' } });
return { message: 'User activated', data: null };
}
}
3. Users Controller (src/modules/users/users.controller.ts)
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import { QueryUserDto } from './dto/query-user.dto';
import { AdminUpdateUserDto, UpdateUserDto } from './dto/update-user.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { RequirePermissions } from '../../common/decorators/permissions.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Role, Permission } from '../../common/enums/roles.enum';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
@ApiTags('Users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard)
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// ─── USER: Own Profile ─────────────────────────────────────────────────────
// GET /api/v1/users/me
@Get('me')
@RequirePermissions(Permission.READ_OWN_PROFILE)
@ApiOperation({ summary: 'Get own profile' })
async getMyProfile(@CurrentUser() user: JwtPayload) {
return this.usersService.findOne(user.sub);
}
// PATCH /api/v1/users/me
@Patch('me')
@RequirePermissions(Permission.UPDATE_OWN_PROFILE)
@ApiOperation({ summary: 'Update own profile' })
async updateMyProfile(
@CurrentUser() user: JwtPayload,
@Body() dto: UpdateUserDto,
) {
return this.usersService.updateProfile(user.sub, dto);
}
// ─── ADMIN: User Management ────────────────────────────────────────────────
// GET /api/v1/users (admin only)
@Get()
@Roles(Role.ADMIN)
@RequirePermissions(Permission.READ_ALL_USERS)
@ApiOperation({ summary: '[Admin] List all users with pagination, search, filter' })
async findAll(@Query() query: QueryUserDto) {
const result = await this.usersService.findAll(query);
return {
message: 'Users retrieved successfully',
data: result.data,
meta: result.meta,
};
}
// GET /api/v1/users/:id (admin only)
@Get(':id')
@Roles(Role.ADMIN)
@RequirePermissions(Permission.READ_ALL_USERS)
@ApiOperation({ summary: '[Admin] Get user by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.findOne(id);
}
// PATCH /api/v1/users/:id (admin only)
@Patch(':id')
@Roles(Role.ADMIN)
@RequirePermissions(Permission.UPDATE_ANY_USER)
@ApiOperation({ summary: '[Admin] Update user name or status' })
async adminUpdate(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AdminUpdateUserDto,
) {
return this.usersService.adminUpdateUser(id, dto);
}
// PATCH /api/v1/users/:id/suspend (admin only)
@Patch(':id/suspend')
@Roles(Role.ADMIN)
@RequirePermissions(Permission.SUSPEND_USER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '[Admin] Suspend user' })
async suspend(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.suspendUser(id);
}
// PATCH /api/v1/users/:id/activate (admin only)
@Patch(':id/activate')
@Roles(Role.ADMIN)
@RequirePermissions(Permission.UPDATE_ANY_USER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '[Admin] Activate user' })
async activate(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.activateUser(id);
}
// DELETE /api/v1/users/:id (admin only — soft delete)
@Delete(':id')
@Roles(Role.ADMIN)
@RequirePermissions(Permission.DELETE_ANY_USER)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '[Admin] Soft-delete user' })
async remove(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.deleteUser(id);
}
}
src/modules/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
══════════════════ TASKS MODULE ══════════════════
4. Task DTOs
src/modules/tasks/dto/create-task.dto.ts
import {
IsString,
IsOptional,
IsEnum,
IsDateString,
MinLength,
MaxLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { TaskPriority } from '@prisma/client';
export class CreateTaskDto {
@ApiProperty({ example: 'Complete NestJS project' })
@IsString()
@MinLength(3)
@MaxLength(200)
title: string;
@ApiPropertyOptional({ example: 'Build full-stack todo app' })
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@ApiPropertyOptional({ enum: TaskPriority, default: 'MEDIUM' })
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@ApiPropertyOptional({ example: '2024-12-31' })
@IsOptional()
@IsDateString()
dueDate?: string;
}
src/modules/tasks/dto/update-task.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { IsEnum, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { TaskStatus } from '@prisma/client';
import { CreateTaskDto } from './create-task.dto';
export class UpdateTaskDto extends PartialType(CreateTaskDto) {
@ApiPropertyOptional({ enum: TaskStatus })
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
}
src/modules/tasks/dto/query-task.dto.ts
import { IsOptional, IsEnum, IsString, IsUUID } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { TaskStatus, TaskPriority } from '@prisma/client';
import { PaginationDto } from '../../../common/dto/pagination.dto';
import { DateRangeDto } from '../../../common/dto/date-range.dto';
import { IntersectionType } from '@nestjs/mapped-types';
export class QueryTaskDto extends IntersectionType(PaginationDto, DateRangeDto) {
@ApiPropertyOptional({ enum: TaskStatus })
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@ApiPropertyOptional({ enum: TaskPriority })
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@ApiPropertyOptional({ description: 'Filter by user ID (admin only)' })
@IsOptional()
@IsUUID()
userId?: string;
}
5. Tasks Service (src/modules/tasks/tasks.service.ts)
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { QueryTaskDto } from './dto/query-task.dto';
import { buildPaginationParams, buildPaginatedResult } from '../../common/utils/pagination.util';
import { buildDateRangeFilter } from '../../common/utils/date-range.util';
import { Prisma } from '@prisma/client';
import { Role } from '../../common/enums/roles.enum';
@Injectable()
export class TasksService {
constructor(private readonly prisma: PrismaService) {}
// ─── CREATE TASK ──────────────────────────────────────────────────────────
async create(userId: string, dto: CreateTaskDto) {
const task = await this.prisma.task.create({
data: {
title: dto.title,
description: dto.description,
priority: dto.priority || 'MEDIUM',
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
userId,
},
});
return { message: 'Task created successfully', data: task };
}
// ─── GET ALL TASKS (with filters) ─────────────────────────────────────────
async findAll(
requestingUserId: string,
requestingUserRole: string,
query: QueryTaskDto,
) {
const params = buildPaginationParams(query);
const dateFilter = buildDateRangeFilter('createdAt', query);
const dueDateFilter = buildDateRangeFilter('dueDate', {
startDate: query.startDate,
endDate: query.endDate,
});
// Role-based ownership scoping
const ownerFilter =
requestingUserRole === Role.ADMIN
? query.userId
? { userId: query.userId }
: {}
: { userId: requestingUserId };
const where: Prisma.TaskWhereInput = {
...ownerFilter,
...(query.status && { status: query.status }),
...(query.priority && { priority: query.priority }),
...(query.search && {
OR: [
{ title: { contains: query.search, mode: 'insensitive' } },
{ description: { contains: query.search, mode: 'insensitive' } },
],
}),
...dateFilter,
};
const allowedSortFields = ['title', 'status', 'priority', 'dueDate', 'createdAt', 'updatedAt'];
const sortBy = allowedSortFields.includes(params.sortBy) ? params.sortBy : 'createdAt';
const [tasks, total] = await this.prisma.$transaction([
this.prisma.task.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
skip: params.skip,
take: params.limit,
orderBy: { [sortBy]: params.sortOrder },
}),
this.prisma.task.count({ where }),
]);
return buildPaginatedResult(tasks, total, params);
}
// ─── GET ONE TASK ──────────────────────────────────────────────────────────
async findOne(
taskId: string,
requestingUserId: string,
requestingUserRole: string,
) {
const task = await this.prisma.task.findUnique({
where: { id: taskId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
if (!task) throw new NotFoundException(`Task with ID "${taskId}" not found`);
this.assertOwnerOrAdmin(task.userId, requestingUserId, requestingUserRole);
return task;
}
// ─── UPDATE TASK ──────────────────────────────────────────────────────────
async update(
taskId: string,
requestingUserId: string,
requestingUserRole: string,
dto: UpdateTaskDto,
) {
const task = await this.findOne(taskId, requestingUserId, requestingUserRole);
const updateData: Prisma.TaskUpdateInput = {
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.priority !== undefined && { priority: dto.priority }),
...(dto.dueDate !== undefined && { dueDate: new Date(dto.dueDate) }),
...(dto.status !== undefined && {
status: dto.status,
completedAt:
dto.status === 'COMPLETED'
? new Date()
: dto.status === 'PENDING' || dto.status === 'IN_PROGRESS'
? null
: undefined,
}),
};
const updated = await this.prisma.task.update({
where: { id: taskId },
data: updateData,
});
return { message: 'Task updated successfully', data: updated };
}
// ─── MARK AS COMPLETED (convenience endpoint) ─────────────────────────────
async markCompleted(
taskId: string,
requestingUserId: string,
requestingUserRole: string,
) {
return this.update(taskId, requestingUserId, requestingUserRole, {
status: 'COMPLETED',
});
}
// ─── DELETE TASK ──────────────────────────────────────────────────────────
async remove(
taskId: string,
requestingUserId: string,
requestingUserRole: string,
) {
await this.findOne(taskId, requestingUserId, requestingUserRole);
await this.prisma.task.delete({ where: { id: taskId } });
return { message: 'Task deleted successfully', data: null };
}
// ─── TASK STATS (for user dashboard) ──────────────────────────────────────
async getStats(userId: string, requestingUserRole: string) {
const scopedUserId = requestingUserRole === Role.ADMIN ? undefined : userId;
const [total, pending, inProgress, completed] = await Promise.all([
this.prisma.task.count({ where: { userId: scopedUserId } }),
this.prisma.task.count({ where: { userId: scopedUserId, status: 'PENDING' } }),
this.prisma.task.count({ where: { userId: scopedUserId, status: 'IN_PROGRESS' } }),
this.prisma.task.count({ where: { userId: scopedUserId, status: 'COMPLETED' } }),
]);
return {
message: 'Task statistics retrieved',
data: { total, pending, inProgress, completed },
};
}
// ─── HELPER ───────────────────────────────────────────────────────────────
private assertOwnerOrAdmin(
ownerId: string,
requestingUserId: string,
role: string,
): void {
if (role !== Role.ADMIN && ownerId !== requestingUserId) {
throw new ForbiddenException('You do not have access to this task');
}
}
}
6. Tasks Controller (src/modules/tasks/tasks.controller.ts)
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { TasksService } from './tasks.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { QueryTaskDto } from './dto/query-task.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { PermissionsGuard } from '../../common/guards/permissions.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermissions } from '../../common/decorators/permissions.decorator';
import { Permission } from '../../common/enums/roles.enum';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
@ApiTags('Tasks')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard)
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
// POST /api/v1/tasks
@Post()
@RequirePermissions(Permission.CREATE_TASK)
@ApiOperation({ summary: 'Create a new task' })
async create(
@CurrentUser() user: JwtPayload,
@Body() dto: CreateTaskDto,
) {
return this.tasksService.create(user.sub, dto);
}
// GET /api/v1/tasks
// Users see only their tasks; Admin can see all or filter by userId
@Get()
@RequirePermissions(Permission.READ_OWN_TASKS)
@ApiOperation({
summary: 'List tasks. Users see own; Admin sees all. Supports pagination, search, filter, sort, date range.',
})
async findAll(
@CurrentUser() user: JwtPayload,
@Query() query: QueryTaskDto,
) {
const result = await this.tasksService.findAll(user.sub, user.role, query);
return {
message: 'Tasks retrieved successfully',
data: result.data,
meta: result.meta,
};
}
// GET /api/v1/tasks/stats
@Get('stats')
@ApiOperation({ summary: 'Get task statistics for the current user' })
async getStats(@CurrentUser() user: JwtPayload) {
return this.tasksService.getStats(user.sub, user.role);
}
// GET /api/v1/tasks/:id
@Get(':id')
@RequirePermissions(Permission.READ_OWN_TASKS)
@ApiOperation({ summary: 'Get a single task by ID' })
async findOne(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.tasksService.findOne(id, user.sub, user.role);
}
// PATCH /api/v1/tasks/:id
@Patch(':id')
@RequirePermissions(Permission.UPDATE_OWN_TASK)
@ApiOperation({ summary: 'Update a task' })
async update(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTaskDto,
) {
return this.tasksService.update(id, user.sub, user.role, dto);
}
// PATCH /api/v1/tasks/:id/complete
@Patch(':id/complete')
@RequirePermissions(Permission.UPDATE_OWN_TASK)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Mark task as completed' })
async markCompleted(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.tasksService.markCompleted(id, user.sub, user.role);
}
// DELETE /api/v1/tasks/:id
@Delete(':id')
@RequirePermissions(Permission.DELETE_OWN_TASK)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete a task' })
async remove(
@CurrentUser() user: JwtPayload,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.tasksService.remove(id, user.sub, user.role);
}
}
src/modules/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
Part 5: Root Application Wiring, Seeding & Running
10. Root Application Setup & Wiring
1. Main App Module (src/app.module.ts)
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ThrottlerModule, APP_GUARD } from '@nestjs/throttler';
import appConfig from './config/app.config';
import jwtConfig from './config/jwt.config';
import redisConfig from './config/redis.config';
import mailConfig from './config/mail.config';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { MailModule } from './mail/mail.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { TasksModule } from './modules/tasks/tasks.module';
import { CustomThrottlerGuard } from './common/guards/throttler.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, jwtConfig, redisConfig, mailConfig],
}),
ThrottlerModule.forRoot([{
ttl: 60000,
limit: 100,
}]),
PrismaModule,
RedisModule,
MailModule,
AuthModule,
UsersModule,
TasksModule,
],
providers: [
{
provide: APP_GUARD,
useClass: CustomThrottlerGuard,
},
],
})
export class AppModule {}
2. Main Entry Point (src/main.ts)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { CustomValidationPipe } from './common/pipes/validation.pipe';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('app.port') || 3000;
app.setGlobalPrefix('api/v1');
app.enableCors({
origin: '*',
credentials: true,
});
app.useGlobalPipes(new CustomValidationPipe());
app.useGlobalInterceptors(new ResponseInterceptor());
app.useGlobalFilters(new GlobalExceptionFilter());
const config = new DocumentBuilder()
.setTitle('Todo App API')
.setDescription('The Todo Application API description')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
}
bootstrap();
11. Seeding & Running
Admin Seeder Script (src/seed/seed.ts)
import { PrismaClient, Role } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({ path: path.join(process.cwd(), '.env') });
const prisma = new PrismaClient();
async function seedAdmin() {
const email = process.env.ADMIN_EMAIL;
const password = process.env.ADMIN_PASSWORD;
if (!email || !password) {
throw new Error('ADMIN_EMAIL and ADMIN_PASSWORD must be set in .env');
}
const existing = await prisma.user.findFirst({ where: { role: Role.ADMIN } });
if (existing) {
console.log('✅ Super Admin already exists, skipping seed.');
return;
}
const hashedPassword = await bcrypt.hash(password, 12);
const admin = await prisma.user.create({
data: {
email,
password: hashedPassword,
name: 'Super Admin',
role: Role.ADMIN,
isVerified: true,
status: 'ACTIVE',
},
});
console.log('🌱 Super Admin seeded:', admin.email);
}
seedAdmin()
.catch((e) => {
console.error('Seed failed:', e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
Add the following to your package.json scripts:
{
"scripts": {
"seed": "ts-node src/seed/seed.ts"
}
}
To seed the database, run:
npm run seed
12. Routing & Error Handling
Welcome Route (/)
The root path (/) is excluded from the global api/v1 prefix. When accessed, it returns a structured welcome message:
{
"success": true,
"message": "Welcome to NestJS Task Manager API!",
"version": "1.0.0"
}
Not Found Route (404)
Any unhandled routes are caught globally by the GlobalExceptionFilter. When an invalid route is requested, it returns a clean, formatted 404 JSON response instead of the default NestJS HTML error:
{
"success": false,
"message": "Cannot GET /invalid-route",
"error": {
"statusCode": 404,
"message": "Cannot GET /invalid-route"
}
}