diff --git a/package.json b/package.json index 76b53fb..5e70b4a 100644 --- a/package.json +++ b/package.json @@ -18,18 +18,21 @@ "license": "ISC", "packageManager": "pnpm@10.26.0", "dependencies": { + "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "mongoose": "^9.1.3", "morgan": "^1.10.1", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "zod": "^4.3.5" }, "devDependencies": { "@eslint/js": "^9.39.2", "@eslint/json": "^0.14.0", "@eslint/markdown": "^7.5.1", + "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0522dcd..71b8d45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 cors: specifier: ^2.8.5 version: 2.8.5 @@ -29,6 +32,9 @@ importers: uuid: specifier: ^13.0.0 version: 13.0.0 + zod: + specifier: ^4.3.5 + version: 4.3.5 devDependencies: '@eslint/js': specifier: ^9.39.2 @@ -39,6 +45,9 @@ importers: '@eslint/markdown': specifier: ^7.5.1 version: 7.5.1 + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 '@types/cors': specifier: ^2.8.19 version: 2.8.19 @@ -176,6 +185,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} @@ -340,6 +352,10 @@ packages: resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} engines: {node: '>= 0.8'} + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1022,6 +1038,14 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + nodemon@3.1.11: resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} engines: {node: '>=10'} @@ -1336,6 +1360,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1446,6 +1473,10 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 25.0.6 + '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 @@ -1654,6 +1685,11 @@ snapshots: dependencies: safe-buffer: 5.1.2 + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + binary-extensions@2.3.0: {} body-parser@2.2.2: @@ -2514,6 +2550,10 @@ snapshots: negotiator@1.0.0: {} + node-addon-api@8.5.0: {} + + node-gyp-build@4.8.4: {} + nodemon@3.1.11: dependencies: chokidar: 3.6.0 @@ -2829,4 +2869,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.3.5: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d06882d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - bcrypt diff --git a/src/app.ts b/src/app.ts index 52acc4a..456e7f8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,7 +4,7 @@ import cors from "cors"; import morgan from "morgan"; import { errorHandler } from "./middleware/error.middleware.js"; -import { authRoutes } from "./routes/auth.routes.js"; +import authRoutes from "./routes/auth.routes.js"; import productRoutes from "./routes/product.routes.js"; import orderRoutes from "./routes/order.routes.js"; diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts index e5b4734..e90670a 100644 --- a/src/controllers/auth.controller.ts +++ b/src/controllers/auth.controller.ts @@ -1,5 +1,5 @@ -import { Request, Response, NextFunction } from "express"; -import { AuthService } from "../services/auth.service"; +import type { Request, Response, NextFunction } from "express"; +import { AuthService } from "../services/auth.service.js"; export class AuthController { static async register(req: Request, res: Response, next: NextFunction) { diff --git a/src/controllers/order.controller.ts b/src/controllers/order.controller.ts index 640dc06..51ef80c 100644 --- a/src/controllers/order.controller.ts +++ b/src/controllers/order.controller.ts @@ -1,5 +1,5 @@ -import { Request, Response, NextFunction } from "express"; -import { OrderService } from "../services/order.service"; +import type { Request, Response, NextFunction } from "express"; +import { OrderService } from "../services/order.service.js"; export class OrderController { static async create(req: Request, res: Response, next: NextFunction) { diff --git a/src/controllers/product.controller.ts b/src/controllers/product.controller.ts index f9d21cf..1979758 100644 --- a/src/controllers/product.controller.ts +++ b/src/controllers/product.controller.ts @@ -1,5 +1,5 @@ -import { Request, Response, NextFunction } from "express"; -import { ProductService } from "../services/product.service"; +import type { Request, Response, NextFunction } from "express"; +import { ProductService } from "../services/product.service.js"; export class ProductController { static async create(req: Request, res: Response, next: NextFunction) { diff --git a/src/middleware/validate.middleware.ts b/src/middleware/validate.middleware.ts index a7cab0a..2af161b 100644 --- a/src/middleware/validate.middleware.ts +++ b/src/middleware/validate.middleware.ts @@ -1,5 +1,5 @@ import { ZodSchema } from "zod"; -import { Request, Response, NextFunction } from "express"; +import type { Request, Response, NextFunction } from "express"; export const validate = (schema: ZodSchema) => (req: Request, _res: Response, next: NextFunction) => { diff --git a/src/services/order.service.ts b/src/services/order.service.ts index 3ec7c7a..7014460 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -1,6 +1,6 @@ import mongoose from "mongoose"; -import { ProductModel } from "../models/product.model"; -import { OrderModel } from "../models/order.model"; +import { ProductRepository } from "../repositories/product.repo"; +import { OrderRepository } from "../repositories/order.repo"; export class OrderService { static async createOrder( @@ -12,28 +12,18 @@ export class OrderService { try { const productIds = items.map((i) => i.productId); - - const products = await ProductModel.find({ - _id: { $in: productIds }, - }).session(session); + const products = await ProductRepository.findByIds(productIds, session); if (products.length !== productIds.length) { throw { statusCode: 400, message: "One or more products not found" }; } - const orderItems = []; let total = 0; + const orderItems = []; 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, @@ -41,8 +31,11 @@ export class OrderService { }; } - product.stock -= item.quantity; - await product.save({ session }); + await ProductRepository.decrementStock( + product._id, + item.quantity, + session, + ); orderItems.push({ productId: product._id, @@ -53,87 +46,16 @@ export class OrderService { total += product.price * item.quantity; } - const order = await OrderModel.create( - [ - { - userId, - items: orderItems, - total, - status: "created", - }, - ], - { session }, + const order = await OrderRepository.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) { diff --git a/src/utils/logger.ts b/src/utils/logger.ts index e69de29..91894b4 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -0,0 +1,22 @@ +type LogMeta = Record; + +function format(level: string, message: string, meta?: LogMeta) { + const timestamp = new Date().toISOString(); + return meta + ? `[${timestamp}] ${level.toUpperCase()} ${message} ${JSON.stringify(meta)}` + : `[${timestamp}] ${level.toUpperCase()} ${message}`; +} + +export const logger = { + info(message: string, meta?: LogMeta) { + console.log(format("info", message, meta)); + }, + + warn(message: string, meta?: LogMeta) { + console.warn(format("warn", message, meta)); + }, + + error(message: string, meta?: LogMeta) { + console.error(format("error", message, meta)); + }, +}; diff --git a/src/utils/password.ts b/src/utils/password.ts index e69de29..2fc9e34 100644 --- a/src/utils/password.ts +++ b/src/utils/password.ts @@ -0,0 +1,14 @@ +import bcrypt from "bcrypt"; + +const SALT_ROUNDS = 10; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword( + password: string, + hash: string, +): Promise { + return bcrypt.compare(password, hash); +}