Skip to main content

Building a backend with NestJS and Prisma

In this chapter, you build a backend API for a React SPA using NestJS and Prisma.

What you learn in this chapter

  • NestJS's module structure and dependency injection
  • Type-safe database operations with Prisma
  • Implementing JWT authentication and refresh tokens
  • Designing and validating a RESTful API
info

In this chapter, you create a new NestJS project. You work in a directory separate from the Chapter 3 React project. After it is complete, you can integrate it with the frontend authentication learned in Chapter 27.

info

Because NestJS has a structure similar to Angular, you may be confused at first by the concepts (modules, decorators, dependency injection, etc.). However, this structure is the key to achieving a scalable, maintainable backend. First, copy the code by hand and grasp how each element works together.

What is NestJS

NestJS is a Node.js framework built with TypeScript. It has a module structure inspired by Angular and is suited to enterprise-level application development.

Characteristics

CharacteristicDescription
TypeScriptFull TypeScript support
Module structureA structure separated by feature
Dependency injectionA testable, loosely coupled design
DecoratorsDeclarative code writing
A rich ecosystemAuthentication, validation, ORMs, and more

What is Prisma

Prisma is a modern ORM for Node.js/TypeScript. It provides type-safe database access.

Characteristics

CharacteristicDescription
Type-safeAuto-generates types from the schema
An intuitive APISimple, easy-to-understand queries
MigrationsSafely manage schema changes
Prisma StudioInspect and edit data in a GUI
Why choose Prisma

With traditional ORMs, SQL and TypeScript types were separated, or complex configuration was required. Prisma defines the database structure with a single schema.prisma file, and auto-generates TypeScript types and a query API from it. This makes the flow of schema change → migration → type update consistent, and prevents the bug of "the database change is not reflected in the code."

Project setup

info

This book uses NestJS 11 (based on Express 5) and Prisma 7 (the Rust engine removed). Prisma 6 and earlier differ greatly in the connection method and the import source of the generated client, so be careful not to mix steps from older sources.

Creating a NestJS project

# The NestJS CLI (you can use npx as an alternative if you do not want to install globally)
npm install -g @nestjs/cli@11
# or
npx -y @nestjs/cli@11 new project-api

# Create the project (if you installed the CLI)
nest new project-api
cd project-api

# Install the required packages
npm install @prisma/client @prisma/adapter-pg dotenv @nestjs/config @nestjs/jwt @nestjs/passport
npm install passport passport-jwt bcrypt cookie-parser class-validator class-transformer @nestjs/mapped-types
npm install -D prisma @types/passport-jwt @types/bcrypt @types/cookie-parser
info

If you do not want to install @nestjs/cli globally, you can use npx -y @nestjs/cli@11 new project-api as an alternative. For CI environments or when using multiple NestJS versions in parallel, this has fewer side effects and is recommended.

Initializing Prisma

npx prisma init

prisma init generates prisma.config.ts in addition to prisma/schema.prisma and .env. Because the Prisma 7 CLI does not load .env automatically, the import 'dotenv/config' at the top of prisma.config.ts plays that role.

// prisma.config.ts
import 'dotenv/config'
import { defineConfig, env } from 'prisma/config'

export default defineConfig({
schema: 'prisma/schema.prisma',
datasource: {
url: env('DATABASE_URL')
}
})

Project structure

project-api/
├── prisma/
│ ├── schema.prisma # the database schema
│ └── migrations/ # the migration files
├── prisma.config.ts # Prisma CLI config (.env loading and the connection URL)
├── src/
│ ├── auth/ # the authentication module
│ │ ├── auth.module.ts
│ │ ├── auth.controller.ts
│ │ ├── auth.service.ts
│ │ ├── jwt.strategy.ts
│ │ └── dto/
│ ├── users/ # the user module
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ └── dto/
│ ├── projects/ # the project module
│ │ ├── projects.module.ts
│ │ ├── projects.controller.ts
│ │ ├── projects.service.ts
│ │ └── dto/
│ ├── prisma/ # the Prisma module
│ │ ├── prisma.module.ts
│ │ └── prisma.service.ts
│ ├── app.module.ts
│ └── main.ts
├── .env
└── package.json

