From da428f83bc127f26c8c108bd963d41de05d45a14 Mon Sep 17 00:00:00 2001 From: andwati Date: Tue, 13 Jan 2026 00:03:48 +0300 Subject: [PATCH] feat: implement order and product models, services, and routes with validation --- package.json | 3 +- pnpm-lock.yaml | 9 ++ src/app.ts | 8 +- src/controllers/order.controller.ts | 54 ++++++++++ src/middleware/validate.middleware.ts | 16 +++ src/models/order.model.ts | 78 ++++++++++++++ src/models/product.model.ts | 40 +++++++ src/models/user.model.ts | 43 ++++++++ src/routes/auth.routes.ts | 23 ++++ src/routes/order.routes.ts | 37 +++++++ src/routes/product.routes.ts | 31 ++++++ src/services/order.service.ts | 146 ++++++++++++++++++++++++++ src/validation/product.schema.ts | 22 ++++ 13 files changed, 505 insertions(+), 5 deletions(-) create mode 100644 src/middleware/validate.middleware.ts diff --git a/package.json b/package.json index 7d86c06..76b53fb 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "express": "^5.2.1", "express-rate-limit": "^8.2.1", "mongoose": "^9.1.3", - "morgan": "^1.10.1" + "morgan": "^1.10.1", + "uuid": "^13.0.0" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14ae44e..0522dcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: morgan: specifier: ^1.10.1 version: 1.10.1 + uuid: + specifier: ^13.0.0 + version: 13.0.0 devDependencies: '@eslint/js': specifier: ^9.39.2 @@ -1294,6 +1297,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2797,6 +2804,8 @@ snapshots: dependencies: punycode: 2.3.1 + uuid@13.0.0: {} + v8-compile-cache-lib@3.0.1: {} vary@1.1.2: {} diff --git a/src/app.ts b/src/app.ts index e12f417..52acc4a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,10 +3,10 @@ import type { Application } from "express"; import cors from "cors"; import morgan from "morgan"; -import { errorHandler } from "./middleware/error.middleware"; -import authRoutes from "./routes/auth.routes"; -import productRoutes from "./routes/product.routes"; -import orderRoutes from "./routes/order.routes"; +import { errorHandler } from "./middleware/error.middleware.js"; +import { authRoutes } from "./routes/auth.routes.js"; +import productRoutes from "./routes/product.routes.js"; +import orderRoutes from "./routes/order.routes.js"; const app: Application = express(); diff --git a/src/controllers/order.controller.ts b/src/controllers/order.controller.ts index e69de29..640dc06 100644 --- a/src/controllers/order.controller.ts +++ b/src/controllers/order.controller.ts @@ -0,0 +1,54 @@ +import { Request, Response, NextFunction } from "express"; +import { OrderService } from "../services/order.service"; + +export class OrderController { + static async create(req: Request, res: Response, next: NextFunction) { + try { + const userId = req.user!.sub; + const { items } = req.body; + + const order = await OrderService.createOrder(userId, items); + + res.status(201).json(order); + } catch (err) { + next(err); + } + } + + static async list(req: Request, res: Response, next: NextFunction) { + try { + const userId = req.user!.sub; + const role = req.user!.role; + + const orders = await OrderService.listOrders(userId, role); + + res.status(200).json(orders); + } catch (err) { + next(err); + } + } + + static async pay(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + + const order = await OrderService.payOrder(id); + + res.status(200).json(order); + } catch (err) { + next(err); + } + } + + static async cancel(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + + const order = await OrderService.cancelOrder(id); + + res.status(200).json(order); + } catch (err) { + next(err); + } + } +} diff --git a/src/middleware/validate.middleware.ts b/src/middleware/validate.middleware.ts new file mode 100644 index 0000000..a7cab0a --- /dev/null +++ b/src/middleware/validate.middleware.ts @@ -0,0 +1,16 @@ +import { ZodSchema } from "zod"; +import { Request, Response, NextFunction } from "express"; + +export const validate = + (schema: ZodSchema) => (req: Request, _res: Response, next: NextFunction) => { + try { + schema.parse({ + body: req.body, + params: req.params, + query: req.query, + }); + next(); + } catch (err) { + next(err); + } + }; diff --git a/src/models/order.model.ts b/src/models/order.model.ts index e69de29..064c971 100644 --- a/src/models/order.model.ts +++ b/src/models/order.model.ts @@ -0,0 +1,78 @@ +import { Schema, model } from "mongoose"; +import { v4 as uuidv4 } from "uuid"; + +export type OrderStatus = "created" | "paid" | "cancelled"; + +export interface OrderItem { + productId: string; + quantity: number; + unitPrice: number; +} + +export interface OrderDocument { + _id: string; + userId: string; + items: OrderItem[]; + total: number; + status: OrderStatus; + createdAt: Date; + updatedAt: Date; +} + +const orderItemSchema = new Schema( + { + productId: { + type: String, + required: true, + }, + quantity: { + type: Number, + required: true, + min: 1, + }, + unitPrice: { + type: Number, + required: true, + min: 1, + }, + }, + { _id: false }, +); + +const orderSchema = new Schema( + { + _id: { + type: String, + default: uuidv4, + }, + userId: { + type: String, + required: true, + index: true, + }, + items: { + type: [orderItemSchema], + required: true, + validate: { + validator: (items: OrderItem[]) => items.length > 0, + message: "Order must contain at least one item", + }, + }, + total: { + type: Number, + required: true, + min: 1, + }, + status: { + type: String, + enum: ["created", "paid", "cancelled"], + default: "created", + index: true, + }, + }, + { + timestamps: true, + }, +); + +export const OrderModel = model("Order", orderSchema); diff --git a/src/models/product.model.ts b/src/models/product.model.ts index e69de29..1621642 100644 --- a/src/models/product.model.ts +++ b/src/models/product.model.ts @@ -0,0 +1,40 @@ +import { Schema, model } from "mongoose"; +import { v4 as uuidv4 } from "uuid"; + +export interface ProductDocument { + _id: string; + name: string; + price: number; // cents + stock: number; + createdAt: Date; + updatedAt: Date; +} + +const productSchema = new Schema( + { + _id: { + type: String, + default: uuidv4, + }, + name: { + type: String, + required: true, + trim: true, + }, + price: { + type: Number, + required: true, + min: 1, + }, + stock: { + type: Number, + required: true, + min: 0, + }, + }, + { + timestamps: true, + }, +); + +export const ProductModel = model("Product", productSchema); diff --git a/src/models/user.model.ts b/src/models/user.model.ts index e69de29..30baf97 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -0,0 +1,43 @@ +import { Schema, model } from "mongoose"; +import { v4 as uuidv4 } from "uuid"; + +export type UserRole = "admin" | "customer"; + +export interface UserDocument { + _id: string; + email: string; + passwordHash: string; + role: UserRole; + createdAt: Date; + updatedAt: Date; +} + +const userSchema = new Schema( + { + _id: { + type: String, + default: uuidv4, + }, + email: { + type: String, + required: true, + unique: true, + lowercase: true, + index: true, + }, + passwordHash: { + type: String, + required: true, + }, + role: { + type: String, + enum: ["admin", "customer"], + default: "customer", + }, + }, + { + timestamps: true, + }, +); + +export const UserModel = model("User", userSchema); diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index e69de29..c70705a 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +import { AuthController } from "../controllers/auth.controller"; +import { validate } from "../middleware/validate.middleware"; +import { authRateLimiter } from "../middleware/rateLimit.middleware"; +import { registerSchema, loginSchema } from "../validation/auth.schema"; + +const router = Router(); + +router.post( + "/register", + authRateLimiter, + validate(registerSchema), + AuthController.register, +); + +router.post( + "/login", + authRateLimiter, + validate(loginSchema), + AuthController.login, +); + +export default router; diff --git a/src/routes/order.routes.ts b/src/routes/order.routes.ts index e69de29..8589d90 100644 --- a/src/routes/order.routes.ts +++ b/src/routes/order.routes.ts @@ -0,0 +1,37 @@ +import { Router } from "express"; +import { OrderController } from "../controllers/order.controller"; +import { authMiddleware } from "../middleware/auth.middleware"; +import { requireRole } from "../middleware/role.middleware"; +import { validate } from "../middleware/validate.middleware"; +import { + createOrderSchema, + orderIdParamSchema, +} from "../validation/order.schema"; + +const router = Router(); + +router.post( + "/", + authMiddleware, + requireRole("customer"), + validate(createOrderSchema), + OrderController.create, +); + +router.get("/", authMiddleware, OrderController.list); + +router.post( + "/:id/pay", + authMiddleware, + validate(orderIdParamSchema), + OrderController.pay, +); + +router.post( + "/:id/cancel", + authMiddleware, + validate(orderIdParamSchema), + OrderController.cancel, +); + +export default router; diff --git a/src/routes/product.routes.ts b/src/routes/product.routes.ts index e69de29..00b6543 100644 --- a/src/routes/product.routes.ts +++ b/src/routes/product.routes.ts @@ -0,0 +1,31 @@ +import { Router } from "express"; +import { ProductController } from "../controllers/product.controller"; +import { authMiddleware } from "../middleware/auth.middleware"; +import { requireRole } from "../middleware/role.middleware"; +import { validate } from "../middleware/validate.middleware"; +import { + createProductSchema, + updateProductSchema, +} from "../validation/product.schema"; + +const router = Router(); + +router.post( + "/", + authMiddleware, + requireRole("admin"), + validate(createProductSchema), + ProductController.create, +); + +router.patch( + "/:id", + authMiddleware, + requireRole("admin"), + validate(updateProductSchema), + ProductController.update, +); + +router.get("/", ProductController.list); + +export default router; diff --git a/src/services/order.service.ts b/src/services/order.service.ts index e69de29..3ec7c7a 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -0,0 +1,146 @@ +import mongoose from "mongoose"; +import { ProductModel } from "../models/product.model"; +import { OrderModel } from "../models/order.model"; + +export class OrderService { + static async createOrder( + userId: string, + items: { productId: string; quantity: number }[], + ) { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const productIds = items.map((i) => i.productId); + + const products = await ProductModel.find({ + _id: { $in: productIds }, + }).session(session); + + if (products.length !== productIds.length) { + throw { statusCode: 400, message: "One or more products not found" }; + } + + const orderItems = []; + let total = 0; + + for (const item of items) { + const product = products.find((p) => p._id === item.productId)!; + + if (item.quantity <= 0) { + throw { + statusCode: 400, + message: "Quantity must be greater than zero", + }; + } + + if (product.stock < item.quantity) { + throw { + statusCode: 409, + message: `Insufficient stock for product ${product._id}`, + }; + } + + product.stock -= item.quantity; + await product.save({ session }); + + orderItems.push({ + productId: product._id, + quantity: item.quantity, + unitPrice: product.price, + }); + + total += product.price * item.quantity; + } + + const order = await OrderModel.create( + [ + { + userId, + items: orderItems, + total, + status: "created", + }, + ], + { session }, + ); + + await session.commitTransaction(); + return order[0]; + } catch (err) { + await session.abortTransaction(); + throw err; + } finally { + session.endSession(); + } + } + + static async listOrders(userId: string, role: "admin" | "customer") { + if (role === "admin") { + return OrderModel.find().lean(); + } + return OrderModel.find({ userId }).lean(); + } + + static async payOrder(orderId: string) { + const order = await OrderModel.findById(orderId); + if (!order) { + throw { statusCode: 404, message: "Order not found" }; + } + + if (order.status === "paid") { + return order; + } + + if (order.status === "cancelled") { + throw { statusCode: 409, message: "Order is cancelled" }; + } + + order.status = "paid"; + await order.save(); + return order; + } + + static async cancelOrder(orderId: string) { + const session = await mongoose.startSession(); + session.startTransaction(); + + try { + const order = await OrderModel.findById(orderId).session(session); + if (!order) { + throw { statusCode: 404, message: "Order not found" }; + } + + if (order.status === "cancelled") { + await session.commitTransaction(); + return order; + } + + if (order.status === "paid") { + throw { + statusCode: 409, + message: "Paid orders cannot be cancelled", + }; + } + + for (const item of order.items) { + await ProductModel.updateOne( + { _id: item.productId }, + { $inc: { stock: item.quantity } }, + { session }, + ); + } + + order.status = "cancelled"; + await order.save({ session }); + + await session.commitTransaction(); + return order; + } catch (err) { + await session.abortTransaction(); + throw err; + } finally { + session.endSession(); + } + } +} diff --git a/src/validation/product.schema.ts b/src/validation/product.schema.ts index e69de29..0cd468d 100644 --- a/src/validation/product.schema.ts +++ b/src/validation/product.schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const createProductSchema = z.object({ + body: z.object({ + name: z.string().min(1), + price: z.number().int().positive(), + stock: z.number().int().nonnegative(), + }), +}); + +export const updateProductSchema = z.object({ + params: z.object({ + id: z.string().uuid(), + }), + body: z + .object({ + name: z.string().min(1), + price: z.number().int().positive(), + stock: z.number().int().nonnegative(), + }) + .partial(), +});