Browse Source

Feature: Implement product management actions and routes

BedirhanOZCAN 1 tháng trước cách đây
mục cha
commit
434597dfd1

+ 5 - 5
src/actions/auth/refreshToken/index.ts

@@ -1,18 +1,18 @@
 import jwt from "jsonwebtoken";
 import redis from "../../../config/redis";
 import {
-    User 
+    User
 } from "../../../models/User";
 import {
     RefreshTokenInput,
-    RefreshTokenResult 
+    RefreshTokenResult
 } from "./types";
 
 const refreshToken = async (input: RefreshTokenInput): Promise<RefreshTokenResult> => {
     const token = input.refreshToken!;
     try {
         const user = await User.findOne({
-            refreshToken: token 
+            refreshToken: token
         });
         if (!user) {
             return {
@@ -51,7 +51,7 @@ const refreshToken = async (input: RefreshTokenInput): Promise<RefreshTokenResul
             },
             process.env.JWT_SECRET as string,
             {
-                expiresIn: "4h" 
+                expiresIn: "4h"
             }
         );
 
@@ -64,7 +64,7 @@ const refreshToken = async (input: RefreshTokenInput): Promise<RefreshTokenResul
             },
             process.env.JWT_SECRET as string,
             {
-                expiresIn: "30d" 
+                expiresIn: "30d"
             }
         );
 

+ 13 - 13
src/actions/auth/register/index.ts

