diff --git a/src/controllers/categoriesController.ts b/src/controllers/categoriesController.ts new file mode 100644 index 00000000..49b00bf2 --- /dev/null +++ b/src/controllers/categoriesController.ts @@ -0,0 +1,19 @@ +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; + await Category.create({ + name, + description, + }); + res.status(201).json({ ok: true, message: 'New category created successully!' }); + } catch (error) { + if (error instanceof Error) { + logger.error(error.message); + } + res.status(500).json({ error: 'Failed to create category' }); + } +}; diff --git a/src/controllers/productsController.ts b/src/controllers/productsController.ts new file mode 100644 index 00000000..61464c1b --- /dev/null +++ b/src/controllers/productsController.ts @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Request, Response } from 'express'; +import uploadImage from '../helpers/claudinary'; +import { sendInternalErrorResponse } from '../validations'; +import { Product, ProductAttributes } from '../database/models/Product'; +import { Size, SizeAttributes } from '../database/models/Size'; + +export const createProduct = async (req: Request, res: Response) => { + try { + const { name, description, colors } = req.body as ProductAttributes; + const { categoryId } = req.params; + const seller = (await req.user) as any; + const sellerId = seller.id; + + // when products exists + const thisProductExists = await Product.findOne({ where: { name } }); + + if (thisProductExists) { + return res.status(400).json({ + ok: false, + message: 'This Product already exists, You can update the stock levels instead.', + data: thisProductExists, + }); + } + // handle images + const productImages = ['asdf', 'asdf', 'asdf', 'asdf']; + 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({ + message: 'Product should have at least 4 images', + }); + } + + // create product + await Product.create({ + sellerId, + name, + description, + categoryId, + colors, + images: productImages, + }); + + res.status(201).json({ + ok: true, + message: 'Thank you for adding new product in the store!', + }); + } catch (error) { + sendInternalErrorResponse(res, error); + } +}; + +export const createSize = async (req: Request, res: Response) => { + try { + const { productId } = req.params; + const { size, price, discount, expiryDate } = req.body as SizeAttributes; + await Size.create({ size, price, discount, expiryDate, productId }); + res.status(201).json({ + ok: true, + message: 'Product size added successfully', + }); + } catch (error) { + sendInternalErrorResponse(res, error); + } +}; diff --git a/src/database/migrations/20240426195145-create-category.js b/src/database/migrations/20240426195145-create-category.js new file mode 100644 index 00000000..3f39ece2 --- /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: true, + }, + 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..27510b60 --- /dev/null +++ b/src/database/migrations/20240429115230-create-product.js @@ -0,0 +1,68 @@ +/* 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, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + description: { + type: Sequelize.TEXT, + allowNull: false, + }, + images: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: false, + }, + colors: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: true, + }, + sellerId: { + type: Sequelize.UUID, + references: { + model: 'Users', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + allowNull: false, + }, + categoryId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: 'categories', + key: 'id', + }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE', + }, + 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('product_sizes'); + await queryInterface.dropTable('products'); + }, +}; diff --git a/src/database/migrations/20240501004030-create-size.js b/src/database/migrations/20240501004030-create-size.js new file mode 100644 index 00000000..769f319e --- /dev/null +++ b/src/database/migrations/20240501004030-create-size.js @@ -0,0 +1,61 @@ +/* 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('sizes', { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: sequelize.UUIDV4, + }, + size: { + type: Sequelize.STRING, + allowNull: true, + }, + price: { + type: Sequelize.FLOAT, + allowNull: false, + }, + quantity: { + type: Sequelize.INTEGER, + defaultValue: 1, + }, + discount: { + type: Sequelize.FLOAT, + allowNull: true, + }, + expiryDate: { + type: Sequelize.DATE, + allowNull: true, + }, + productId: { + type: Sequelize.UUID, + references: { + model: 'products', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + 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('sizes'); + }, +}; diff --git a/src/database/models/Category.ts b/src/database/models/Category.ts new file mode 100644 index 00000000..02aa27c3 --- /dev/null +++ b/src/database/models/Category.ts @@ -0,0 +1,42 @@ +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; + public sizes!: 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..09883a6e --- /dev/null +++ b/src/database/models/Product.ts @@ -0,0 +1,78 @@ +import { Model, DataTypes } from 'sequelize'; +import sequelize from './index'; +import { UUIDV4 } from 'sequelize'; +import { Category } from './Category'; +import { Size } from './Size'; + +export interface ProductAttributes { + id?: string; + sellerId: string; + name: string; + description: string; + images: string[]; + colors?: string[]; + categoryId: string; + createdAt?: Date; + updatedAt?: Date; +} + +export class Product extends Model implements ProductAttributes { + public id!: string; + public sellerId!: string; + public name!: string; + public description!: string; + public categoryId!: string; + public images!: string[]; + public colors!: string[]; + public readonly createdAt!: Date | undefined; + public readonly updatedAt!: Date | undefined; +} + +Product.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + unique: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.TEXT, + allowNull: false, + }, + colors: { + type: DataTypes.ARRAY(DataTypes.STRING), + allowNull: true, + }, + images: { + type: DataTypes.ARRAY(DataTypes.STRING), + allowNull: false, + }, + sellerId: { + type: DataTypes.UUID, + references: { + model: 'Users', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + allowNull: false, + }, + categoryId: { + type: DataTypes.UUID, + references: { + model: 'Category', + key: 'id', + }, + }, + }, + { sequelize: sequelize, timestamps: true, modelName: 'Product', tableName: 'products' } +); + +Product.belongsTo(Category, { foreignKey: 'categoryId' }); + +Product.hasMany(Size, { foreignKey: 'productId' }); diff --git a/src/database/models/Size.ts b/src/database/models/Size.ts new file mode 100644 index 00000000..6d053653 --- /dev/null +++ b/src/database/models/Size.ts @@ -0,0 +1,72 @@ +import { Model, Optional, DataTypes, UUIDV4 } from 'sequelize'; +import sequelize from './index'; + +export interface SizeAttributes { + id: number; + size?: string; + price: number; + quantity?: number; + discount?: number; + expiryDate?: Date; + productId: string; +} + +export interface SizeCreationAttributes extends Optional {} + +export class Size extends Model implements SizeAttributes { + public id!: number; + public size!: string; + public price!: number; + public quantity!: number; + public discount!: number; + public expiryDate!: Date; + public productId!: string; +} + +Size.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + autoIncrement: true, + }, + size: { + type: DataTypes.STRING, + allowNull: true, + }, + price: { + type: DataTypes.FLOAT, + allowNull: false, + }, + quantity: { + type: DataTypes.INTEGER, + defaultValue: 1, + }, + discount: { + type: DataTypes.FLOAT, + allowNull: true, + defaultValue: 1, + }, + expiryDate: { + type: DataTypes.DATE, + allowNull: true, + }, + productId: { + type: DataTypes.UUID, + references: { + model: 'products', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + allowNull: false, + }, + }, + { + sequelize, + modelName: 'Size', + tableName: 'sizes', + timestamps: true, + } +); 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..fe1c1301 --- /dev/null +++ b/src/database/seeders/20240429200629-add-seller-role.js @@ -0,0 +1,22 @@ +'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/docs/docs.yaml b/src/docs/docs.yaml new file mode 100644 index 00000000..a632348f --- /dev/null +++ b/src/docs/docs.yaml @@ -0,0 +1,124 @@ +tags: + - name: Product + description: Operations related to products + +paths: + /api/{categoryId}/products: + post: + summary: Create a new product + tags: + - Product + description: Create a new product in the store + parameters: + - in: path + name: categoryId + required: true + schema: + type: string + description: ID of the category to which the product belongs + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + name *: + type: string + description: Name of the product to which the product belongs + sellerId *: + type: string + description: Name of the seller to which the product belongs to + description *: + type: string + description: Description to which the product + price *: + type: number + description: Price of the product + quantity: + type: number + description: Quantity of the product(by default it's 1) + expiryDate: + type: string + format: date + description: For products that might end + images *: + type: array + items: + type: string + format: binary + description: Array of 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 diff --git a/src/docs/products.yaml b/src/docs/products.yaml new file mode 100644 index 00000000..a632348f --- /dev/null +++ b/src/docs/products.yaml @@ -0,0 +1,124 @@ +tags: + - name: Product + description: Operations related to products + +paths: + /api/{categoryId}/products: + post: + summary: Create a new product + tags: + - Product + description: Create a new product in the store + parameters: + - in: path + name: categoryId + required: true + schema: + type: string + description: ID of the category to which the product belongs + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + name *: + type: string + description: Name of the product to which the product belongs + sellerId *: + type: string + description: Name of the seller to which the product belongs to + description *: + type: string + description: Description to which the product + price *: + type: number + description: Price of the product + quantity: + type: number + description: Quantity of the product(by default it's 1) + expiryDate: + type: string + format: date + description: For products that might end + images *: + type: array + items: + type: string + format: binary + description: Array of 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 diff --git a/src/routes/categoryRouter.ts b/src/routes/categoryRouter.ts new file mode 100644 index 00000000..9853930b --- /dev/null +++ b/src/routes/categoryRouter.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import express from 'express'; +import { checkUserRoles, isAuthenticated } from '../middlewares/authMiddlewares'; +import { createCategory } from '../controllers/categoriesController'; + +export const categoryRouter = express.Router(); + +categoryRouter.post('/create-category', isAuthenticated, checkUserRoles('admin'), createCategory); diff --git a/src/routes/index.ts b/src/routes/index.ts index 64663233..26b69b2e 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,12 +1,16 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ 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..aadb4dc8 --- /dev/null +++ b/src/routes/productRoutes.ts @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +import express from 'express'; +import { createProduct, createSize } 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 +); + +productRouter.post('/:productId/add-size', isAuthenticated, checkUserRoles('seller'), createSize);