The database schema

Prisma schema definition

// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}

datasource db {
provider = "postgresql"
// The connection URL is managed in prisma.config.ts (for the CLI) and the driver adapter (for runtime)
}

model User {
id String @id @default(uuid())
email String @unique
name String
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

// Relations
projects Project[]
boards Board[]
cards Card[]
refreshTokens RefreshToken[]

@@map("users")
}

model RefreshToken {
id String @id @default(uuid())
token String @unique
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")

@@map("refresh_tokens")
}

model Project {
id String @id @default(uuid())
name String
description String?
ownerId String @map("owner_id")
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

// Relations
boards Board[]

@@map("projects")
}

model Board {
id String @id @default(uuid())
name String
position Int @default(0)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdById String @map("created_by_id")
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

// Relations
cards Card[]

@@map("boards")
}

model Card {
id String @id @default(uuid())
title String
description String?
position Int @default(0)
boardId String @map("board_id")
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
createdById String @map("created_by_id")
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

@@map("cards")
}

Running migrations

# Create and apply a migration (development environment)
npx prisma migrate dev --name init

# Generate the Prisma Client
npx prisma generate

Migration operations in production

The commands you use differ between development and production. Be careful not to confuse their purposes.

CommandPurposeSafety
prisma migrate devDevelopment environment. Automatically runs schema change → migration generation → DB application → prisma generateProposes a data reset when it detects a destructive change
prisma migrate deployProduction environment. Just applies the existing migration SQL in orderDoes not reset the DB (safe)
prisma migrate resetA last resort during development. Initializes the DB and re-runs all migrationsAll data is lost
prisma migrate resolveManually resolve the state of a migration that failed to applyFor advanced operations
# Production environment (run in the CI / CD pipeline)
npx prisma migrate deploy
warning

Never use prisma migrate dev in production. When it detects a database difference, it proposes "Reset?", and accepting it by mistake loses production data. For production deployment, always use prisma migrate deploy, with an operation that applies only pre-verified migration SQL.

info

About rollbacks: Prisma does not have an automatic rollback command. When a rollback is needed, handle it with one of the following methods.

  1. Add a counteracting migration (recommended): create a new migration that reverts the schema and apply it with prisma migrate deploy
  2. Manually resolve the failed state: fix the migration history on the DB with prisma migrate resolve --rolled-back <migration-name>, and apply a migration with the failure cause removed

In either case, it is important to verify it in a staging environment and take a DB backup before applying it in production.

The Prisma service

// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../generated/prisma/client'

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
// In Prisma 7, connect to the DB via a driver adapter
// (so it is evaluated after ConfigModule loads .env, the adapter is created inside the constructor)
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL
})
super({ adapter })
}

async onModuleInit() {
await this.$connect()
}

async onModuleDestroy() {
await this.$disconnect()
}
}
// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common'
import { PrismaService } from './prisma.service'

@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}

The authentication module

Defining the DTOs

// src/auth/dto/register.dto.ts
import { IsEmail, IsString, MinLength, MaxLength } from 'class-validator'

export class RegisterDto {
@IsString()
@MinLength(2)
@MaxLength(50)
name: string

@IsEmail()
email: string

@IsString()
@MinLength(8)
password: string
}
// src/auth/dto/login.dto.ts
import { IsEmail, IsString } from 'class-validator'

export class LoginDto {
@IsEmail()
email: string

@IsString()
password: string
}

The authentication service

// src/auth/auth.service.ts
import {
Injectable,
UnauthorizedException,
ConflictException
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { ConfigService } from '@nestjs/config'
import * as bcrypt from 'bcrypt'
import { PrismaService } from '../prisma/prisma.service'
import { RegisterDto } from './dto/register.dto'
import { LoginDto } from './dto/login.dto'

@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private configService: ConfigService
) {}

