mirror of
https://github.com/andwati/order-service.git
synced 2026-10-24 04:52:15 +00:00
feat: implement order and product models, services, and routes with validation
This commit is contained in:
+2
-1
@@ -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",
|
||||
|
||||
Generated
+9
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user