mirror of
https://github.com/andwati/order-service.git
synced 2026-10-19 10:52:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a09e9fc07e | ||
|
|
86b9ef8dec | ||
|
|
aa38999ff4 | ||
|
|
fc42bff882 | ||
|
|
dd92d8eaae | ||
|
|
e8618b7c98 | ||
|
|
e52ab793c5 | ||
|
|
9028465bcc | ||
|
|
ef5703a5ed | ||
|
|
1d68dc0c0a | ||
|
|
ba21363eae | ||
|
|
b74197f60f | ||
|
|
8e385887de | ||
|
|
d2334181a0 |
@@ -1 +1,338 @@
|
|||||||
an order service with authentication,data persistence etc
|
# Order Service
|
||||||
|
|
||||||
|
An **order management REST API** with authentication, role-based access control, and MongoDB persistence. It exposes endpoints for:
|
||||||
|
|
||||||
|
- **Auth** – register/login, issue JWTs
|
||||||
|
- **Products** – admin-only create/update, public list
|
||||||
|
- **Orders** – customers create/list/pay/cancel orders
|
||||||
|
- **Users** – admin-only user listing
|
||||||
|
|
||||||
|
Built with **Node.js**, **Express 5**, **TypeScript**, and **Mongoose**.
|
||||||
|
|
||||||
|
Swagger doc for test in the browser: <https://order-service-t77z.onrender.com/docs/>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Setup & Installation
|
||||||
|
|
||||||
|
- **Prerequisites**
|
||||||
|
- **Node.js**: v20+ recommended
|
||||||
|
- **Package manager**: `pnpm` (project uses `pnpm-lock.yaml`)
|
||||||
|
- **MongoDB**: running locally or accessible via connection string
|
||||||
|
|
||||||
|
- **Clone and install**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-fork-or-repo-url> order-service
|
||||||
|
cd order-service
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Build the project**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Start the server**
|
||||||
|
|
||||||
|
By default the compiled server entrypoint is `dist/server.js`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node dist/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will listen on `http://localhost:<PORT>` (defaults to `3000`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Environment Variables
|
||||||
|
|
||||||
|
Environment variables are loaded via `dotenv` in `src/config/env.ts`. The following variables are **required/recommended**:
|
||||||
|
|
||||||
|
- **Required**
|
||||||
|
- **`MONGO_URI`**: MongoDB connection string
|
||||||
|
Example: `mongodb://localhost:27017/order_service`
|
||||||
|
- **`JWT_SECRET`**: Secret key used to sign JWT access tokens
|
||||||
|
|
||||||
|
- **Optional**
|
||||||
|
- **`PORT`**: HTTP port to run the server on
|
||||||
|
Default: `3000`
|
||||||
|
- **`NODE_ENV`**: `development` | `production`
|
||||||
|
Default: `development`
|
||||||
|
- **`API_URL`**: External base URL for the API
|
||||||
|
Default: `http://localhost:3000`
|
||||||
|
- **`JWT_EXPIRES_IN`**: Token lifetime, e.g. `1h`, `7d`
|
||||||
|
Default: `1h`
|
||||||
|
|
||||||
|
- **Example `.env`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
API_URL=http://localhost:3000
|
||||||
|
|
||||||
|
MONGO_URI=mongodb://localhost:27017/order_service
|
||||||
|
|
||||||
|
JWT_SECRET=super-secret-change-me
|
||||||
|
JWT_EXPIRES_IN=1h
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a `.env` file at the project root before running the server or scripts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Database, Migrations & Seeding
|
||||||
|
|
||||||
|
This project uses **MongoDB + Mongoose**. There is no formal migration tool configured; schemas are defined via Mongoose models under `src/models`.
|
||||||
|
|
||||||
|
- **Database connection**
|
||||||
|
- Configured in `src/config/db.ts` using `MONGO_URI`.
|
||||||
|
|
||||||
|
- **Seeding products**
|
||||||
|
|
||||||
|
There is a seed script at `src/scripts/seed.ts` which:
|
||||||
|
|
||||||
|
- Connects to MongoDB
|
||||||
|
- Clears the `products` collection
|
||||||
|
- Inserts a fixed list of sample products (mice, keyboards, etc.)
|
||||||
|
|
||||||
|
**Run the seed script:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Under the hood this will:
|
||||||
|
|
||||||
|
1. Compile TypeScript (`tsc`)
|
||||||
|
2. Run `node dist/scripts/seed.js`
|
||||||
|
|
||||||
|
Make sure:
|
||||||
|
|
||||||
|
- The project is built without errors.
|
||||||
|
- `MONGO_URI` is correctly set in your `.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Running the Service
|
||||||
|
|
||||||
|
- **Build once:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Start server:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node dist/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The service will:
|
||||||
|
|
||||||
|
- Connect to MongoDB.
|
||||||
|
- Expose health check at `GET /health`.
|
||||||
|
- Expose REST endpoints under `/auth`, `/products`, `/orders`, `/users`.
|
||||||
|
- Expose Swagger docs under `/docs`.
|
||||||
|
|
||||||
|
> Note: For local development you can also use `ts-node` / `nodemon` if you wire custom scripts, but for this task the build‑then‑run flow above is sufficient.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. API Reference & Example `curl` Requests
|
||||||
|
|
||||||
|
## 5.1 Health Check
|
||||||
|
|
||||||
|
- **GET `/health`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i https://order-service-t77z.onrender.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.2 Auth
|
||||||
|
|
||||||
|
### Register
|
||||||
|
|
||||||
|
- **POST `/auth/register`**
|
||||||
|
- Body:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST https://order-service-t77z.onrender.com/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "[email protected]",
|
||||||
|
"password": "Password123",
|
||||||
|
"role": "customer"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
For an admin user, set `"role": "admin"`.
|
||||||
|
|
||||||
|
### Login
|
||||||
|
|
||||||
|
- **POST `/auth/login`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST https://order-service-t77z.onrender.com/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"email": "[email protected]",
|
||||||
|
"password": "Password123"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The response includes a `token` field (JWT). Use this token in subsequent requests:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.3 Products
|
||||||
|
|
||||||
|
### List products (public)
|
||||||
|
|
||||||
|
- **GET `/products`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i https://order-service-t77z.onrender.com/products
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create product (admin only)
|
||||||
|
|
||||||
|
- **POST `/products`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST https://order-service-t77z.onrender.com/products \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <admin-token>" \
|
||||||
|
-d '{
|
||||||
|
"name": "New Gadget",
|
||||||
|
"price": 1999,
|
||||||
|
"stock": 25
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update product (admin only)
|
||||||
|
|
||||||
|
- **PATCH `/products/:id`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X PATCH https://order-service-t77z.onrender.com/products/<productId> \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <admin-token>" \
|
||||||
|
-d '{
|
||||||
|
"price": 2499,
|
||||||
|
"stock": 40
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.4 Orders
|
||||||
|
|
||||||
|
### Create order (customer or admin)
|
||||||
|
|
||||||
|
- **POST `/orders`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST https://order-service-t77z.onrender.com/orders \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-d '{
|
||||||
|
"items": [
|
||||||
|
{ "productId": "<productId>", "quantity": 2 },
|
||||||
|
{ "productId": "<otherProductId>", "quantity": 1 }
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### List my orders
|
||||||
|
|
||||||
|
- **GET `/orders`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i https://order-service-t77z.onrender.com/orders \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pay for an order
|
||||||
|
|
||||||
|
- **POST `/orders/:id/pay`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST https://order-service-t77z.onrender.com/orders/<orderId>/pay \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cancel an order
|
||||||
|
|
||||||
|
- **POST `/orders/:id/cancel`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i -X POST https://order-service-t77z.onrender.com/orders/<orderId>/cancel \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.5 Users (Admin only)
|
||||||
|
|
||||||
|
### List users
|
||||||
|
|
||||||
|
- **GET `/users`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i https://order-service-t77z.onrender.com/users \
|
||||||
|
-H "Authorization: Bearer <admin-token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Swagger / API Docs
|
||||||
|
|
||||||
|
The service is wired with `swagger-jsdoc` and `swagger-ui-express`. OpenAPI annotations live in the route files (e.g. `src/routes/auth.routes.ts`, `src/routes/order.routes.ts`, etc) and are aggregated in the Swagger configuration.
|
||||||
|
|
||||||
|
- **View documentation UI:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open https://order-service-t77z.onrender.com/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
This provides an interactive UI for exploring and testing all endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Design Notes
|
||||||
|
|
||||||
|
- **Architecture**
|
||||||
|
- **Layered structure**:
|
||||||
|
- `controllers/`: Express route handlers (request/response orchestration).
|
||||||
|
- `services/`: Business logic (auth, orders, products, users).
|
||||||
|
- `repositories/`: Data access layer over Mongoose models.
|
||||||
|
- `models/`: Mongoose schemas and models.
|
||||||
|
- `middleware/`: Cross-cutting concerns (auth, validation, error handling, rate limiting, role checks).
|
||||||
|
- **Separation of concerns** keeps controllers thin and business logic testable.
|
||||||
|
|
||||||
|
- **Authentication & Authorization**
|
||||||
|
- JWT-based auth (see `auth.middleware.ts` and `utils/jwt.ts`).
|
||||||
|
- Role-based access control via `requireRole` middleware:
|
||||||
|
- Customers can create/list/pay/cancel their own orders.
|
||||||
|
- Admins can manage products and list all users.
|
||||||
|
|
||||||
|
- **Validation & Error Handling**
|
||||||
|
- **Zod** schemas validate payloads (`validation/*.schema.ts`).
|
||||||
|
- Central `error.middleware.ts` normalizes API error responses.
|
||||||
|
- `rateLimit.middleware.ts` limits sensitive endpoints (e.g. auth) to mitigate brute-force.
|
||||||
|
|
||||||
|
- **Persistence**
|
||||||
|
- MongoDB via Mongoose models (`user.model.ts`, `product.model.ts`, `order.model.ts`).
|
||||||
|
- Order items reference products and store quantities and pricing data for consistency.
|
||||||
|
|
||||||
|
- **Observability & DX**
|
||||||
|
- `morgan` + custom `logger` provide structured request logs.
|
||||||
|
- TypeScript configuration (`tsconfig.json`) and linting (`eslint.config.mts`) encourage a consistent, safe codebase.
|
||||||
|
|||||||
+10
-2
@@ -10,7 +10,11 @@
|
|||||||
"lint": "eslint . --ext .ts",
|
"lint": "eslint . --ext .ts",
|
||||||
"lint:fix": "eslint . --ext .ts --fix",
|
"lint:fix": "eslint . --ext .ts --fix",
|
||||||
"prettier": "prettier --check .",
|
"prettier": "prettier --check .",
|
||||||
"prettier:fix": "prettier --write ."
|
"prettier:fix": "prettier --write .",
|
||||||
|
"seed": "tsc && node dist/scripts/seed.js",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:unit": "vitest run src/tests/unit",
|
||||||
|
"test:integration": "vitest run src/tests/integration"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -39,15 +43,19 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/morgan": "^1.9.10",
|
"@types/morgan": "^1.9.10",
|
||||||
"@types/node": "^25.0.6",
|
"@types/node": "^25.0.6",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/swagger-jsdoc": "^6.0.4",
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.52.0"
|
"typescript-eslint": "^8.52.0",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+1156
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@scarf/scarf'
|
- '@scarf/scarf'
|
||||||
- bcrypt
|
- bcrypt
|
||||||
|
- esbuild
|
||||||
|
|||||||
+7
-1
@@ -4,9 +4,11 @@ import cors from "cors";
|
|||||||
import morgan from "morgan";
|
import morgan from "morgan";
|
||||||
|
|
||||||
import { errorHandler } from "./middleware/error.middleware.js";
|
import { errorHandler } from "./middleware/error.middleware.js";
|
||||||
|
import { logger } from "./utils/logger.js";
|
||||||
import authRoutes from "./routes/auth.routes.js";
|
import authRoutes from "./routes/auth.routes.js";
|
||||||
import productRoutes from "./routes/product.routes.js";
|
import productRoutes from "./routes/product.routes.js";
|
||||||
import orderRoutes from "./routes/order.routes.js";
|
import orderRoutes from "./routes/order.routes.js";
|
||||||
|
import userRoutes from "./routes/user.routes.js";
|
||||||
import docsRoutes from "./routes/docs.routes.js";
|
import docsRoutes from "./routes/docs.routes.js";
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
@@ -14,7 +16,10 @@ const app: Application = express();
|
|||||||
//global middleware
|
//global middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(morgan("dev"));
|
|
||||||
|
// HTTP request logging with morgan
|
||||||
|
const morganFormat = ":method :url :status :res[content-length] - :response-time ms";
|
||||||
|
app.use(morgan(morganFormat, { stream: logger.stream }));
|
||||||
|
|
||||||
// health check route
|
// health check route
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
@@ -25,6 +30,7 @@ app.get("/health", (req, res) => {
|
|||||||
app.use("/auth", authRoutes);
|
app.use("/auth", authRoutes);
|
||||||
app.use("/products", productRoutes);
|
app.use("/products", productRoutes);
|
||||||
app.use("/orders", orderRoutes);
|
app.use("/orders", orderRoutes);
|
||||||
|
app.use("/users", userRoutes);
|
||||||
// swagger ui
|
// swagger ui
|
||||||
app.use("/docs", docsRoutes);
|
app.use("/docs", docsRoutes);
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { AuthService } from "../services/auth.service.js";
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
static async register(req: Request, res: Response, next: NextFunction) {
|
static async register(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
const { email, password, role } = req.body;
|
||||||
|
|
||||||
const user = await AuthService.register(email, password);
|
const user = await AuthService.register(email, password, role);
|
||||||
|
|
||||||
res.status(201).json(user);
|
res.status(201).json(user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
import { UserService } from "../services/user.service.js";
|
||||||
|
|
||||||
|
export class UserController {
|
||||||
|
static async getAll(_req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const users = await UserService.getAll();
|
||||||
|
res.status(200).json(users);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
export function requireRole(role: "admin" | "customer") {
|
export function requireRole(roles: "admin" | "customer" | ("admin" | "customer")[]) {
|
||||||
|
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
||||||
|
|
||||||
return (req: Request, _res: Response, next: NextFunction) => {
|
return (req: Request, _res: Response, next: NextFunction) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return next({
|
return next({
|
||||||
@@ -9,7 +11,7 @@ export function requireRole(role: "admin" | "customer") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.user.role !== role) {
|
if (!allowedRoles.includes(req.user.role as "admin" | "customer")) {
|
||||||
return next({
|
return next({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: "Forbidden",
|
message: "Forbidden",
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
import type { ZodSchema } from "zod";
|
import type { ZodSchema } from "zod";
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
interface ParsedRequest {
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
params?: Record<string, string>;
|
||||||
|
query?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export const validate =
|
export const validate =
|
||||||
(schema: ZodSchema) => (req: Request, _res: Response, next: NextFunction) => {
|
(schema: ZodSchema) => (req: Request, _res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
schema.parse({
|
const parsed = schema.parse({
|
||||||
body: req.body,
|
body: req.body,
|
||||||
params: req.params,
|
params: req.params,
|
||||||
query: req.query,
|
query: req.query,
|
||||||
});
|
}) as ParsedRequest;
|
||||||
|
|
||||||
|
// Update request with parsed values (including defaults)
|
||||||
|
if (parsed.body) req.body = parsed.body;
|
||||||
|
if (parsed.params) req.params = parsed.params;
|
||||||
|
if (parsed.query) req.query = parsed.query as Record<string, string>;
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { UserModel } from "../models/user.model.js";
|
|||||||
import type { UserDocument } from "../models/user.model.js";
|
import type { UserDocument } from "../models/user.model.js";
|
||||||
|
|
||||||
export class UserRepository {
|
export class UserRepository {
|
||||||
|
static findAll(): Promise<UserDocument[]> {
|
||||||
|
return UserModel.find().select("-passwordHash");
|
||||||
|
}
|
||||||
|
|
||||||
static findByEmail(email: string): Promise<UserDocument | null> {
|
static findByEmail(email: string): Promise<UserDocument | null> {
|
||||||
return UserModel.findOne({ email });
|
return UserModel.findOne({ email });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,52 @@ const router: Router = Router();
|
|||||||
* tags:
|
* tags:
|
||||||
* - Auth
|
* - Auth
|
||||||
* summary: Register a new user
|
* summary: Register a new user
|
||||||
|
* security: []
|
||||||
* requestBody:
|
* requestBody:
|
||||||
* required: true
|
* required: true
|
||||||
* content:
|
* content:
|
||||||
* application/json:
|
* application/json:
|
||||||
* schema:
|
* schema:
|
||||||
* type: object
|
* type: object
|
||||||
|
* required:
|
||||||
|
* - email
|
||||||
|
* - password
|
||||||
* properties:
|
* properties:
|
||||||
* email:
|
* email:
|
||||||
* type: string
|
* type: string
|
||||||
|
* format: email
|
||||||
|
* description: User's email address
|
||||||
* password:
|
* password:
|
||||||
* type: string
|
* type: string
|
||||||
|
* minLength: 8
|
||||||
|
* description: User's password (minimum 8 characters)
|
||||||
|
* role:
|
||||||
|
* type: string
|
||||||
|
* enum: [customer, admin]
|
||||||
|
* default: customer
|
||||||
|
* description: Account type (defaults to customer)
|
||||||
* responses:
|
* responses:
|
||||||
* 201:
|
* 201:
|
||||||
* description: User created
|
* description: User created successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* description: The user ID
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* description: The user's email
|
||||||
|
* role:
|
||||||
|
* type: string
|
||||||
|
* enum: [customer, admin]
|
||||||
|
* description: The user's role
|
||||||
* 400:
|
* 400:
|
||||||
* description: Invalid input
|
* description: Invalid input
|
||||||
|
* 409:
|
||||||
|
* description: Email already registered
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
"/register",
|
"/register",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const router: Router = Router();
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
requireRole("customer"),
|
requireRole(["customer", "admin"]),
|
||||||
validate(createOrderSchema),
|
validate(createOrderSchema),
|
||||||
OrderController.create,
|
OrderController.create,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { UserController } from "../controllers/user.controller.js";
|
||||||
|
import { authMiddleware } from "../middleware/auth.middleware.js";
|
||||||
|
import { requireRole } from "../middleware/role.middleware.js";
|
||||||
|
|
||||||
|
const router: Router = Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /users:
|
||||||
|
* get:
|
||||||
|
* tags:
|
||||||
|
* - Users
|
||||||
|
* summary: Get all users (Admin only)
|
||||||
|
* description: Retrieve a list of all registered users. This endpoint is restricted to administrators only.
|
||||||
|
* security:
|
||||||
|
* - BearerAuth: []
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: List of users retrieved successfully
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* description: The user ID
|
||||||
|
* email:
|
||||||
|
* type: string
|
||||||
|
* description: The user's email
|
||||||
|
* role:
|
||||||
|
* type: string
|
||||||
|
* enum: [customer, admin]
|
||||||
|
* description: The user's role
|
||||||
|
* createdAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* description: Account creation date
|
||||||
|
* updatedAt:
|
||||||
|
* type: string
|
||||||
|
* format: date-time
|
||||||
|
* description: Last update date
|
||||||
|
* 401:
|
||||||
|
* description: Unauthorized - Missing or invalid token
|
||||||
|
* 403:
|
||||||
|
* description: Forbidden - Admin access required
|
||||||
|
*/
|
||||||
|
router.get("/", authMiddleware, requireRole("admin"), UserController.getAll);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { connectDB } from "../config/db.js";
|
||||||
|
import { ProductModel } from "../models/product.model.js";
|
||||||
|
import mongoose from "mongoose";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
|
||||||
|
const PRODUCTS = [
|
||||||
|
{
|
||||||
|
name: "Wireless Mouse",
|
||||||
|
price: 2500, // $25.00
|
||||||
|
stock: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mechanical Keyboard",
|
||||||
|
price: 12000, // $120.00
|
||||||
|
stock: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "USB-C Hub",
|
||||||
|
price: 4500, // $45.00
|
||||||
|
stock: 75,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Monitor Stand",
|
||||||
|
price: 3500, // $35.00
|
||||||
|
stock: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gaming Headset",
|
||||||
|
price: 8000, // $80.00
|
||||||
|
stock: 30,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
try {
|
||||||
|
logger.info("Connecting to database...");
|
||||||
|
await connectDB();
|
||||||
|
logger.info("Connected to database.");
|
||||||
|
|
||||||
|
logger.info("Clearing products...");
|
||||||
|
await ProductModel.deleteMany({});
|
||||||
|
logger.info("Products cleared.");
|
||||||
|
|
||||||
|
logger.info("Seeding products...");
|
||||||
|
await ProductModel.insertMany(PRODUCTS);
|
||||||
|
logger.info("Products seeded successfully.");
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error seeding database:", { error });
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seed();
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { UserRepository } from "../repositories/user.repo.js";
|
import { UserRepository } from "../repositories/user.repo.js";
|
||||||
import { hashPassword, verifyPassword } from "../utils/password.js";
|
import { hashPassword, verifyPassword } from "../utils/password.js";
|
||||||
import { signToken } from "../utils/jwt.js";
|
import { signToken } from "../utils/jwt.js";
|
||||||
|
import type { UserRole } from "../models/user.model.js";
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
static async register(email: string, password: string) {
|
static async register(email: string, password: string, role: UserRole = "customer") {
|
||||||
const existing = await UserRepository.findByEmail(email);
|
const existing = await UserRepository.findByEmail(email);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw { statusCode: 409, message: "Email already registered" };
|
throw { statusCode: 409, message: "Email already registered" };
|
||||||
@@ -14,7 +15,7 @@ export class AuthService {
|
|||||||
const user = await UserRepository.create({
|
const user = await UserRepository.create({
|
||||||
email,
|
email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
role: "customer",
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id: user._id, email: user.email, role: user.role };
|
return { id: user._id, email: user.email, role: user.role };
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { UserRepository } from "../repositories/user.repo.js";
|
||||||
|
|
||||||
|
export class UserService {
|
||||||
|
static async getAll() {
|
||||||
|
const users = await UserRepository.findAll();
|
||||||
|
return users.map((user) => ({
|
||||||
|
id: user._id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import request from "supertest";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("GET /health", () => {
|
||||||
|
it("returns 200 OK", async () => {
|
||||||
|
// Minimal env required by app config during import
|
||||||
|
process.env.MONGO_URI = process.env.MONGO_URI ?? "mongodb://localhost:27017/test";
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET ?? "test-secret";
|
||||||
|
|
||||||
|
const { default: app } = await import("../../app.js");
|
||||||
|
|
||||||
|
const res = await request(app).get("/health");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ status: "OK" });
|
||||||
|
}, 20000);
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { OrderService } from "../../services/order.service.js";
|
||||||
|
import { OrderRepository } from "../../repositories/order.repo.js";
|
||||||
|
import { ProductRepository } from "../../repositories/product.repo.js";
|
||||||
|
|
||||||
|
vi.mock("mongoose", async (importOriginal) => {
|
||||||
|
const actual = (await importOriginal()) as typeof import("mongoose");
|
||||||
|
|
||||||
|
const startSession = vi.fn().mockResolvedValue({
|
||||||
|
startTransaction: vi.fn(),
|
||||||
|
commitTransaction: vi.fn(),
|
||||||
|
abortTransaction: vi.fn(),
|
||||||
|
endSession: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: {
|
||||||
|
...(actual as any).default,
|
||||||
|
startSession,
|
||||||
|
},
|
||||||
|
startSession,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../repositories/order.repo.js");
|
||||||
|
vi.mock("../../repositories/product.repo.js");
|
||||||
|
|
||||||
|
describe("OrderService", () => {
|
||||||
|
const mockedOrderRepo = vi.mocked(OrderRepository);
|
||||||
|
const mockedProductRepo = vi.mocked(ProductRepository);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listOrders", () => {
|
||||||
|
it("lists all orders for admin role", async () => {
|
||||||
|
const orders = [{ id: "1" }];
|
||||||
|
mockedOrderRepo.findAll.mockResolvedValueOnce(orders as never);
|
||||||
|
|
||||||
|
const result = await OrderService.listOrders("user-id", "admin");
|
||||||
|
|
||||||
|
expect(mockedOrderRepo.findAll).toHaveBeenCalledOnce();
|
||||||
|
expect(result).toEqual(orders);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists user orders for non-admin role", async () => {
|
||||||
|
const orders = [{ id: "2" }];
|
||||||
|
mockedOrderRepo.findByUser.mockResolvedValueOnce(orders as never);
|
||||||
|
|
||||||
|
const result = await OrderService.listOrders("user-123", "customer");
|
||||||
|
|
||||||
|
expect(mockedOrderRepo.findByUser).toHaveBeenCalledWith("user-123");
|
||||||
|
expect(result).toEqual(orders);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("payOrder", () => {
|
||||||
|
it("throws 400 for invalid id", async () => {
|
||||||
|
await expect(OrderService.payOrder(undefined)).rejects.toMatchObject({
|
||||||
|
statusCode: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+36
-4
@@ -1,13 +1,33 @@
|
|||||||
type LogMeta = Record<string, unknown>;
|
type LogMeta = Record<string, unknown>;
|
||||||
|
|
||||||
function format(level: string, message: string, meta?: LogMeta) {
|
type LogLevel = "debug" | "info" | "warn" | "error" | "http";
|
||||||
|
|
||||||
|
const LOG_COLORS: Record<LogLevel, string> = {
|
||||||
|
debug: "\x1b[36m", // cyan
|
||||||
|
info: "\x1b[32m", // green
|
||||||
|
warn: "\x1b[33m", // yellow
|
||||||
|
error: "\x1b[31m", // red
|
||||||
|
http: "\x1b[35m", // magenta
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
|
||||||
|
function format(level: LogLevel, message: string, meta?: LogMeta): string {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
return meta
|
const color = LOG_COLORS[level];
|
||||||
? `[${timestamp}] ${level.toUpperCase()} ${message} ${JSON.stringify(meta)}`
|
const levelStr = `${color}${level.toUpperCase().padEnd(5)}${RESET}`;
|
||||||
: `[${timestamp}] ${level.toUpperCase()} ${message}`;
|
|
||||||
|
const baseLog = `[${timestamp}] ${levelStr} ${message}`;
|
||||||
|
return meta ? `${baseLog} ${JSON.stringify(meta)}` : baseLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
|
debug(message: string, meta?: LogMeta) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.log(format("debug", message, meta));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
info(message: string, meta?: LogMeta) {
|
info(message: string, meta?: LogMeta) {
|
||||||
console.log(format("info", message, meta));
|
console.log(format("info", message, meta));
|
||||||
},
|
},
|
||||||
@@ -19,4 +39,16 @@ export const logger = {
|
|||||||
error(message: string, meta?: LogMeta) {
|
error(message: string, meta?: LogMeta) {
|
||||||
console.error(format("error", message, meta));
|
console.error(format("error", message, meta));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
http(message: string, meta?: LogMeta) {
|
||||||
|
console.log(format("http", message, meta));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stream for morgan integration
|
||||||
|
stream: {
|
||||||
|
write(message: string) {
|
||||||
|
// Remove trailing newline from morgan
|
||||||
|
logger.http(message.trim());
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const registerSchema = z.object({
|
|||||||
body: z.object({
|
body: z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(8),
|
password: z.string().min(8),
|
||||||
|
role: z.enum(["customer", "admin"]).default("customer"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
include: ["src/tests/**/*.test.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "html"],
|
||||||
|
reportsDirectory: "./coverage",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user