feat: implement order and product models, services, and routes with validation

This commit is contained in:
2026-01-13 00:03:48 +03:00
parent 8f2477b25e
commit da428f83bc
13 changed files with 505 additions and 5 deletions
+2 -1
View File
@@ -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",
+9
View File
@@ -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:
[email protected]:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
[email protected]:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -2797,6 +2804,8 @@ snapshots:
dependencies:
punycode: 2.3.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
+4 -4
View File
@@ -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();
+54
View File
@@ -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);
}
}
}
+16
View File
@@ -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);
}
};
+78
View File
@@ -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<OrderItem>(
{
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<OrderDocument>(
{
_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<OrderDocument>("Order", orderSchema);
+40
View File
@@ -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<ProductDocument>(
{
_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<ProductDocument>("Product", productSchema);
+43
View File
@@ -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<UserDocument>(
{
_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<UserDocument>("User", userSchema);
+23
View File
@@ -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;
+37
View File
@@ -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;
+31
View File
@@ -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;
+146
View File
@@ -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();
}
}
}
+22
View File
@@ -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(),
});