From 9c53a2f9a0ed352de8b955a1432e58d653860718 Mon Sep 17 00:00:00 2001 From: Mc-Knight Date: Thu, 9 May 2024 05:39:40 +0200 Subject: [PATCH] feat(product managment) add product managment this commit adds product entity with its relation of a vendor. it is used to implement product managment for vendor. Resolves: #48 --- .env.example | 7 +- package.json | 4 + src/@types/index.d.ts | 0 src/controllers/productController.ts | 26 +++++++ src/entities/Product.ts | 54 +++++++++++++ src/helper/productValidator.ts | 25 ++++++ src/lib/types.ts | 16 ++++ src/middlewares/multer.ts | 14 ++++ src/middlewares/verifyToken.ts | 50 ++++++++++++ src/routes/ProductRoutes.ts | 17 +++++ src/routes/index.ts | 2 + src/services/index.ts | 16 ++-- src/services/productServices/createProduct.ts | 56 ++++++++++++++ src/services/productServices/deleteProduct.ts | 32 ++++++++ src/services/productServices/readProduct.ts | 58 ++++++++++++++ .../productServices/removeProductImage.ts | 53 +++++++++++++ src/services/productServices/updateProduct.ts | 76 +++++++++++++++++++ src/utils/cloudinary.ts | 13 ++++ src/utils/response.utils.ts | 2 +- tsconfig.json | 4 +- 20 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 src/@types/index.d.ts create mode 100644 src/controllers/productController.ts create mode 100644 src/entities/Product.ts create mode 100644 src/helper/productValidator.ts create mode 100644 src/lib/types.ts create mode 100644 src/middlewares/multer.ts create mode 100644 src/middlewares/verifyToken.ts create mode 100644 src/routes/ProductRoutes.ts create mode 100644 src/services/productServices/createProduct.ts create mode 100644 src/services/productServices/deleteProduct.ts create mode 100644 src/services/productServices/readProduct.ts create mode 100644 src/services/productServices/removeProductImage.ts create mode 100644 src/services/productServices/updateProduct.ts create mode 100644 src/utils/cloudinary.ts diff --git a/.env.example b/.env.example index 069130c..5e17a15 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,11 @@ PINDO_API_URL = ******************************** PINDO_SENDER = ******************************** JWT_SECRET = ******************************** TWO_FA_MINS = ******************************** + HOST = ******************* AUTH_EMAIL = ********************* -AUTH_PASSWORD = ****************** \ No newline at end of file +AUTH_PASSWORD = ****************** + +CLOUDNARY_API_KEY = ************** +CLOUDINARY_CLOUD_NAME = ************** +CLOUDINARY_API_SECRET = ************** \ No newline at end of file diff --git a/package.json b/package.json index 8a90cc1..9851474 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,14 @@ "dependencies": { "@types/express-winston": "^4.0.0", "@types/jsonwebtoken": "^9.0.6", + "@types/multer": "^1.4.11", "@types/nodemailer": "^6.4.14", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", "axios": "^1.6.8", "bcrypt": "^5.1.1", "class-validator": "^0.14.1", + "cloudinary": "^2.2.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cross-env": "^7.0.3", @@ -33,10 +35,12 @@ "express": "^4.19.2", "express-winston": "^4.2.0", "highlight.js": "^11.9.0", + "joi": "^17.13.1", "jsend": "^1.1.0", "jsonwebtoken": "^9.0.2", "mailgen": "^2.0.28", "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.13", "nodemon": "^3.1.0", "pg": "^8.11.5", diff --git a/src/@types/index.d.ts b/src/@types/index.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/controllers/productController.ts b/src/controllers/productController.ts new file mode 100644 index 0000000..ead88a1 --- /dev/null +++ b/src/controllers/productController.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; +import { createProductService, updateProductService, removeProductImageService, readProductService, readProductsService, deleteProductService } from '../services'; + +export const readProduct = async (req: Request, res: Response) => { + await readProductService(req, res); +}; + +export const readProducts = async (req: Request, res: Response) => { + await readProductsService(req, res); +}; + +export const createProduct = async (req: Request, res: Response) => { + await createProductService(req, res); +}; + +export const updateProduct = async (req: Request, res: Response) => { + await updateProductService(req, res); +}; + +export const removeProductImage = async (req: Request, res: Response) => { + await removeProductImageService(req, res); +}; + +export const deleteProduct = async (req: Request, res: Response) => { + await deleteProductService(req, res); +}; diff --git a/src/entities/Product.ts b/src/entities/Product.ts new file mode 100644 index 0000000..723f9c3 --- /dev/null +++ b/src/entities/Product.ts @@ -0,0 +1,54 @@ +import { Entity, PrimaryGeneratedColumn, Column, Unique, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { IsNotEmpty, IsString, IsBoolean, ArrayNotEmpty, IsArray, MaxLength } from 'class-validator'; +import { User } from './User'; + +@Entity() +@Unique(['id']) +export class Product { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User) + @IsNotEmpty() + vendor!: User; + + @Column() + @IsNotEmpty() + @IsString() + name!: string; + + @Column() + @IsNotEmpty() + description!: string; + + @Column('simple-array') + @IsArray() + @ArrayNotEmpty() + @MaxLength(10) + images!: string[]; + + @Column('decimal') + @IsNotEmpty() + newPrice!: number; + + @Column('decimal', { nullable: true }) + oldPrice?: number; + + @Column('timestamp', { nullable: true }) + expirationDate?: Date; + + @Column('int') + @IsNotEmpty() + quantity!: number; + + @Column({ default: true }) + @IsBoolean() + isAvailable!: boolean; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/helper/productValidator.ts b/src/helper/productValidator.ts new file mode 100644 index 0000000..e534fdf --- /dev/null +++ b/src/helper/productValidator.ts @@ -0,0 +1,25 @@ +import Joi from 'joi'; +import { Product } from '../lib/types'; + +export const validateProduct = ( + product: Pick +): Joi.ValidationResult => { + const schema = Joi.object({ + name: Joi.string().min(3).required().messages({ + 'any.required': 'name is required.', + }), + description: Joi.string().min(3).required().messages({ + 'any.required': 'description is required.', + }), + newPrice: Joi.number().required().messages({ + 'any.required': 'newPrice is required.', + }), + quantity: Joi.number().required().messages({ + 'any.required': 'quantity is required.', + }), + expirationDate: Joi.date(), + oldPrice: Joi.number(), + }); + + return schema.validate(product); +}; diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..b8598e8 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,16 @@ +import { User } from '../entities/User'; + +export type Product = { + id: string; + vendor: User; + name: string; + description: string; + images: string[]; + newPrice: number; + oldPrice?: number; + expirationDate?: Date; + quantity: number; + isAvailable: boolean; + createdAt: Date; + updatedAt: Date; +}; diff --git a/src/middlewares/multer.ts b/src/middlewares/multer.ts new file mode 100644 index 0000000..0c1eba5 --- /dev/null +++ b/src/middlewares/multer.ts @@ -0,0 +1,14 @@ +import multer from 'multer'; +import path from 'path'; + +export default multer({ + storage: multer.diskStorage({}), + fileFilter: (req, file, next) => { + const ext = path.extname(file.originalname); + const supported = ['.png', '.jpg', '.jpeg', '.webp']; + if (!supported.includes(ext)) { + next(new Error(`file type not supported\ntry ${supported} are supported`)); + } + next(null, true); + }, +}); diff --git a/src/middlewares/verifyToken.ts b/src/middlewares/verifyToken.ts new file mode 100644 index 0000000..3fe4f1a --- /dev/null +++ b/src/middlewares/verifyToken.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import { User, UserInterface } from '../entities/User'; +import { getRepository } from 'typeorm'; +import jwt, { type JwtPayload, type Secret } from 'jsonwebtoken'; +import dotenv from 'dotenv'; + +dotenv.config(); + +interface AuthRequest extends Request { + user?: UserInterface; +} + +export const authMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (authHeader === undefined) { + return res.status(401).json({ error: 'Access denied. No token provided.' }); + } + + const [bearer, token] = authHeader.split(' '); + + if (bearer !== 'Bearer' || token === undefined) { + return res.status(401).json({ error: 'Please login' }); + } + + if (token !== undefined) { + try { + jwt.verify(token, process.env.JWT_SECRET as Secret, async (err, decodedToken) => { + if (err !== null) { + return res.status(403).json({ status: 'error', error: 'Access denied' }); + } + + if (decodedToken !== undefined) { + const { email } = decodedToken as JwtPayload; + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + return res.status(401).json({ status: 'error', error: 'You are not Authorized' }); + } + + req.user = user as UserInterface; + next(); + } + }); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } + } +}; diff --git a/src/routes/ProductRoutes.ts b/src/routes/ProductRoutes.ts new file mode 100644 index 0000000..67f077a --- /dev/null +++ b/src/routes/ProductRoutes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; + +import upload from '../middlewares/multer'; +import { authMiddleware } from '../middlewares/verifyToken'; +import { hasRole } from '../middlewares'; +import { createProduct, updateProduct, removeProductImage, readProducts, readProduct, deleteProduct } from '../controllers/productController'; + +const router = Router(); + +router.get('/', authMiddleware, hasRole('VENDOR'), readProducts); +router.get('/:id', authMiddleware, hasRole('VENDOR'), readProduct); +router.post('/', authMiddleware, hasRole('VENDOR'), upload.array('images', 10), createProduct); +router.put('/:id', authMiddleware, hasRole('VENDOR'), upload.array('images', 10), updateProduct); +router.delete('/images/:id', authMiddleware, hasRole('VENDOR'), removeProductImage); +router.delete('/:id', authMiddleware, hasRole('VENDOR'), deleteProduct); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 851f5c2..0926106 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,6 +1,7 @@ import { Request, Response, Router } from 'express'; import { responseSuccess } from '../utils/response.utils'; import userRoutes from './UserRoutes'; +import productRoutes from './ProductRoutes'; const router = Router(); @@ -9,5 +10,6 @@ router.get('/api/v1/status', (req: Request, res: Response) => { }); router.use('/user', userRoutes); +router.use('/product', productRoutes); export default router; diff --git a/src/services/index.ts b/src/services/index.ts index a7b7863..97a40a9 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,11 +1,17 @@ - -export * from "./userServices/sendResetPasswordLinkService"; -export * from "./userServices/userPasswordResetService"; -export * from "./userServices/userRegistrationService"; -export * from "./userServices/userValidationService"; +export * from './userServices/sendResetPasswordLinkService'; +export * from './userServices/userPasswordResetService'; +export * from './userServices/userRegistrationService'; +export * from './userServices/userValidationService'; export * from './userServices/userEnableTwoFactorAuth'; export * from './userServices/userDisableTwoFactorAuth'; export * from './userServices/userValidateOTP'; export * from './userServices/userLoginService'; export * from './userServices/userResendOTP'; export * from './userServices/logoutServices'; + +// Vendor product services +export * from './productServices/createProduct'; +export * from './productServices/updateProduct'; +export * from './productServices/removeProductImage'; +export * from './productServices/readProduct'; +export * from './productServices/deleteProduct'; diff --git a/src/services/productServices/createProduct.ts b/src/services/productServices/createProduct.ts new file mode 100644 index 0000000..2455d71 --- /dev/null +++ b/src/services/productServices/createProduct.ts @@ -0,0 +1,56 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { validateProduct } from '../../helper/productValidator'; +import cloudinary from '../../utils/cloudinary'; +import { User } from '../../entities/User'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const createProductService = async (req: Request, res: Response) => { + const { error } = validateProduct(req.body); + if (error !== undefined) { + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const files: any = req.files; + + if (files.length < 2) { + return res.status(400).json({ status: 'error', error: 'Please upload more than one image' }); + } + + const imageUrls: string[] = []; + for (const file of files) { + const image = file.path; + const link = await cloudinary.uploader.upload(image); + imageUrls.push(link.secure_url); + } + + const product = new Product(); + product.name = req.body.name; + product.description = req.body.description; + product.newPrice = req.body.newPrice; + product.quantity = req.body.quantity; + product.images = imageUrls; + + if (req.body.expirationDate) { + product.expirationDate = req.body.expirationDate; + } + product.vendor = req.user as User; + + const productRepository = getRepository(Product); + await productRepository.save(product); + + product.vendor = product.vendor.id as unknown as User; + return res.status(201).json({ + status: 'success', + data: { + message: 'Product created successfully', + product, + }, + }); +}; diff --git a/src/services/productServices/deleteProduct.ts b/src/services/productServices/deleteProduct.ts new file mode 100644 index 0000000..43ec3d1 --- /dev/null +++ b/src/services/productServices/deleteProduct.ts @@ -0,0 +1,32 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + + +export const deleteProductService = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const productRepository = getRepository(Product); + + const product = await productRepository.findOne({ + where: { + id: id, + vendor: { + id: req.user?.id + } + } + }); + + if (product) { + await productRepository.remove(product); + return responseSuccess(res, 200, 'Product successfully deleted'); + } + + return responseError(res, 404, 'Product not found'); + + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/readProduct.ts b/src/services/productServices/readProduct.ts new file mode 100644 index 0000000..0b3d7a1 --- /dev/null +++ b/src/services/productServices/readProduct.ts @@ -0,0 +1,58 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + + +export const readProductsService = async (req: Request, res: Response) => { + try { + + // Define pagination parameters + const page = req.query.page ? Number(req.query.page) : 1; + const limit = req.query.limit ? Number(req.query.limit) : 10; + const skip = (page - 1) * limit; + + // Retrieve products + const productRepository = getRepository(Product); + const products = await productRepository.find({ + where: { + vendor: { + id: req.user?.id + } + }, + skip, + take: limit + }); + + if (!products) { + return responseError(res, 404, 'No Products available'); + } + return responseSuccess(res, 200, 'Products retrieved', { products }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; + +export const readProductService = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id: id, + vendor: { + id: req.user?.id + } + } + }); + + if (!product) { + return responseError(res, 404, 'Product not found'); + } + + return responseSuccess(res, 200, 'Product retrieved', { product }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/removeProductImage.ts b/src/services/productServices/removeProductImage.ts new file mode 100644 index 0000000..e6a04dd --- /dev/null +++ b/src/services/productServices/removeProductImage.ts @@ -0,0 +1,53 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { User } from '../../entities/User'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const removeProductImageService = async (req: Request, res: Response) => { + const { image } = req.body; + + if (!image) { + return res.status(400).json({ status: 'error', error: 'Please provide an image to remove' }); + } + + const { id } = req.params; + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id, + }, + relations: ['vendor'], + }); + + if (!product) { + return res.status(404).json({ status: 'error', error: 'Product not found' }); + } + + if (product.vendor.id !== req.user?.id) { + return res.status(403).json({ status: 'error', error: 'You are not authorized to perform this action' }); + } + + const index = product.images.indexOf(image); + + if (index === -1) { + return res.status(404).json({ status: 'error', error: 'Image not found' }); + } + + product.images.splice(index, 1); + await productRepository.save(product); + product.vendor = product.vendor.id as unknown as User; + + return res.status(200).json({ + status: 'success', + data: { + message: 'Image removed successfully', + product, + }, + }); +}; diff --git a/src/services/productServices/updateProduct.ts b/src/services/productServices/updateProduct.ts new file mode 100644 index 0000000..ed76cb2 --- /dev/null +++ b/src/services/productServices/updateProduct.ts @@ -0,0 +1,76 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { validateProduct } from '../../helper/productValidator'; +import cloudinary from '../../utils/cloudinary'; +import { User } from '../../entities/User'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const updateProductService = async (req: Request, res: Response) => { + const { error } = validateProduct(req.body); + if (error !== undefined) { + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const { id } = req.params; + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id, + }, + relations: ['vendor'], + }); + + if (!product) { + return res.status(404).json({ status: 'error', error: 'Product not found' }); + } + + if (product.vendor.id !== req.user?.id) { + return res.status(403).json({ status: 'error', error: 'You are not authorized to perform this action' }); + } + + product.name = req.body.name; + product.description = req.body.description; + product.newPrice = req.body.newPrice; + + if (parseInt(req.body.quantity) === 0) { + product.isAvailable = false; + product.quantity = req.body.quantity; + } else { + product.isAvailable = true; + product.quantity = req.body.quantity; + } + + if (req.files) { + const imageUrls: string[] = []; + for (const image of req.files) { + const link = await cloudinary.uploader.upload(image.path); + imageUrls.push(link.secure_url); + } + product.images = [...product.images, ...imageUrls]; + } + + if (req.body.expirationDate) { + product.expirationDate = req.body.expirationDate; + } + + if (req.body.oldPrice) { + product.oldPrice = req.body.oldPrice; + } + + await productRepository.save(product); + + product.vendor = product.vendor.id as unknown as User; + return res.status(200).json({ + status: 'success', + data: { + message: 'Product updated successfully', + product, + }, + }); +}; diff --git a/src/utils/cloudinary.ts b/src/utils/cloudinary.ts new file mode 100644 index 0000000..18b8db3 --- /dev/null +++ b/src/utils/cloudinary.ts @@ -0,0 +1,13 @@ +/* eslint-disable camelcase */ +import { v2 as cloudinary } from 'cloudinary'; +import dotenv from 'dotenv'; + +dotenv.config(); + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDNARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +export default cloudinary; diff --git a/src/utils/response.utils.ts b/src/utils/response.utils.ts index 3be109b..3fd1ce5 100644 --- a/src/utils/response.utils.ts +++ b/src/utils/response.utils.ts @@ -17,7 +17,7 @@ export const responseSuccess = ( jsend.success({ code: statusCode, message, - data, + ...data, }) ); }; diff --git a/tsconfig.json b/tsconfig.json index 88326b4..d58c75f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,7 +31,9 @@ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ "types": [ "node", - "jest" + "jest", + "express", + "joi" ] /* Specify type package names to be included without being referenced in a source file. */, // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */