From 9bd544321416e2f1d05d185232f97967b4d32359 Mon Sep 17 00:00:00 2001 From: Patrick Hagenimana Date: Sun, 28 Apr 2024 17:42:27 +0200 Subject: [PATCH] Feature for enabling product to be created by seller modified: package.json new file: public/uploads/mac.jpg new file: public/uploads/to-do-app.jpeg new file: src/controllers/categoriesController.ts new file: src/controllers/productsController.ts modified: src/database/index.ts new file: src/database/migrations/20240426195145-create-category.js new file: src/database/migrations/20240426204430-create-product.js new file: src/database/models/Category.ts new file: src/database/models/Product.ts new file: src/middlewares/multer.ts new file: src/routes/categoryRouter.ts modified: src/routes/index.ts new file: src/routes/productRoutes.ts modified: src/server.ts --- package.json | 4 +- src/controllers/categoriesController.ts | 31 +++++ src/controllers/productsController.ts | 72 +++++++++++ src/database/index.ts | 4 +- .../migrations/20240425195548-create-user.js | 5 + .../20240426195145-create-category.js | 40 ++++++ .../20240429115230-create-product.js | 63 +++++++++ src/database/models/Category.ts | 41 ++++++ src/database/models/Product.ts | 72 +++++++++++ .../seeders/20240429200629-add-seller-role.js | 23 ++++ .../seeders/20240429201217-add-admin-role.js | 23 ++++ src/docs/products.yaml | 122 ++++++++++++++++++ src/docs/users.yaml | 43 ++---- src/helpers/claudinary.ts | 20 +-- src/middlewares/cloudinary.ts | 12 ++ src/middlewares/multer.ts | 13 ++ src/routes/categoryRouter.ts | 9 ++ src/routes/index.ts | 4 + src/routes/productRoutes.ts | 15 +++ src/server.ts | 6 +- src/validations/index.ts | 4 + 21 files changed, 575 insertions(+), 51 deletions(-) create mode 100644 src/controllers/categoriesController.ts create mode 100644 src/controllers/productsController.ts create mode 100644 src/database/migrations/20240426195145-create-category.js create mode 100644 src/database/migrations/20240429115230-create-product.js create mode 100644 src/database/models/Category.ts create mode 100644 src/database/models/Product.ts create mode 100644 src/database/seeders/20240429200629-add-seller-role.js create mode 100644 src/database/seeders/20240429201217-add-admin-role.js create mode 100644 src/docs/products.yaml create mode 100644 src/middlewares/cloudinary.ts create mode 100644 src/middlewares/multer.ts create mode 100644 src/routes/categoryRouter.ts create mode 100644 src/routes/productRoutes.ts diff --git a/package.json b/package.json index 140615de..ed028ea6 100644 --- a/package.json +++ b/package.json @@ -34,11 +34,11 @@ "glob": "^10.3.12", "jsonwebtoken": "^9.0.2", "mailgen": "^2.0.28", + "multer": "^1.4.5-lts.1", "nodemailer": "^6.9.13", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", - "multer": "^1.4.5-lts.1", "pg": "^8.11.5", "pg-hstore": "^2.3.4", "sequelize": "^6.37.2", @@ -91,4 +91,4 @@ "eslint --fix" ] } -} \ No newline at end of file +} diff --git a/src/controllers/categoriesController.ts b/src/controllers/categoriesController.ts new file mode 100644 index 00000000..eedb5e6e --- /dev/null +++ b/src/controllers/categoriesController.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { Category, CategoryCreationAttributes } from '../database/models/Category'; +import logger from '../logs/config'; + +export const createCategory = async (req: Request, res: Response) => { + try { + const { name, description } = req.body as CategoryCreationAttributes; + const newCategory = await Category.create({ + name, + description, + }); + res.status(201).json({ status: 201, ok: true, message: 'New category created successully!', data: newCategory }); + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); + } + res.status(500).json({ error: 'Failed to create category' }); + } +}; + +export const getCategory = async (req: Request, res: Response) => { + try { + const categories = await Category.findAll(); + res.status(200).json({ ok: true, data: categories }); + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); + } + res.status(500).json({ error: 'Failed to fetch categories' }); + } +}; diff --git a/src/controllers/productsController.ts b/src/controllers/productsController.ts new file mode 100644 index 00000000..74f5ac08 --- /dev/null +++ b/src/controllers/productsController.ts @@ -0,0 +1,72 @@ +import { Request, Response } from 'express'; +import { Product, ProductCreationAttributes } from '../database/models/Product'; +import logger from '../logs/config'; +import uploadImage from '../helpers/claudinary'; +import { validateFields } from '../validations'; + +export const createProduct = async (req: Request, res: Response) => { + try { + const { categoryId } = req.params; + const productImages = []; + const images: unknown = req.files; + if (images instanceof Array && images.length > 3) { + for (const image of images) { + const imageBuffer: Buffer = image.buffer; + const url = await uploadImage(imageBuffer); + productImages.push(url); + } + } else { + return res.status(400).json({ + status: 400, + message: 'Product should have at least 4 images', + }); + } + + const requiredFields = ['name', 'description', 'price']; + const missingFields = validateFields(req, requiredFields); + + if (missingFields.length > 0) { + res.status(400).json({ + ok: false, + message: `Required fields are missing: ${missingFields.join(', ')}`, + }); + } + + const { name, description, price, discount } = req.body as ProductCreationAttributes; + + const thisProductExists = await Product.findOne({ where: { name } }); + + if (thisProductExists) { + return res.status(400).json({ + status: 400, + ok: false, + message: 'This Product already exists, You can update the stock levels instead.', + data: thisProductExists, + discount, + }); + } + + const createdProduct = await Product.create({ + name, + description, + price, + categoryId, + images: productImages, + }); + + res + .status(201) + .json({ status: 201, ok: true, data: createdProduct, message: 'Thank you for adding new product in the store!' }); + } catch (error) { + if (error instanceof Error) { + res.status(500).json({ + status: 500, + ok: false, + message: 'Something went wrong when creating the product', + error: error.message, + }); + } else { + logger.error('Unexpected error', error); + } + } +}; diff --git a/src/database/index.ts b/src/database/index.ts index 647e9f76..4d340004 100644 --- a/src/database/index.ts +++ b/src/database/index.ts @@ -6,8 +6,8 @@ const databaseConnection = async () => { try { await sequelize.authenticate(); logger.info('connected to the database'); - } catch (error: any) { - logger.error(error.message); + } catch (error: unknown) { + if (error instanceof Error) console.log(error.message); } }; diff --git a/src/database/migrations/20240425195548-create-user.js b/src/database/migrations/20240425195548-create-user.js index 099529eb..9f5140ae 100644 --- a/src/database/migrations/20240425195548-create-user.js +++ b/src/database/migrations/20240425195548-create-user.js @@ -48,6 +48,11 @@ module.exports = { allowNull: false, defaultValue: false, }, + status: { + type: Sequelize.ENUM('active', 'inactive'), + allowNull: false, + defaultValue: 'active', + }, createdAt: { allowNull: false, type: Sequelize.DATE, diff --git a/src/database/migrations/20240426195145-create-category.js b/src/database/migrations/20240426195145-create-category.js new file mode 100644 index 00000000..0e2fcdba --- /dev/null +++ b/src/database/migrations/20240426195145-create-category.js @@ -0,0 +1,40 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +'use strict'; + +const sequelize = require('sequelize'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('categories', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: sequelize.UUIDV4, + unique: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('NOW()'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + }, + async down(queryInterface) { + await queryInterface.dropTable('categories'); + }, +}; diff --git a/src/database/migrations/20240429115230-create-product.js b/src/database/migrations/20240429115230-create-product.js new file mode 100644 index 00000000..27c04602 --- /dev/null +++ b/src/database/migrations/20240429115230-create-product.js @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +'use strict'; + +const sequelize = require('sequelize'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('products', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: sequelize.UUIDV4, + unique: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: false, + }, + price: { + type: Sequelize.FLOAT, + allowNull: false, + }, + discount: { + type: Sequelize.FLOAT, + allowNull: true, + defaultValue: 0, // Default discount is 0 + }, + images: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: false, + }, + categoryId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'categories', + key: 'id', + }, + onUpdate: 'CASCADE', // Optional: Update the behavior on category deletion + onDelete: 'CASCADE', // Optional: Delete associated products on category deletion + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('NOW()'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + }); + }, + async down(queryInterface) { + await queryInterface.dropTable('products'); + }, +}; diff --git a/src/database/models/Category.ts b/src/database/models/Category.ts new file mode 100644 index 00000000..9063f94a --- /dev/null +++ b/src/database/models/Category.ts @@ -0,0 +1,41 @@ +import { Model, Optional, DataTypes, UUIDV4 } from 'sequelize'; +import sequelize from './index'; + +interface CategoryAttributes { + id: number; + name: string; + description?: string; +} + +export interface CategoryCreationAttributes extends Optional {} + +export class Category extends Model implements CategoryAttributes { + public id!: number; + public name!: string; + public description!: string; +} + +Category.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + }, + { + sequelize, + modelName: 'Category', + tableName: 'categories', + timestamps: true, + } +); diff --git a/src/database/models/Product.ts b/src/database/models/Product.ts new file mode 100644 index 00000000..eea5c186 --- /dev/null +++ b/src/database/models/Product.ts @@ -0,0 +1,72 @@ +import { Model, Optional, DataTypes } from 'sequelize'; +import sequelize from './index'; +import { UUIDV4 } from 'sequelize'; +import { Category } from './Category'; + +interface ProductAttributes { + id: number; + name: string; + description: string; + price: number; + discount?: number; + categoryId: string; + createdAt?: Date; + updatedAt?: Date; + images: string[]; +} + +export interface ProductCreationAttributes extends Optional {} + +export class Product extends Model implements ProductAttributes { + public id!: number; + public name!: string; + public description!: string; + public price!: number; + public discount!: number; + public categoryId!: string; + public images!: string[]; + public readonly createdAt!: Date | undefined; + public readonly updatedAt!: Date | undefined; +} + +Product.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + autoIncrement: true, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: false, + }, + price: { + type: DataTypes.FLOAT, + allowNull: false, + }, + discount: { + type: DataTypes.FLOAT, + allowNull: true, + defaultValue: 0, // Default discount is 0 + }, + images: { + type: DataTypes.ARRAY(DataTypes.STRING), + allowNull: false, + }, + categoryId: { + type: DataTypes.UUID, + references: { + model: 'Category', + key: 'id', + }, + }, + }, + { sequelize: sequelize, timestamps: true, modelName: 'Product', tableName: 'products' } +); + +Product.belongsTo(Category, { foreignKey: 'categoryId' }); diff --git a/src/database/seeders/20240429200629-add-seller-role.js b/src/database/seeders/20240429200629-add-seller-role.js new file mode 100644 index 00000000..2bc23f16 --- /dev/null +++ b/src/database/seeders/20240429200629-add-seller-role.js @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +'use strict'; + +const { v4: uuidv4 } = require('uuid'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.bulkInsert('Roles', [ + { + id: uuidv4(), + name: 'seller', + displayName: 'Seller Role', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + return queryInterface.bulkDelete('Roles', { name: 'seller' }); + }, +}; diff --git a/src/database/seeders/20240429201217-add-admin-role.js b/src/database/seeders/20240429201217-add-admin-role.js new file mode 100644 index 00000000..8f93eb51 --- /dev/null +++ b/src/database/seeders/20240429201217-add-admin-role.js @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +'use strict'; + +const { v4: uuidv4 } = require('uuid'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.bulkInsert('Roles', [ + { + id: uuidv4(), + name: 'admin', + displayName: 'Admin Role', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + }, + + down: async (queryInterface, Sequelize) => { + return queryInterface.bulkDelete('Roles', { name: 'admin' }); + }, +}; diff --git a/src/docs/products.yaml b/src/docs/products.yaml new file mode 100644 index 00000000..ec9c0eba --- /dev/null +++ b/src/docs/products.yaml @@ -0,0 +1,122 @@ +tags: + - name: Product + description: Operations related to products + +paths: + /api/products/{categoryId}/create-product: + post: + summary: Create a new product + tags: + - Product + parameters: + - in: path + name: categoryId + required: true + type: string + description: ID of the category to which the product belongs + security: + - bearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + name: + type: string + description: + type: string + price: + type: number + discount: + type: number + required: false + images: + type: array + items: + type: string + format: binary + description: Product images (minimum 4 images required) + responses: + 201: + description: Product created successfully + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 201 + ok: + type: boolean + example: true + data: + $ref: '#/components/schemas/Product' + message: + type: string + example: Thank you for adding a new product in the store! + 400: + description: Bad request, invalid parameters provided + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 400 + ok: + type: boolean + example: false + message: + type: string + example: Product should have at least 4 images + 500: + description: Internal Server Error + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 500 + ok: + type: boolean + example: false + message: + type: string + example: Something went wrong when creating the product + +components: + schemas: + Product: + type: object + properties: + name: + type: string + description: Name of the product + description: + type: string + description: Description of the product + price: + type: number + format: float + description: Price of the product + categoryId: + type: string + description: ID of the category to which the product belongs + images: + type: array + items: + type: string + description: Array of URLs of product images + securitySchemes: + bearerAuth: + type: http + scheme: bearer + +security: + - bearerAuth: [] diff --git a/src/docs/users.yaml b/src/docs/users.yaml index 99c0ab00..1e8482e2 100644 --- a/src/docs/users.yaml +++ b/src/docs/users.yaml @@ -1,10 +1,10 @@ -tags: +tags: - name: User description: The role API test paths: /api/users/signup: post: - summary: "Create a new user" + summary: 'Create a new user' tags: - User description: This create a new user @@ -86,7 +86,6 @@ paths: 500: description: Internal Server Error - /api/users/{id}: delete: summary: Delete a user @@ -137,11 +136,11 @@ paths: type: string responses: 200: - description: "Successfully Get User" + description: 'Successfully Get User' 404: - description: "User with this ID does not exits" + description: 'User with this ID does not exits' 500: - description: "Internal Server Error" + description: 'Internal Server Error' /api/users/{token}/verify-email: get: @@ -155,34 +154,10 @@ paths: type: string responses: 201: - description: "Account verified, Login to continue." + description: 'Account verified, Login to continue.' 400: - description: "Verification failed. Try again later" + description: 'Verification failed. Try again later' 403: - description: "Verification link has expired. Please request a new one." - 500: - description: "Internal Server Error" - - /api/users/resend-verify: - post: - summary: Endpoint for resend link to verify your email - tags: - - User - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - email: - type: string - responses: - 201: - description: Check your email to verify. - 202: - description: User is already verified. Login to continue - 400: - description: Email is already used, Login to continuue + description: 'Verification link has expired. Please request a new one.' 500: - description: "Internal Server Error" \ No newline at end of file + description: 'Internal Server Error' diff --git a/src/helpers/claudinary.ts b/src/helpers/claudinary.ts index c00482e5..91e12fbe 100644 --- a/src/helpers/claudinary.ts +++ b/src/helpers/claudinary.ts @@ -10,15 +10,19 @@ cloudinary.config({ const uploadImage = async (imageData: Buffer): Promise => { const base64Image = imageData.toString('base64'); return new Promise((resolve, reject) => { - cloudinary.uploader.upload(`data:image/png;base64,${base64Image}`, { public_id: 'user_image' }, (error, result) => { - if (error) { - reject(error); - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const url: any = result?.secure_url; - resolve(url); + cloudinary.uploader.upload( + `data:image/png;base64,${base64Image}`, + { public_id: String(Date.now()) }, + (error, result) => { + if (error) { + reject(error); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const url: any = result?.secure_url; + resolve(url); + } } - }); + ); }); }; export default uploadImage; diff --git a/src/middlewares/cloudinary.ts b/src/middlewares/cloudinary.ts new file mode 100644 index 00000000..095761c4 --- /dev/null +++ b/src/middlewares/cloudinary.ts @@ -0,0 +1,12 @@ +import { v2 as cloudinary } from 'cloudinary'; +import dotenv from 'dotenv'; + +dotenv.config(); + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_NAME, + api_key: process.env.CLOUDINARY_KEY, + api_secret: process.env.CLOUDINARY_SECRET, +}); + +export default cloudinary; diff --git a/src/middlewares/multer.ts b/src/middlewares/multer.ts new file mode 100644 index 00000000..d5b010e5 --- /dev/null +++ b/src/middlewares/multer.ts @@ -0,0 +1,13 @@ +import { Request } from 'express'; +import multer from 'multer'; + +const storage = multer.diskStorage({ + destination: (_req: Request, _file, cb) => { + cb(null, 'public/uploads/'); + }, + filename: (_req: Request, file, cb) => { + cb(null, file.originalname); + }, +}); + +export const upload = multer({ storage }); diff --git a/src/routes/categoryRouter.ts b/src/routes/categoryRouter.ts new file mode 100644 index 00000000..4929f2e5 --- /dev/null +++ b/src/routes/categoryRouter.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import express from 'express'; +import { createCategory, getCategory } from '../controllers/categoriesController'; +import { checkUserRoles, isAuthenticated } from '../middlewares/authMiddlewares'; + +export const categoryRouter = express.Router(); + +categoryRouter.post('/create-category', isAuthenticated, checkUserRoles('admin'), createCategory); +categoryRouter.get('/', isAuthenticated, getCategory); diff --git a/src/routes/index.ts b/src/routes/index.ts index 64663233..aa592525 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,11 +2,15 @@ import { Router } from 'express'; import userRoute from './userRoute'; import authRoute from './authRoute'; import roleRoute from './roleRoute'; +import { productRouter } from './productRoutes'; +import { categoryRouter } from './categoryRouter'; const router = Router(); router.use('/users', userRoute); router.use('/auth', authRoute); router.use('/roles', roleRoute); +router.use('/products', productRouter); +router.use('/category', categoryRouter); export default router; diff --git a/src/routes/productRoutes.ts b/src/routes/productRoutes.ts new file mode 100644 index 00000000..940bb209 --- /dev/null +++ b/src/routes/productRoutes.ts @@ -0,0 +1,15 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import express from 'express'; +import { createProduct } from '../controllers/productsController'; +import multerUpload from '../helpers/multer'; +import { checkUserRoles, isAuthenticated } from '../middlewares/authMiddlewares'; + +export const productRouter = express.Router(); + +productRouter.post( + '/:categoryId/create-product/', + isAuthenticated, + checkUserRoles('seller'), + multerUpload.array('images', 8), + createProduct +); diff --git a/src/server.ts b/src/server.ts index ca27b8db..1a8b32bf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,10 @@ -import { Application, Request, Response } from 'express'; +import type { Application, Request, Response } from 'express'; import dotenv from 'dotenv'; -import cors from 'cors'; import express from 'express'; import swaggerUI from 'swagger-ui-express'; import swaggerJsDoc from 'swagger-jsdoc'; import options from './docs/swaggerdocs'; import routes from './routes'; -import passport from './config/passport'; import logger, { errorLogger } from './logs/config'; import expressWinston from 'express-winston'; import databaseConnection from './database'; @@ -15,8 +13,6 @@ dotenv.config(); const app: Application = express(); -app.use(cors()); -app.use(passport.initialize()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); const swaggerSpec = swaggerJsDoc(options); diff --git a/src/validations/index.ts b/src/validations/index.ts index 197da309..0621f4b8 100644 --- a/src/validations/index.ts +++ b/src/validations/index.ts @@ -32,6 +32,10 @@ export const validatePassword = (password: string) => { return passwordRegex.test(password); }; +export const toLowerCase = (str: string): string => { + return str.toLowerCase(); +}; + // Function to send 500 Internal Server Error responses function sendInternalErrorResponse(res: Response, err: unknown): void { // Ensure the error is an instance of Error