async register(dto: RegisterDto) {
// Check for a duplicate email address
const existingUser = await this.prisma.user.findUnique({
where: { email: dto.email }
})

if (existingUser) {
throw new ConflictException('This email address is already registered')
}

// Hash the password
const passwordHash = await bcrypt.hash(dto.password, 10)

// Create the user
const user = await this.prisma.user.create({
data: {
name: dto.name,
email: dto.email,
passwordHash
},
select: {
id: true,
email: true,
name: true
}
})

// Generate tokens
const tokens = await this.generateTokens(user.id)

return {
user,
...tokens
}
}

async login(dto: LoginDto) {
// Find the user
const user = await this.prisma.user.findUnique({
where: { email: dto.email }
})

if (!user) {
throw new UnauthorizedException('Invalid credentials')
}

// Verify the password
const isPasswordValid = await bcrypt.compare(dto.password, user.passwordHash)

if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials')
}

// Generate tokens
const tokens = await this.generateTokens(user.id)

return {
user: {
id: user.id,
email: user.email,
name: user.name
},
...tokens
}
}

async refresh(refreshToken: string) {
// Verify the refresh token
const storedToken = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: true }
})

if (!storedToken || storedToken.expiresAt < new Date()) {
throw new UnauthorizedException('The refresh token is invalid')
}

// Delete the old token
await this.prisma.refreshToken.delete({
where: { id: storedToken.id }
})

// Generate new tokens
const tokens = await this.generateTokens(storedToken.userId)

return {
user: {
id: storedToken.user.id,
email: storedToken.user.email,
name: storedToken.user.name
},
...tokens
}
}

async logout(refreshToken: string) {
await this.prisma.refreshToken.deleteMany({
where: { token: refreshToken }
})
}

async getMe(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true
}
})

return { user }
}

private async generateTokens(userId: string) {
// Access token (short expiration)
const accessToken = this.jwtService.sign(
{ sub: userId },
{
secret: this.configService.get('JWT_ACCESS_SECRET'),
expiresIn: '15m'
}
)

// Refresh token (long expiration)
const refreshToken = this.jwtService.sign(
{ sub: userId },
{
secret: this.configService.get('JWT_REFRESH_SECRET'),
expiresIn: '7d'
}
)

// Save the refresh token to the DB
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)

await this.prisma.refreshToken.create({
data: {
token: refreshToken,
userId,
expiresAt
}
})

return { accessToken, refreshToken }
}
}
Hash the refresh token in production

The above saves it in plaintext for learning purposes. In production, make it irreversible with a fast hash such as SHA-256, like crypto.createHash('sha256').update(token).digest('hex'), before saving (bcrypt only looks at the first 72 bytes, so it is unsuitable for hashing a long JWT). This prevents the risk that, if the DB leaks, the stored tokens are abused directly (the impact is larger because they are longer-lived than access tokens). Because a SHA-256 hash is deterministic, you can findUnique by the hash value during verification.

The authentication controller

// src/auth/auth.controller.ts
import {
Controller,
Post,
Get,
Body,
Res,
Req,
UseGuards,
HttpCode,
HttpStatus,
UnauthorizedException
} from '@nestjs/common'
import { Response, Request } from 'express'
import { AuthService } from './auth.service'
import { RegisterDto } from './dto/register.dto'
import { LoginDto } from './dto/login.dto'
import { JwtAuthGuard } from './jwt-auth.guard'
import { CurrentUser } from './current-user.decorator'

@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}

@Post('register')
async register(
@Body() dto: RegisterDto,
@Res({ passthrough: true }) res: Response
) {
const { user, accessToken, refreshToken } = await this.authService.register(dto)

this.setRefreshTokenCookie(res, refreshToken)

return { user, accessToken }
}

@Post('login')
@HttpCode(HttpStatus.OK)
async login(
@Body() dto: LoginDto,
@Res({ passthrough: true }) res: Response
) {
const { user, accessToken, refreshToken } = await this.authService.login(dto)

this.setRefreshTokenCookie(res, refreshToken)

return { user, accessToken }
}

@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
const refreshToken = req.cookies['refreshToken']

if (!refreshToken) {
throw new UnauthorizedException('No refresh token')
}

const { user, accessToken, refreshToken: newRefreshToken } =
await this.authService.refresh(refreshToken)

this.setRefreshTokenCookie(res, newRefreshToken)

return { user, accessToken }
}

@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
const refreshToken = req.cookies['refreshToken']

if (refreshToken) {
await this.authService.logout(refreshToken)
}

res.clearCookie('refreshToken')

return { message: 'Logged out' }
}

@Get('me')
@UseGuards(JwtAuthGuard)
async getMe(@CurrentUser() userId: string) {
return this.authService.getMe(userId)
}

private setRefreshTokenCookie(res: Response, token: string) {
res.cookie('refreshToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
})
}
}

The JWT strategy and guard

// src/auth/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'

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private prisma: PrismaService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_ACCESS_SECRET')
})
}

async validate(payload: { sub: string }) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub }
})

if (!user) {
throw new UnauthorizedException()
}

return payload.sub // set on the request as userId
}
}
// src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
// src/auth/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common'

export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest()
return request.user // the value returned by JwtStrategy's validate
}
)

Defining the authentication module

Consolidate the controller, service, and strategy created so far into a single module.

// src/auth/auth.module.ts
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
import { AuthService } from './auth.service'
import { AuthController } from './auth.controller'
import { JwtStrategy } from './jwt.strategy'

@Module({
imports: [PassportModule, JwtModule.register({})],
controllers: [AuthController],
providers: [AuthService, JwtStrategy]
})
export class AuthModule {}

Because generateTokens specifies the secret each time it signs, registering JwtModule.register({}) with empty options is sufficient.

The project module

Defining the DTOs

// src/projects/dto/create-project.dto.ts
import { IsString, IsOptional, MaxLength } from 'class-validator'

export class CreateProjectDto {
@IsString()
@MaxLength(100)
name: string

@IsString()
@IsOptional()
@MaxLength(500)
description?: string
}
// src/projects/dto/update-project.dto.ts
import { PartialType } from '@nestjs/mapped-types'
import { CreateProjectDto } from './create-project.dto'

export class UpdateProjectDto extends PartialType(CreateProjectDto) {}

The project service

// src/projects/projects.service.ts
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { CreateProjectDto } from './dto/create-project.dto'
import { UpdateProjectDto } from './dto/update-project.dto'

@Injectable()
export class ProjectsService {
constructor(private prisma: PrismaService) {}

async create(userId: string, dto: CreateProjectDto) {
return this.prisma.project.create({
data: {
...dto,
ownerId: userId
},
include: {
owner: {
select: { id: true, name: true, email: true }
},
boards: {
orderBy: { position: 'asc' }
}
}
})
}

async findAll(userId: string) {
return this.prisma.project.findMany({
where: { ownerId: userId },
include: {
owner: {
select: { id: true, name: true, email: true }
},
_count: {
select: { boards: true }
}
},
orderBy: { createdAt: 'desc' }
})
}

async findOne(userId: string, id: string) {
const project = await this.prisma.project.findUnique({
where: { id },
include: {
owner: {
select: { id: true, name: true, email: true }
},
boards: {
orderBy: { position: 'asc' },
include: {
cards: {
orderBy: { position: 'asc' }
}
}
}
}
})

if (!project) {
throw new NotFoundException('Project not found')
}

if (project.ownerId !== userId) {
throw new ForbiddenException('You do not have permission to access this project')
}

return project
}

async update(userId: string, id: string, dto: UpdateProjectDto) {
const project = await this.findOne(userId, id)

return this.prisma.project.update({
where: { id: project.id },
data: dto,
include: {
owner: {
select: { id: true, name: true, email: true }
},
boards: {
orderBy: { position: 'asc' }
}
}
})
}

async remove(userId: string, id: string) {
const project = await this.findOne(userId, id)

await this.prisma.project.delete({
where: { id: project.id }
})

return { message: 'Project deleted' }
}
}

