Matt Angelosanto
Posted on June 23, 2022
Written by Ivaylo Gerchev✏️
NestJS is one of the best Node frameworks for building server-side applications. In this tutorial, we'll explore how to build a simple NestJS ecommerce app, demonstrating many of Nest’s major features along the way. We’ll cover:
- Getting started with our NestJS ecommerce app
- Creating the NestJS ecommerce store product feature
- Creating the user management feature
- Creating user authentication and authorization
- Creating the store cart feature for our NestJS ecommerce app
Getting started with our NestJS ecommerce app
By default, NestJS uses Express under the hood, although you have the option to use Fastify instead. Nest provides a solid application architecture, while Express and Fastify are strong HTTP server frameworks with a myriad of features for app development.
Having robust architecture gives you the ability to build highly scalable, testable, loosely coupled, and easy-to-maintain applications. Using Nest enables you to take your Node.js backend to the next level.
Nest is heavily inspired by Angular and borrows many of its concepts. If you already use Angular, Nest could be the perfect match.
To follow this tutorial, you will need at least basic knowledge of and experience with Node, MongoDB, TypeScript, and Nest. Make sure you have Node and MongoDB installed on your machine.
Nest features you should know
Let’s take a moment to review the main Nest features: modules, controllers, and services.
Modules are the main strategy to organize and structure Nest app. There must be at least one root module to create an app. Each module can contain controllers and services, and even other modules.
Nest uses the dependency injection pattern to join modules with their dependencies. To make a class injectable, Nest uses an @Injectable
decorator. Then, to provide the class in a module or in a controller, it uses the constructor-based dependency injection.
Controllers handle incoming HTTP requests, validate parameters, and return responses to the client. Controllers should be kept clean and simple, which is where the next Nest feature comes into play.
Services hold most of the business logic and app functionality for your Nest projects. Any complex logic should be provided via services. In fact, services fall under a main type of class called providers.
A provider is just a class injected as a dependency. Other types of a provider which might be used include classes like repositories, factories, helpers, etc.
Creating a new Nest project for our ecommerce app
When you're ready, let's initialize a new Nest project. First, we’ll install Nest CLI. Then, we will create a new project:
npm install -g @nestjs/cli
nest new nestjs-ecommerce
After installation is complete, navigate to the project and start it:
cd nestjs-ecommerce
npm run start:dev
You can then launch the app in your browser by visiting http://localhost:3000/. You should see a nice “Hello World!” message.
The app will reload automatically after any changes you make. If you want to restart the app manually, use npm run start
command instead.
Now we’re ready to start creating the store features.
Creating the NestJS ecommerce store product feature
In this section, we'll focus on product management. The store product feature will allow us to retrieve store products, add new ones, and edit or delete them.
Creating our product resources
Let’s start by creating the needed resources. To create them, run the following commands:
nest g module product
nest g service product --no-spec
nest g controller product --no-spec
The first command generates a product module and puts it in its own directory with the same name.
The next two commands generate service and controller files and import them automatically in the product
module. The --no-spec
argument tells Nest that we don't want to generate additional test files.
After running the above commands, we’ll get a new product
directory containing the following files: product.module.ts
, product.service.ts
, and product.controller.ts
.
Now we have a basic structure for the NestJS ecommerce store product feature. Before we move on, we need to set up our database.
Configuring the MongoDB database
As we are using MongoDB as a database, we'll need to install mongoose
and @nestjs/mongoose
packages.
npm install --save @nestjs/mongoose mongoose
After the installation is complete, open app.module.ts
and replace its content with the following:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; // 1.1 Import the mongoose module
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ProductModule } from './product/product.module'; // 2.1 Import the product module
@Module({
imports: [
MongooseModule.forRoot('mongodb://localhost/store'), // 1.2 Setup the database
ProductModule, // 2.2 Add the product module
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Here’s what we did in the code above. Follow along using my numbered notes:
- First, we imported the
MongooseModule
(1.1) and used it to set up a newstore
database (1.2) - Second, we imported the
ProductModule
(2.1) and added it to theimports
array (2.2)
Our next step is to create a database schema for our product model.
Creating a product model schema
In the product
directory, create a new schemas
directory. Put a product.schema.ts
file in the new directory with the following content:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
export type ProductDocument = Product & Document;
@Schema()
export class Product {
@Prop()
name: string;
@Prop()
description: string;
@Prop()
price: number;
@Prop()
category: string;
}
export const ProductSchema = SchemaFactory.createForClass(Product);
The code above creates a schema for our product with name
, description
, price
, and category
properties.
Now edit the product.module.ts
in the following manner:
import { Module } from '@nestjs/common';
import { ProductController } from './product.controller';
import { ProductService } from './product.service';
import { MongooseModule } from '@nestjs/mongoose'; // 1\. Import mongoose module
import { ProductSchema } from './schemas/product.schema'; // 2\. Import product schema
@Module({
imports: [
MongooseModule.forFeature([{ name: 'Product', schema: ProductSchema }]) // 3\. Setup the mongoose module to use the product schema
],
controllers: [ProductController],
providers: [ProductService]
})
export class ProductModule {}
As you can see from my numbered notes, in the code above, we imported the MongooseModule
(1) and ProductModule
(2), then set the ProductSchema
to be used for our product model (3).
Creating product DTO files
In addition to the product schema, we’ll also need two Data Transfer Object (DTO) files for our NestJS ecommerce app. A DTO file defines the data which will be received from a form submission, a search query, and so on.
We need one DTO for product creation and another for product filtering. Let’s create them now.
In the product
directory, create a new dtos
directory. Put a create-product.dto.ts
file in this new directory with the following content:
export class CreateProductDTO {
name: string;
description: string;
price: number;
category: string;
}
The above DTO defines a product object with the necessary properties for new product creation.
Then, in the same directory, create a filter-product.dto.ts
file with the following content:
export class FilterProductDTO {
search: string;
category: string;
}
This second DTO defines a filter object, which we’ll use to filter the store products by search query, category, or both.
Creating product service methods
All the prep work for this section is done. Now let’s create the actual code for product management.
Open the product.service.ts
file and replace its content with the following:
import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { Product, ProductDocument } from './schemas/product.schema';
import { CreateProductDTO } from './dtos/create-product.dto';
import { FilterProductDTO } from './dtos/filter-product.dto';
@Injectable()
export class ProductService {
constructor(@InjectModel('Product') private readonly productModel: Model<ProductDocument>) { }
async getFilteredProducts(filterProductDTO: FilterProductDTO): Promise<Product[]> {
const { category, search } = filterProductDTO;
let products = await this.getAllProducts();
if (search) {
products = products.filter(product =>
product.name.includes(search) ||
product.description.includes(search)
);
}
if (category) {
products = products.filter(product => product.category === category)
}
return products;
}
async getAllProducts(): Promise<Product[]> {
const products = await this.productModel.find().exec();
return products;
}
async getProduct(id: string): Promise<Product> {
const product = await this.productModel.findById(id).exec();
return product;
}
async addProduct(createProductDTO: CreateProductDTO): Promise<Product> {
const newProduct = await this.productModel.create(createProductDTO);
return newProduct.save();
}
async updateProduct(id: string, createProductDTO: CreateProductDTO): Promise<Product> {
const updatedProduct = await this.productModel
.findByIdAndUpdate(id, createProductDTO, { new: true });
return updatedProduct;
}
async deleteProduct(id: string): Promise<any> {
const deletedProduct = await this.productModel.findByIdAndRemove(id);
return deletedProduct;
}
}
Let’s examine the code block above piece by piece.
First, let’s take a look at the section copied below:
@Injectable()
export class ProductService {
constructor(@InjectModel('Product') private readonly productModel: Model<ProductDocument>) { }
}
This code injects the needed dependencies (the product model) by using the @InjectModel
decorator.
In the next section, we have two methods:
async getAllProducts(): Promise<Product[]> {
const products = await this.productModel.find().exec();
return products;
}
async getProduct(id: string): Promise<Product> {
const product = await this.productModel.findById(id).exec();
return product;
}
The first method getAllProducts
is for getting all products. The second method getProduct
is for getting a single product. We use standard Mongoose methods to achieve these actions.
The method getFilteredProducts
below returns filtered products:
async getFilteredProducts(filterProductDTO: FilterProductDTO): Promise<Product[]> {
const { category, search } = filterProductDTO;
let products = await this.getAllProducts();
if (search) {
products = products.filter(product =>
product.name.includes(search) ||
product.description.includes(search)
);
}
if (category) {
products = products.filter(product => product.category === category)
}
return products;
}
Products can be filtered by search query, by category, or by both.
The next method addProduct
below creates a new product:
async addProduct(createProductDTO: CreateProductDTO): Promise<Product> {
const newProduct = await this.productModel.create(createProductDTO);
return newProduct.save();
}
addProduct
achieves this by using the class from the create-product.dto.ts
file and saving it to the database.
The final two methods are updateProduct
and deleteProduct
:
async updateProduct(id: string, createProductDTO: CreateProductDTO): Promise<Product> {
const updatedProduct = await this.productModel
.findByIdAndUpdate(id, createProductDTO, { new: true });
return updatedProduct;
}
async deleteProduct(id: string): Promise<any> {
const deletedProduct = await this.productModel.findByIdAndRemove(id);
return deletedProduct;
}
Using these methods, you can find a product by ID and either update it or remove it from the database.
Creating product controller methods
The final step for the product module is to create the API endpoints.
We’ll create the following API endpoints:
- POST
store/products/
— add new product - GET
store/products/
— get all products - GET
store/products/:id
— get single product - PUT
store/products/:id
— edit single product - DELETE
store/products/:id
— remove single product
Open the product.controller.ts
file and replace its content with the following:
import { Controller, Post, Get, Put, Delete, Body, Param, Query, NotFoundException } from '@nestjs/common';
import { ProductService } from './product.service';
import { CreateProductDTO } from './dtos/create-product.dto';
import { FilterProductDTO } from './dtos/filter-product.dto';
@Controller('store/products')
export class ProductController {
constructor(private productService: ProductService) { }
@Get('/')
async getProducts(@Query() filterProductDTO: FilterProductDTO) {
if (Object.keys(filterProductDTO).length) {
const filteredProducts = await this.productService.getFilteredProducts(filterProductDTO);
return filteredProducts;
} else {
const allProducts = await this.productService.getAllProducts();
return allProducts;
}
}
@Get('/:id')
async getProduct(@Param('id') id: string) {
const product = await this.productService.getProduct(id);
if (!product) throw new NotFoundException('Product does not exist!');
return product;
}
@Post('/')
async addProduct(@Body() createProductDTO: CreateProductDTO) {
const product = await this.productService.addProduct(createProductDTO);
return product;
}
@Put('/:id')
async updateProduct(@Param('id') id: string, @Body() createProductDTO: CreateProductDTO) {
const product = await this.productService.updateProduct(id, createProductDTO);
if (!product) throw new NotFoundException('Product does not exist!');
return product;
}
@Delete('/:id')
async deleteProduct(@Param('id') id: string) {
const product = await this.productService.deleteProduct(id);
if (!product) throw new NotFoundException('Product does not exist');
return product;
}
}
NestJS provides a full set of JavaScript decorators to work with HTTP requests and responses (Get
, Put
, Body
, Param
, etc.), handle errors (NotFoundException
), define controllers (Controller
), and so on.
We imported the ones we need from @nestjs/common
at the beginning of the file. We also import all the other files we’ve already created and we need: ProductService
, CreateProductDTO
, and FilterProductDTO
.
From now on, I won’t explain imports in great detail. Most of them are pretty straightforward and self-explanatory. For more information about a particular class or component’s use, you can consult the documentation.
Let’s divide the rest of the code into smaller chunks.
First, we use @Controller
decorator to set the the part of the URL which is shared by all endpoints:
@Controller('store/products')
export class ProductController {
constructor(private productService: ProductService) { }
}
We also inject the product service in the class constructor in the code above.
Next, we define the following endpoint by using the @Get
decorator:
@Get('/')
async getProducts(@Query() filterProductDTO: FilterProductDTO) {
if (Object.keys(filterProductDTO).length) {
const filteredProducts = await this.productService.getFilteredProducts(filterProductDTO);
return filteredProducts;
} else {
const allProducts = await this.productService.getAllProducts();
return allProducts;
}
}
After defining the endpoint, we use @Query
decorator in the getProducts()
method and the object from filter-product.dto.ts
to get the query parameters from a request.
If the query parameters from a request exist, we use getFilteredProduct()
method from the product service. If there are no such parameters, we use the regular getAllProducts()
method instead.
In the following endpoint, we use the @Body
decorator to get the needed data from the request body and then pass it to the addProduct()
method:
@Post('/')
async addProduct(@Body() createProductDTO: CreateProductDTO) {
const product = await this.productService.addProduct(createProductDTO);
return product;
}
In the next endpoints, we use the @Param
decorator to get the product ID from the URL:
@Get('/:id')
async getProduct(@Param('id') id: string) {
const product = await this.productService.getProduct(id);
if (!product) throw new NotFoundException('Product does not exist!');
return product;
}
@Put('/:id')
async updateProduct(@Param('id') id: string, @Body() createProductDTO: CreateProductDTO) {
const product = await this.productService.updateProduct(id, createProductDTO);
if (!product) throw new NotFoundException('Product does not exist!');
return product;
}
@Delete('/:id')
async deleteProduct(@Param('id') id: string) {
const product = await this.productService.deleteProduct(id);
if (!product) throw new NotFoundException('Product does not exist');
return product;
}
We then use the appropriate method from the product service to get, edit, or delete a product. If a product is not found, we use the NotFoundException
to throw an error message.
Creating the user management feature
The next feature we need to create for our NestJS ecommerce app is the user management feature.
Generating our user management resources
For the user management feature, we’ll need only a module and a service. To create them, run the following:
nest g module user
nest g service user --no-spec
As with the previous feature, we’ll need a schema and DTO.
Creating a user schema and DTO
In the user
directory generated by Nest, create a new schemas
folder. Add a user.schema.ts
file to this new folder with the following content:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
// import { Role } from 'src/auth/enums/role.enum';
export type UserDocument = User & Document;
@Schema()
export class User {
@Prop()
username: string;
@Prop()
email: string;
@Prop()
password: string;
/*
@Prop()
roles: Role[];
*/
}
export const UserSchema = SchemaFactory.createForClass(User);
The commented code towards the end of the block will be used when we implement user authorization. I’ll tell you when to uncomment them later on in this tutorial.
Next, in the user
directory, create a new dtos
folder. Add a create-user-dto.ts
file in this new folder with the following content:
export class CreateUserDTO {
username: string;
email: string;
password: string;
roles: string[];
}
Configuring the resources
Open user.module.ts
and set the schema in the same way as we did with the product feature:
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { UserSchema } from './schemas/user.schema';
import { UserService } from './user.service';
@Module({
imports: [
MongooseModule.forFeature([{ name: 'User', schema: UserSchema }])
],
providers: [UserService],
exports: [UserService]
})
export class UserModule {}
In the code above, we are also exporting UserService
so we can use it in the authentication service later on.
We’ll also need to install two additional packages: bcrypt
and @types/bcrypt
:
npm install bcrypt
npm install -D @types/bcrypt
These packages enable us to keep the password saved, which we will work on in the next section.
Creating user service methods
Now let’s add the logic for the user management. Open the user.service.ts
file and replace its content with the following:
import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { User, UserDocument } from './schemas/user.schema';
import { CreateUserDTO } from './dtos/create-user.dto';
import * as bcrypt from 'bcrypt';
@Injectable()
export class UserService {
constructor(@InjectModel('User') private readonly userModel: Model<UserDocument>) { }
async addUser(createUserDTO: CreateUserDTO): Promise<User> {
const newUser = await this.userModel.create(createUserDTO);
newUser.password = await bcrypt.hash(newUser.password, 10);
return newUser.save();
}
async findUser(username: string): Promise<User | undefined> {
const user = await this.userModel.findOne({username: username});
return user;
}
}
We have added two methods in the code above. The addUser()
method creates a new user, encrypts the new user’s password by using bcrypt.hash()
, and then saves the user to the database.
The findUser()
method finds a particular user by the username
.
Creating user authentication and authorization
In this section, we’ll extend the user management feature in our NestJS ecommerce app by adding user authentication, which verifies the user’s identity, and user authorization, which defines what the user is allowed to do.
We’ll use the well-known Passport library, which provides a big variety of authenticating strategies. Let’s install the necessary packages:
npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local
In the code above, we installed the main passport
package, the passport-local
strategy (which implements a simple username and password authentication mechanism), and the Nest passport adapter. We also installed the types for passport-local
.
We’ll also need to install also the dotenv
package for managing environment variables:
npm install dotenv
Create an .env
file in the root directory and put the following code inside:
JWT_SECRET="topsecret"
We’ll use this variable later on.
Generating our user authentication and authorization resources
As usual, let’s start by creating the needed resources for our auth feature:
nest g module auth
nest g service auth --no-spec
nest g controller auth --no-spec
Creating user service methods
Open the auth.service.ts
file and replace its content with the following:
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(private readonly userService: UserService) {}
async validateUser(username: string, password: string): Promise<any> {
const user = await this.userService.findUser(username);
const isPasswordMatch = await bcrypt.compare(
password,
user.password
);
if (user && isPasswordMatch) {
return user;
}
return null;
}
}
The code above gives us a user validation method, which retrieves the user and verifies the user’s password.
Creating a local authentication strategy
In the auth
directory, create a new strategies
folder. Add a local.strategy.ts
file in this new folder with the following content:
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<any> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
This code does two things.
First, it calls the super()
method in the constructor. We can pass an options object here if we need to. We’ll go through an example later.
Second, we added a validate()
method, which uses validateUser()
from the auth service to verify the user.
Creating an authentication strategy with JWT
Now we’ll create a passport authentication strategy using JSON Web Tokens (JWT). This will return a JWT for logged users for use in subsequent calls to protected API endpoints.
Let’s install the necessary packages:
npm install --save @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt
Next, in the strategies
directory, create a jwt.strategy.ts
file with the following content:
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import 'dotenv/config'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username, roles: payload.roles };
}
}
In the code above, we set an options
object with the following properties:
-
jwtFromRequest
tells the Passport module how JWT will be extracted from the request (in this case, as a bearer token) -
ignoreExpiration
set tofalse
means the responsibility of ensuring that a JWT has not expired is delegated to the Passport module -
secretOrKey
is used to sign the token
The validate()
method returns a payload
, which is the JWT decoded as JSON. We then use this payload to return a user object with the necessary properties.
Now let’s modify the auth.service.ts
file:
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt'; // 1
import * as bcrypt from 'bcrypt';
@Injectable()
export class AuthService {
constructor(private readonly userService: UserService, private readonly jwtService: JwtService) {} // 2
async validateUser(username: string, password: string): Promise<any> {
const user = await this.userService.findUser(username);
const isPasswordMatch = await bcrypt.compare(
password,
user.password
);
if (user && isPasswordMatch) {
return user;
}
return null;
}
async login(user: any) {
const payload = { username: user.username, sub: user._id, roles: user.roles };
return {
access_token: this.jwtService.sign(payload),
};
}
}
The code above is labeled so you can follow what we did:
- Imported the
JwtService
(see//1
) - Added
JwtService
to the constructor (see//2
).
We then used the login()
method to sign a JWT.
After all the changes we’ve made, we need to update the auth.module.ts
in the following manner:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserModule } from 'src/user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';
import 'dotenv/config'
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '3600s' },
}),
],
providers: [
AuthService,
LocalStrategy,
JwtStrategy
],
controllers: [AuthController],
})
export class AuthModule {}
In the code above, we added UserModule
, PassportModule
, and JwtModule
in the imports
array.
We also used the register()
method to provide the necessary options: the secret
key and signOptions
object, which set the token expiration to 3600s
, or 1 hour.
Finally, we added LocalStrategy
and JwtStrategy
in the providers
array.
Creating local and JWT guards
To use the strategies we’ve just created, we’ll need to create Guards.
In auth
directory, create a new guards
folder. Add a local.guard.ts
file to this new folder with the following content:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
Also in the guards
folder, create a jwt.guard.ts
file with the following content:
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
We’ll see how to use these guards in a minute. But first, let’s create the user authorization functionality.
Creating user roles management
To implement this feature in our NestJS ecommerce app, we’ll use role-based access control.
For this feature, we’ll need three files: role.enum.ts
, roles.decorator.ts
, and roles.guard.ts
. Let’s start with the role.enum.ts
file.
In the auth
directory, create a new enums
folder. Add a role.enum.ts
file in this new folder with the following content:
export enum Role {
User = 'user',
Admin = 'admin',
}
This represents the available roles for registered users.
Now you can go back to the user.schema.ts
file we created earlier and uncomment the commented code.
Next, in the auth
directory, create a new decorators
folder. Add a roles.decorator.ts
file in this new folder with the following content:
import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
In the code above, we used SetMetadata()
to create the decorator.
Finally, in the guards
directory, create a roles.guard.ts
file with the following content:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../enums/role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';
@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) {
return true;
}
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
In the code above, we used the Reflector
helper class to access the route's roles. We also switched the execution context to HTTP with switchToHttp()
to get the user
details using getRequest()
. Finally, we returned the user’s roles.
Controller methods
Our last step in this section is to create the controller methods. Open the auth.controller.ts
file and replace its content with the following:
import { Controller, Request, Get, Post, Body, UseGuards } from '@nestjs/common';
import { CreateUserDTO } from 'src/user/dtos/create-user.dto';
import { UserService } from 'src/user/user.service';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './guards/local-auth.guard';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { Roles } from './decorators/roles.decorator';
import { Role } from './enums/role.enum';
import { RolesGuard } from './guards/roles.guard';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService, private userService: UserService) {}
@Post('/register')
async register(@Body() createUserDTO: CreateUserDTO) {
const user = await this.userService.addUser(createUserDTO);
return user;
}
@UseGuards(LocalAuthGuard)
@Post('/login')
async login(@Request() req) {
return this.authService.login(req.user);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.User)
@Get('/user')
getProfile(@Request() req) {
return req.user;
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.Admin)
@Get('/admin')
getDashboard(@Request() req) {
return req.user;
}
}
We have four endpoints in the code above:
- POST
auth/register
is used to create a new user - POST
auth/login
is used to log in a registered user- To verify the user, we use the
LocalAuthGuard
- To verify the user, we use the
- GET
auth/user
is used to access the user’s profile- We used
JwtGuard
to authenticate the user - We used
RolesGuard
plus@Roles
decorator to provide the appropriate authorization depending on the user’s roles
- We used
- GET
auth/admin
is used to access the admin dashboard - We also used
JwtGuard
andRolesGuard
as done in the previous endpoint
Creating the store cart feature for our NestJS ecommerce app
The last feature we’ll add to our project is a basic cart functionality.
Creating our store cart resources
Let’s create the resources we need for this next section:
nest g module cart
nest g service cart --no-spec
nest g controller cart --no-spec
Creating the schemas and DTOs
For the store cart feature, we’ll need two schemas: one describing the products in the cart, and one describing the cart itself.
As usual, in the cart
directory, create a new schemas
folder. Add a item.schema.ts
file in this new folder with the following content:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, SchemaTypes } from 'mongoose';
export type ItemDocument = Item & Document;
@Schema()
export class Item {
@Prop({ type: SchemaTypes.ObjectId, ref: 'Product' })
productId: string;
@Prop()
name: string;
@Prop()
quantity: number;
@Prop()
price: number;
@Prop()
subTotalPrice: number;
}
export const ItemSchema = SchemaFactory.createForClass(Item);
In the code above, in the @Prop
decorator for the productId
property, we defined an object id schema type and added a reference to the product. This means that we will use the id of the product for the productId
value.
The next schema is for the cart. In the schemas
directory, create a cart.schema.ts
file with the following content:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document, SchemaTypes } from 'mongoose';
import { Item } from './item.schema';
export type CartDocument = Cart & Document;
@Schema()
export class Cart {
@Prop({ type: SchemaTypes.ObjectId, ref: 'User' })
userId: string;
@Prop()
items: Item[];
@Prop()
totalPrice: number;
}
export const CartSchema = SchemaFactory.createForClass(Cart);
Here, we use the same technique for the userId
property which will get as a value the user’s id. For the items
property we use the our Item
schema to define an array of items with type of Item
.
And lastly, let’s create the item DTO. In the user
directory, create a new dtos
folder and add an item.dto.ts
file with the following content:
export class ItemDTO {
productId: string;
name: string;
quantity: number;
price: number;
}
Configuring the cart module
Before we move to the business logic, we need to add the cart schema to the cart module. Open the cart.module.ts
file and configure it to use the cart schema as follows:
import { Module } from '@nestjs/common';
import { CartController } from './cart.controller';
import { CartService } from './cart.service';
import { MongooseModule } from '@nestjs/mongoose';
import { CartSchema } from './schemas/cart.schema';
@Module({
imports: [
MongooseModule.forFeature([{ name: 'Cart', schema: CartSchema }])
],
controllers: [CartController],
providers: [CartService]
})
export class CartModule {}
Creating cart service methods
Now let’s create the cart management logic. Open the cart.service.ts
file and replace its content with the following:
import { Injectable } from '@nestjs/common';
import { Model } from 'mongoose';
import { InjectModel } from '@nestjs/mongoose';
import { Cart, CartDocument } from './schemas/cart.schema';
import { ItemDTO } from './dtos/item.dto';
@Injectable()
export class CartService {
constructor(@InjectModel('Cart') private readonly cartModel: Model<CartDocument>) { }
async createCart(userId: string, itemDTO: ItemDTO, subTotalPrice: number, totalPrice: number): Promise<Cart> {
const newCart = await this.cartModel.create({
userId,
items: [{ ...itemDTO, subTotalPrice }],
totalPrice
});
return newCart;
}
async getCart(userId: string): Promise<CartDocument> {
const cart = await this.cartModel.findOne({ userId });
return cart;
}
async deleteCart(userId: string): Promise<Cart> {
const deletedCart = await this.cartModel.findOneAndRemove({ userId });
return deletedCart;
}
private recalculateCart(cart: CartDocument) {
cart.totalPrice = 0;
cart.items.forEach(item => {
cart.totalPrice += (item.quantity * item.price);
})
}
async addItemToCart(userId: string, itemDTO: ItemDTO): Promise<Cart> {
const { productId, quantity, price } = itemDTO;
const subTotalPrice = quantity * price;
const cart = await this.getCart(userId);
if (cart) {
const itemIndex = cart.items.findIndex((item) => item.productId == productId);
if (itemIndex > -1) {
let item = cart.items[itemIndex];
item.quantity = Number(item.quantity) + Number(quantity);
item.subTotalPrice = item.quantity * item.price;
cart.items[itemIndex] = item;
this.recalculateCart(cart);
return cart.save();
} else {
cart.items.push({ ...itemDTO, subTotalPrice });
this.recalculateCart(cart);
return cart.save();
}
} else {
const newCart = await this.createCart(userId, itemDTO, subTotalPrice, price);
return newCart;
}
}
async removeItemFromCart(userId: string, productId: string): Promise<any> {
const cart = await this.getCart(userId);
const itemIndex = cart.items.findIndex((item) => item.productId == productId);
if (itemIndex > -1) {
cart.items.splice(itemIndex, 1);
return cart.save();
}
}
}
There are many methods here. Let’s examine them one by one.
The first one is for creating a new cart for the current user:
async createCart(userId: string, itemDTO: ItemDTO, subTotalPrice: number, totalPrice: number): Promise<Cart> {
const newCart = await this.cartModel.create({
userId,
items: [{ ...itemDTO, subTotalPrice }],
totalPrice
});
return newCart;
}
The next two methods are for getting or deleting a particular user’s cart:
async getCart(userId: string): Promise<CartDocument> {
const cart = await this.cartModel.findOne({ userId });
return cart;
}
async deleteCart(userId: string): Promise<Cart> {
const deletedCart = await this.cartModel.findOneAndRemove({ userId });
return deletedCart;
}
The next method is for recalculating the cart total when an item is added or removed, or when an item’s quantity is changed:
private recalculateCart(cart: CartDocument) {
cart.totalPrice = 0;
cart.items.forEach(item => {
cart.totalPrice += (item.quantity * item.price);
})
}
The next method is for adding items to the cart:
async addItemToCart(userId: string, itemDTO: ItemDTO): Promise<Cart> {
const { productId, quantity, price } = itemDTO;
const subTotalPrice = quantity * price;
const cart = await this.getCart(userId);
if (cart) {
const itemIndex = cart.items.findIndex((item) => item.productId == productId);
if (itemIndex > -1) {
let item = cart.items[itemIndex];
item.quantity = Number(item.quantity) + Number(quantity);
item.subTotalPrice = item.quantity * item.price;
cart.items[itemIndex] = item;
this.recalculateCart(cart);
return cart.save();
} else {
cart.items.push({ ...itemDTO, subTotalPrice });
this.recalculateCart(cart);
return cart.save();
}
} else {
const newCart = await this.createCart(userId, itemDTO, subTotalPrice, price);
return newCart;
}
}
In the method above, if the cart exists, there are two options:
- The product exists, so we need to update its quantity and subtotal price
- The product doesn’t exist, so we need to add it
Either way, we need to run the recalculateCart()
method to update the cart appropriately. If the cart doesn’t exist, we need to create a new one.
The last method is for removing an item from the cart:
async removeItemFromCart(userId: string, productId: string): Promise<any> {
const cart = await this.getCart(userId);
const itemIndex = cart.items.findIndex((item) => item.productId == productId);
if (itemIndex > -1) {
cart.items.splice(itemIndex, 1);
this.recalculateCart(cart);
return cart.save();
}
}
Similarly to the previous method, in the method above, we run recalculateCart()
to update the cart correctly after an item is removed.
Creating cart controller methods
Our final step to finish this NestJS ecommerce app project is to add the cart controller methods.
Open cart.controller.ts
file and replace its content with the following:
import { Controller, Post, Body, Request, UseGuards, Delete, NotFoundException, Param } from '@nestjs/common';
import { Roles } from 'src/auth/decorators/roles.decorator';
import { Role } from 'src/auth/enums/role.enum';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RolesGuard } from 'src/auth/guards/roles.guard';
import { CartService } from './cart.service';
import { ItemDTO } from './dtos/item.dto';
@Controller('cart')
export class CartController {
constructor(private cartService: CartService) { }
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.User)
@Post('/')
async addItemToCart(@Request() req, @Body() itemDTO: ItemDTO) {
const userId = req.user.userId;
const cart = await this.cartService.addItemToCart(userId, itemDTO);
return cart;
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.User)
@Delete('/')
async removeItemFromCart(@Request() req, @Body() { productId }) {
const userId = req.user.userId;
const cart = await this.cartService.removeItemFromCart(userId, productId);
if (!cart) throw new NotFoundException('Item does not exist');
return cart;
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.User)
@Delete('/:id')
async deleteCart(@Param('id') userId: string) {
const cart = await this.cartService.deleteCart(userId);
if (!cart) throw new NotFoundException('Cart does not exist');
return cart;
}
}
In the code above, we used @UseGuards
and @Roles
decorators for the three methods. This instructs the app that a customer must be logged in and must have a user
role assigned to add or remove products.
That’s it. If you have followed along correctly, you should have a basic but fully functional NestJS eccomerce app.
Conclusion
Phew! This was a pretty long ride. I hope you’ve enjoyed and learned something new about NestJS.
Despite the detailed explanations needed to explain each step of building this NestJS ecommerce app example, it is pretty basic and can be extended to include even more features. Here are some ideas you can try:
- Add pagination for the products
- Add validation for the received data
- Create an order module, in which you can store and manage a particular user’s various orders
As you can see, NestJS is a powerful and flexible server-side framework that can give you a robust and scalable structure for your next projects. If you want to learn more, dive into the official Nest documentation and start building great apps.
LogRocket: See the technical and UX reasons for why users don’t complete a step in your ecommerce flow.
LogRocket is like a DVR for web and mobile apps and websites, recording literally everything that happens on your ecommerce app. Instead of guessing why users don’t convert, LogRocket proactively surfaces the root cause of issues that are preventing conversion in your funnel, such as JavaScript errors or dead clicks. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Start proactively monitoring your ecommerce apps — try for free.
Posted on June 23, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 9, 2024