@@ -1,38 +1,38 @@
 import {
-    User 
+    User
 } from "../../../models/User";
 import {
     RegisterInput,
-    RegisterResult 
+    RegisterResult
 } from "./types";
 
 const register = async (input: RegisterInput): Promise<RegisterResult> => {
     const {
         companyName,
         phoneNumber,
-        firstName, 
-        lastName, 
-        password, 
-        mail 
+        firstName,
+        lastName,
+        password,
+        mail
     } = input;
 
     const existingUser = await User.findOne({
-        mail 
+        mail
     });
     if (existingUser) {
         return {
-            message: "email-already-in-use", 
-            code: 409 
+            message: "email-already-in-use",
+            code: 409
         };
     }
 
     const existingPhone = await User.findOne({
-        phoneNumber 
+        phoneNumber
     });
     if (existingPhone) {
         return {
-            message: "phone-already-in-use", 
-            code: 409 
+            message: "phone-already-in-use",
+            code: 409
         };
     }
 
@@ -48,7 +48,7 @@ const register = async (input: RegisterInput): Promise<RegisterResult> => {
 
     return {
         message: "registration-successful",
-        code: 201 
+        code: 201
     };
 };
 

+ 51 - 0
src/actions/menu/addProduct/index.ts

@@ -0,0 +1,51 @@
+import {
+    Product
+} from "../../../models/Product";
+import {
+    AddProductResult
+} from "./types";
+
+const addProduct = async (body: any, context: { userID: string }): Promise<AddProductResult> => {
+    try {
+        const {
+            coverPhotoPath,
+            description,
+            isAvailable,
+            categoryID,
+            isActive,
+            title,
+            price,
+        } = body;
+
+        const {
+            userID
+        } = context;
+
+        const newProduct = await Product.create({
+            coverPhotoPath,
+            description,
+            isAvailable,
+            categoryID,
+            isActive,
+            title,
+            price,
+            userID: userID,
+        });
+
+        return {
+            message: "product-created-successfully",
+            code: 201,
+            payload: {
+                product: newProduct
+            }
+        };
+    } catch (error) {
+        console.error("AddProduct action error:", error);
+        return {
+            message: "internal-server-error",
+            code: 500
+        };
+    }
+};
+
+export default addProduct;

+ 46 - 0
src/actions/menu/addProduct/types.ts

@@ -0,0 +1,46 @@
+import {
+    IsNotEmpty,
+    IsOptional,
+    IsBoolean,
+    IsString,
+    IsNumber,
+    Min
+} from "class-validator";
+
+
+export class AddProductInput {
+    @IsString()
+    @IsNotEmpty({ message: "categoryID-is-required" })
+    categoryID!: string;
+
+    @IsString()
+    @IsNotEmpty({ message: "title-is-required" })
+    title!: string;
+
+    @IsString()
+    @IsOptional()
+    description?: string;
+
+    @Min(0, { message: "price-cannot-be-negative" })
+    @IsNumber()
+    @IsNotEmpty()
+    price!: number;
+
+    @IsString()
+    @IsOptional()
+    coverPhotoPath?: string;
+
+    @IsBoolean()
+    @IsOptional()
+    isActive?: boolean;
+
+    @IsBoolean()
+    @IsOptional()
+    isAvailable?: boolean;
+}
+
+export type AddProductResult = {
+    message: string;
+    code: number;
+    payload?: any;
+};

+ 44 - 0
src/actions/menu/deleteProduct/index.ts

@@ -0,0 +1,44 @@
+import {
+    Product
+} from "../../../models/Product";
+import {
+    DeleteProductResult
+} from "./types";
+
+const deleteProduct = async (body: any, context: { userID: string }): Promise<DeleteProductResult> => {
+    try {
+        const {
+            productID
+        } = body;
+
+        const {
+            userID
+        } = context;
+
+        const deletedProduct = await Product.findOneAndDelete({
+            _id: productID,
+            userID: userID
+        });
+
+        if (!deletedProduct) {
+            return {
+                message: "product-not-found-or-unauthorized",
+                code: 404
+            };
+        }
+
+        return {
+            message: "product-deleted-successfully",
+            code: 200
+        };
+
+    } catch (error) {
+        console.error("DeleteProduct action error:", error);
+        return {
+            message: "internal-server-error",
+            code: 500
+        };
+    }
+};
+
+export default deleteProduct;

+ 16 - 0
src/actions/menu/deleteProduct/types.ts

@@ -0,0 +1,16 @@
+import {
+    IsNotEmpty,
+    IsString
+} from "class-validator";
+
+export class DeleteProductInput {
+    @IsString()
+    @IsNotEmpty({ message: "productID-is-required" })
+    productID!: string;
+}
+
+export type DeleteProductResult = {
+    message: string;
+    code: number;
+    payload?: any;
+};

+ 77 - 0
src/actions/menu/getProducts/index.ts

@@ -0,0 +1,77 @@
+import mongoose from "mongoose";
+import {
+    Product
+} from "../../../models/Product";
+import {
+    GetProductsResult,
+    GetProductsInput
+} from "./types";
+
+const getProducts = async (query: GetProductsInput, context: { userID: string }): Promise<GetProductsResult> => {
+    try {
+        const {
+            userID
+        } = context;
+        const {
+            categoryID
+        } = query;
+
+        const matchStage: any = {
+            userID: new mongoose.Types.ObjectId(userID)
+        };
+
+        if (categoryID) {
+            matchStage.categoryID = new mongoose.Types.ObjectId(categoryID);
+        }
+
+        const products = await Product.aggregate([
+            {
+                $match: matchStage
+            },
+            {
+                $lookup: {
+                    from: "categories",
+                    localField: "categoryID",
+                    foreignField: "_id",
+                    as: "categoryID"
+                }
+            },
+            {
+                $unwind: {
+                    path: "$categoryID",
+                    preserveNullAndEmptyArrays: true
+                }
+            },
+            {
+                $addFields: {
+                    categoryID: {
+                        _id: "$categoryID._id",
+                        title: "$categoryID.title",
+                        isActive: "$categoryID.isActive"
+                    }
+                }
+            },
+            {
+                $sort: {
+                    createdAt: -1
+                }
+            }
+        ]);
+
+        return {
+            message: "products-retrieved-successfully",
+            code: 200,
+            payload: {
+                products
+            }
+        };
+    } catch (error) {
+        console.error("GetProducts action error:", error);
+        return {
+            message: "internal-server-error",
+            code: 500
+        };
+    }
+};
+
+export default getProducts;

+ 16 - 0
src/actions/menu/getProducts/types.ts

@@ -0,0 +1,16 @@
+import {
+    IsOptional,
+    IsString
+} from "class-validator";
+
+export class GetProductsInput {
+    @IsString()
+    @IsOptional()
+    categoryID?: string;
+}
+
+export type GetProductsResult = {
+    message: string;
+    code: number;
+    payload?: any;
+};

+ 12 - 0
src/actions/menu/index.ts

@@ -0,0 +1,12 @@
+export {
+    default as addProduct
+} from "./addProduct";
+export {
+    default as getProducts
+} from "./getProducts";
+export {
+    default as updateProduct
+} from "./updateProduct";
+export {
+    default as deleteProduct
+} from "./deleteProduct";

+ 9 - 0
src/actions/menu/types/index.ts

@@ -0,0 +1,9 @@
+export {
+    AddProductInput
+} from "../addProduct/types";
+export {
+    UpdateProductInput
+} from "../updateProduct/types";
+export {
+    DeleteProductInput
+} from "../deleteProduct/types";

+ 106 - 0
src/actions/menu/updateProduct/index.ts

@@ -0,0 +1,106 @@
+import mongoose from "mongoose";
+import {
+    Product
+} from "../../../models/Product";
+import {
+    UpdateProductResult
+} from "./types";
+
+const updateProduct = async (body: any, context: { userID: string }): Promise<UpdateProductResult> => {
+    try {
+        const {
+            coverPhotoPath,
+            description,
+            isAvailable,
+            categoryID,
+            productID,
+            isActive,
+            price,
+            title,
+        } = body;
+
+        const {
+            userID
+        } = context;
+
+        const updateData: any = {
+        };
+
+        if (categoryID !== undefined) updateData.categoryID = new mongoose.Types.ObjectId(categoryID);
+
+        if (title !== undefined) updateData.title = title;
+        if (description !== undefined) updateData.description = description;
+        if (price !== undefined) updateData.price = price;
+        if (coverPhotoPath !== undefined) updateData.coverPhotoPath = coverPhotoPath;
+        if (isActive !== undefined) updateData.isActive = isActive;
+        if (isAvailable !== undefined) updateData.isAvailable = isAvailable;
+
+        const updatedDoc = await Product.findOneAndUpdate(
+            {
+                _id: new mongoose.Types.ObjectId(productID),
+                userID: new mongoose.Types.ObjectId(userID)
+            },
+            {
+                $set: updateData
+            },
+            {
+                new: true
+            }
+        );
+
+        if (!updatedDoc) {
+            return {
+                message: "product-not-found-or-unauthorized",
+                code: 404
+            };
+        }
+
+        const aggregatedProduct = await Product.aggregate([
+            {
+                $match: {
+                    _id: new mongoose.Types.ObjectId(productID)
+                }
+            },
+            {
+                $lookup: {
+                    from: "categories",
+                    localField: "categoryID",
+                    foreignField: "_id",
+                    as: "categoryID"
+                }
+            },
+            {
+                $unwind: {
+                    path: "$categoryID",
+                    preserveNullAndEmptyArrays: true
+                }
+            },
+            {
+                $addFields: {
+                    categoryID: {
+                        _id: "$categoryID._id",
+                        title: "$categoryID.title",
+                        isActive: "$categoryID.isActive"
+                    }
+                }
+            }
+        ]);
+
+        return {
+            message: "product-updated-successfully",
+            code: 200,
+            payload: {
+                product: aggregatedProduct[0]
+            }
+        };
+
+    } catch (error) {
+        console.error("UpdateProduct action error:", error);
+        return {
+            message: "internal-server-error",
+            code: 500
+        };
+    }
+};
+
+export default updateProduct;

+ 50 - 0
src/actions/menu/updateProduct/types.ts

@@ -0,0 +1,50 @@
+import {
+    IsNotEmpty,
+    IsOptional,
+    IsBoolean,
+    IsString,
+    IsNumber,
+    Min
+} from "class-validator";
+
+export class UpdateProductInput {
+    @IsString()
+    @IsNotEmpty({ message: "productID-is-required" })
+    productID!: string;
+
+    @IsString()
+    @IsOptional()
+    categoryID?: string;
+
+    @IsString()
+    @IsOptional()
+    title?: string;
+
+    @IsString()
+    @IsOptional()
+    description?: string;
+
+    @Min(0, { message: "price-cannot-be-negative" })
+    @IsNumber()
+    @IsOptional()
+    price?: number;
+
+    @IsString()
+    @IsOptional()
+    coverPhotoPath?: string;
+
+    @IsBoolean()
+    @IsOptional()
+    isActive?: boolean;
+
+    @IsBoolean()
+    @IsOptional()
+    isAvailable?: boolean;
+
+}
+
+export type UpdateProductResult = {
+    message: string;
+    code: number;
+    payload?: any;
+};

+ 92 - 0
src/controllers/menuController.ts

@@ -0,0 +1,92 @@
+import {
+    Response
+} from "express";
+import {
+    updateProduct as _updateProduct,
+    deleteProduct as _deleteProduct,
+    getProducts as _getProducts,
+    addProduct as _addProduct
+} from "../actions/menu";
+import {
+    AuthRequest
+} from "../middlewares/authMiddleware";
+
+export const addProduct = async (req: AuthRequest, res: Response): Promise<void> => {
+    try {
+        const result = await _addProduct(req.body, req.context!);
+
+        res.status(result.code).json({
+            message: result.message,
+            code: result.code,
+            ...(result.payload && {
+                payload: result.payload
+            })
+        });
+    } catch (error) {
+        console.error("AddProduct controller error:", error);
+        res.status(500).json({
+            message: "server-error",
+            code: 500
+        });
+    }
+};
+
+export const getProducts = async (req: AuthRequest, res: Response): Promise<void> => {
+    try {
+        const result = await _getProducts(req.query, req.context!);
+
+        res.status(result.code).json({
+            message: result.message,
+            code: result.code,
+            ...(result.payload && {
+                payload: result.payload
+            })
+        });
+    } catch (error) {
+        console.error("GetProducts controller error:", error);
+        res.status(500).json({
+            message: "server-error",
+            code: 500
+        });
+    }
+};
+
+export const updateProduct = async (req: AuthRequest, res: Response): Promise<void> => {
+    try {
+        const result = await _updateProduct(req.body, req.context!);
+
+        res.status(result.code).json({
+            message: result.message,
+            code: result.code,
+            ...(result.payload && {
+                payload: result.payload
+            })
+        });
+    } catch (error) {
+        console.error("UpdateProduct controller error:", error);
+        res.status(500).json({
+            message: "server-error",
+            code: 500
+        });
+    }
+};
+
+export const deleteProduct = async (req: AuthRequest, res: Response): Promise<void> => {
+    try {
+        const result = await _deleteProduct(req.body, req.context!);
+
+        res.status(result.code).json({
+            message: result.message,
+            code: result.code,
+            ...(result.payload && {
+                payload: result.payload
+            })
+        });
+    } catch (error) {
+        console.error("DeleteProduct controller error:", error);
+        res.status(500).json({
+            message: "server-error",
+            code: 500
+        });
+    }
+};

+ 13 - 11
src/middlewares/authMiddleware.ts

@@ -1,5 +1,7 @@
 import {
-    Request, Response, NextFunction
+    NextFunction,
+    Response,
+    Request
 } from "express";
 import jwt from "jsonwebtoken";
 import redis from "../config/redis";
@@ -28,24 +30,24 @@ export const authMiddleware = async (req: AuthRequest, res: Response, next: Next
             return;
         }
 
-        let decoded: { 
+        let decoded: {
             companyName: string;
             fullName: string;
             userID: string;
-            token:string;
+            token: string;
         };
-        
+
         try {
-            decoded = jwt.verify(token, process.env.JWT_SECRET as string) as { 
+            decoded = jwt.verify(token, process.env.JWT_SECRET as string) as {
                 companyName: string;
                 fullName: string;
                 userID: string,
-                token:string;
+                token: string;
             };
         } catch (err) {
             res.status(401)
                 .json({
-                    message: "expired-token", 
+                    message: "expired-token",
                     code: 401
                 });
             return;
@@ -54,7 +56,7 @@ export const authMiddleware = async (req: AuthRequest, res: Response, next: Next
         if (!decoded || !decoded.userID) {
             res.status(401)
                 .json({
-                    message: "invalid-token", 
+                    message: "invalid-token",
                     code: 401
                 });
             return;
@@ -64,7 +66,7 @@ export const authMiddleware = async (req: AuthRequest, res: Response, next: Next
         if (!cachedToken) {
             res.status(401)
                 .json({
-                    message: "expired-token", 
+                    message: "expired-token",
                     code: 401
                 });
             return;
@@ -83,7 +85,7 @@ export const authMiddleware = async (req: AuthRequest, res: Response, next: Next
         if (!user) {
             res.status(401)
                 .json({
-                    message: "user-not-found", 
+                    message: "user-not-found",
                     code: 401
                 });
             return;
@@ -100,7 +102,7 @@ export const authMiddleware = async (req: AuthRequest, res: Response, next: Next
     } catch (error) {
         res.status(401)
             .json({
-                message: "invalid-token", 
+                message: "invalid-token",
                 code: 401
             });
     }

+ 11 - 7
src/middlewares/validateBody.ts

@@ -1,27 +1,31 @@
 import {
-    plainToInstance 
+    plainToInstance
 } from "class-transformer";
 import {
-    validate 
+    validate
 } from "class-validator";
 import {
-    Request,
+    NextFunction,
     Response,
-    NextFunction 
+    Request
 } from "express";
 import {
-    formatValidationErrors 
+    formatValidationErrors
 } from "../utils";
 
 export const validateBody = (dto: any) => {
     return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
         const instance = plainToInstance(dto, req.body);
-        const errors = await validate(instance);
+
+        const errors = await validate(instance, {
+            whitelist: true,
+            forbidNonWhitelisted: false
+        });
 
         if (errors.length > 0) {
             res.status(400).json({
                 message: formatValidationErrors(errors),
-                code: 400 
+                code: 400
             });
             return;
         }

+ 60 - 0
src/models/Product.ts

@@ -0,0 +1,60 @@
+import mongoose, {
+    Document,
+    Schema
+} from "mongoose";
+
+export interface IProduct extends Document {
+    categoryID: mongoose.Types.ObjectId;
+    userID: mongoose.Types.ObjectId;
+    title: string;
+    description: string;
+    price: number;
+    coverPhotoPath: string;
+    isActive: boolean;
+    isAvailable: boolean;
+}
+
+const productSchema = new Schema<IProduct>(
+    {
+        categoryID: {
+            type: mongoose.Schema.Types.ObjectId,
+            index: true
+        },
+        userID: {
+            type: mongoose.Schema.Types.ObjectId,
+            index: true
+        },
+        title: {
+            type: String,
+            required: true,
+            trim: true
+        },
+        description: {
+            type: String,
+            default: "",
+            trim: true
+        },
+        price: {
+            type: Number,
+            required: true,
+            min: 0
+        },
+        coverPhotoPath: {
+            type: String,
+            default: ""
+        },
+        isActive: {
+            type: Boolean,
+            default: true
+        },
+        isAvailable: {
+            type: Boolean,
+            default: true
+        },
+    },
+    {
+        timestamps: true,
+    }
+);
+
+export const Product = mongoose.models.Product || mongoose.model<IProduct>("Product", productSchema);

+ 3 - 1
src/routes/index.ts

@@ -1,10 +1,12 @@
 import {
-    Router 
+    Router
 } from "express";
 import authRoutes from "./authRoutes";
+import menuRoutes from "./menuRoutes";
 
 const router = Router();
 
 router.use("/auth", authRoutes);
+router.use("/menu", menuRoutes);
 
 export default router;

+ 29 - 0
src/routes/menuRoutes.ts

@@ -0,0 +1,29 @@
+import {
+    Router
+} from "express";
+import {
+    deleteProduct,
+    updateProduct,
+    getProducts,
+    addProduct
+} from "../controllers/menuController";
+import {
+    authMiddleware
+} from "../middlewares/authMiddleware";
+import {
+    validateBody
+} from "../middlewares/validateBody";
+import {
+    UpdateProductInput,
+    DeleteProductInput,
+    AddProductInput
+} from "../actions/menu/types";
+
+const router = Router();
+
+router.delete("/deleteProduct", authMiddleware, validateBody(DeleteProductInput), deleteProduct);
+router.put("/updateProduct", authMiddleware, validateBody(UpdateProductInput), updateProduct);
+router.post("/addProduct", authMiddleware, validateBody(AddProductInput), addProduct);
+router.get("/getProducts", authMiddleware, getProducts);
+
+export default router;