The project controller

// src/projects/projects.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards
} from '@nestjs/common'
import { ProjectsService } from './projects.service'
import { CreateProjectDto } from './dto/create-project.dto'
import { UpdateProjectDto } from './dto/update-project.dto'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../auth/current-user.decorator'

@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}

@Post()
create(@CurrentUser() userId: string, @Body() dto: CreateProjectDto) {
return this.projectsService.create(userId, dto)
}

@Get()
findAll(@CurrentUser() userId: string) {
return this.projectsService.findAll(userId)
}

@Get(':id')
findOne(@CurrentUser() userId: string, @Param('id') id: string) {
return this.projectsService.findOne(userId, id)
}

@Patch(':id')
update(
@CurrentUser() userId: string,
@Param('id') id: string,
@Body() dto: UpdateProjectDto
) {
return this.projectsService.update(userId, id, dto)
}

@Delete(':id')
remove(@CurrentUser() userId: string, @Param('id') id: string) {
return this.projectsService.remove(userId, id)
}
}

The application module

// src/app.module.ts
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { PrismaModule } from './prisma/prisma.module'
import { AuthModule } from './auth/auth.module'
import { ProjectsModule } from './projects/projects.module'

@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true
}),
PrismaModule,
AuthModule,
ProjectsModule
]
})
export class AppModule {}
info

What we implemented in the main text is the above 3 modules. Create the UsersModule in the "Try it" at the end of this chapter, and the Boards / Cards modules as advanced challenges to combine with the Chapter 29 frontend, using the same pattern as the Projects module (you can generate scaffolds with nest g module boards, etc.). Once created, add them to imports.

The main file

// src/main.ts
import { NestFactory } from '@nestjs/core'
import { ValidationPipe } from '@nestjs/common'
import cookieParser from 'cookie-parser'
import { AppModule } from './app.module'

async function bootstrap() {
const app = await NestFactory.create(AppModule)

// Global prefix
app.setGlobalPrefix('api')

// CORS settings
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true
})

// Cookie parser
app.use(cookieParser())

// Validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true
})
)

await app.listen(3000)
console.log('Server running on http://localhost:3000')
}

bootstrap()

Environment variables

# .env
DATABASE_URL="postgresql://user:password@localhost:5432/project_db"
JWT_ACCESS_SECRET="your-access-secret-key"
JWT_REFRESH_SECRET="your-refresh-secret-key"
FRONTEND_URL="http://localhost:5173"
warning

In production, use sufficiently long random strings for JWT_ACCESS_SECRET and JWT_REFRESH_SECRET. You can generate them with openssl rand -base64 32 or similar. Also, do not commit the .env file to Git (add it to .gitignore).

List of API endpoints

MethodEndpointDescriptionAuth
POST/api/auth/registerUser registrationNot required
POST/api/auth/loginLoginNot required
POST/api/auth/refreshToken refreshCookie
POST/api/auth/logoutLogoutCookie
GET/api/auth/meThe current userRequired
GET/api/projectsProject listRequired
POST/api/projectsCreate a projectRequired
GET/api/projects/:idProject detailRequired
PATCH/api/projects/:idUpdate a projectRequired
DELETE/api/projects/:idDelete a projectRequired

Try it

Take on the following challenges to deepen your understanding of NestJS and Prisma.

  1. Add a user profile update API: create a PATCH /api/users/me endpoint so that the logged-in user can update their own name. Also implement validation with a DTO.

  2. Implement a password change feature: create an endpoint that receives the current password and a new password and changes the password. Do not forget to verify the current password.

  3. Implement refresh token invalidation: add a feature that deletes all of the user's refresh tokens at logout. This logs the user out from other devices too.

Hint
  1. Add a @Patch('me') endpoint to UsersController and retrieve the user ID with @CurrentUser(). Create an UpdateUserDto and validate.
  2. Verify the current password with bcrypt.compare and hash the new password with bcrypt.hash. Throw UnauthorizedException on a mismatch.
  3. You can delete all of the user's tokens with this.prisma.refreshToken.deleteMany({ where: { userId } }).
Answer and explanation

Challenge 1: Add a user profile update API

// src/users/dto/update-user.dto.ts
import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'

export class UpdateUserDto {
@IsString()
@IsOptional()
@MinLength(2)
@MaxLength(50)
name?: string
}
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { UpdateUserDto } from './dto/update-user.dto'

@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}

async updateProfile(userId: string, dto: UpdateUserDto) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
})

if (!user) {
throw new NotFoundException('User not found')
}

const updatedUser = await this.prisma.user.update({
where: { id: userId },
data: dto,
select: {
id: true,
email: true,
name: true
}
})

return updatedUser
}
}
// src/users/users.controller.ts
import { Controller, Patch, Body, UseGuards } from '@nestjs/common'
import { UsersService } from './users.service'
import { UpdateUserDto } from './dto/update-user.dto'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../auth/current-user.decorator'

@Controller('users')
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Patch('me')
updateProfile(@CurrentUser() userId: string, @Body() dto: UpdateUserDto) {
return this.usersService.updateProfile(userId, dto)
}
}

With the @CurrentUser() decorator, you receive the user ID obtained from the JWT, so that users can update only their own profile. Validation with a DTO prevents invalid input.


Challenge 2: Implement a password change feature

// src/users/dto/change-password.dto.ts
import { IsString, MinLength } from 'class-validator'

export class ChangePasswordDto {
@IsString()
currentPassword: string

@IsString()
@MinLength(8)
newPassword: string
}
// src/users/users.service.ts (add a method)
import { UnauthorizedException } from '@nestjs/common'
import * as bcrypt from 'bcrypt'

async changePassword(userId: string, dto: ChangePasswordDto) {
const user = await this.prisma.user.findUnique({
where: { id: userId }
})

if (!user) {
throw new NotFoundException('User not found')
}

// Verify the current password
const isPasswordValid = await bcrypt.compare(
dto.currentPassword,
user.passwordHash
)

if (!isPasswordValid) {
throw new UnauthorizedException('The current password is incorrect')
}

// Hash and save the new password
const newPasswordHash = await bcrypt.hash(dto.newPassword, 10)

await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: newPasswordHash }
})

return { message: 'Password changed' }
}
// src/users/users.controller.ts (add an endpoint)
@Patch('me/password')
changePassword(
@CurrentUser() userId: string,
@Body() dto: ChangePasswordDto
) {
return this.usersService.changePassword(userId, dto)
}

When changing a password, always confirm the current password. This prevents an attacker from changing the password even if a session is hijacked. Hash the new password with bcrypt.hash before saving.


Challenge 3: Implement refresh token invalidation

// src/auth/auth.service.ts (add a method)
async logoutAll(userId: string) {
// Delete all of the user's refresh tokens
await this.prisma.refreshToken.deleteMany({
where: { userId }
})

return { message: 'Logged out from all devices' }
}
// src/auth/auth.controller.ts (add an endpoint)
@Post('logout-all')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async logoutAll(@CurrentUser() userId: string) {
return this.authService.logoutAll(userId)
}
// Usage on the frontend
const handleLogoutAll = async () => {
await api.post('/auth/logout-all')
clearAuth() // also clear the local auth state
navigate('/login')
}

Using deleteMany, you can bulk-delete all of the user's refresh tokens. This invalidates all sessions logged in on other devices or browsers. It is useful when a user wants to end all sessions for security reasons (such as a suspected password leak).

Summary

  • NestJS builds a scalable backend with a module structure
  • Type-safe database access with Prisma's TypeScript types
  • Secure authentication with JWT + HttpOnly Cookie
  • Declarative code writing with decorators
  • Input validation with the validation pipe

In the next chapter, you will integrate the React frontend and the NestJS backend to complete a full-stack application.