Capstone: Production-Grade E-Commerce API
This capstone project integrates every concept from the course into a single production-grade application: a fully-RESTful e-commerce API with dual versioning, JWT authentication with RBAC, file uploads to Cloudinary, transactional email via Nodemailer, BullMQ background jobs, OpenAPI documentation, Winston structured logging, Jest unit and integration tests, PM2 cluster configuration, and graceful shutdown. Building this project demonstrates that you can architect, implement, document, and deploy a real-world Node.js API from scratch.
You will build CricketStore API — a product catalogue and order management system for a cricket equipment shop. The API supports three user roles (customer, seller, admin), product image uploads with background processing, order confirmation emails, paginated product listings with filtering, and a complete OpenAPI documentation portal. The architecture is production-ready: structured logging, error handling, security middleware, and PM2 cluster configuration are all included from the start, not added as an afterthought.
Learning Objectives
- Apply RESTful resource naming, HTTP verb semantics, pagination, HATEOAS links, and API versioning in a single coherent codebase
- Implement JWT access/refresh token authentication with bcrypt password hashing and role-based access control
- Handle multipart file uploads with Multer, process images with Sharp, and store them in Cloudinary using BullMQ background jobs
- Send transactional emails (order confirmation, welcome email) asynchronously via a BullMQ email queue
- Generate and serve interactive OpenAPI 3.0 documentation with swagger-jsdoc and swagger-ui-express
- Configure Winston structured logging with Morgan HTTP request logging integrated into the same pipeline
- Write unit tests with mocked dependencies and integration tests with mongodb-memory-server
- Configure PM2 cluster mode with graceful shutdown, health check endpoint, and zero-downtime reload
Technical Requirements
The API must implement the following resource model: Users (register, login, refresh, logout, profile), Products (CRUD with image upload, pagination, filtering by category and price range, HATEOAS links), Orders (create from cart, list by user, update status by seller/admin), and Jobs (status endpoint for async operations). All state-changing operations require authentication. Products are publicly readable; creation requires seller role; deletion requires admin role. Orders are readable only by the owning customer or admin; status updates require seller or admin role.
# Core dependencies
npm install express mongoose bcrypt jsonwebtoken cors helmet \
express-rate-limit cookie-parser multer sharp cloudinary \
streamifier bullmq ioredis nodemailer swagger-jsdoc \
swagger-ui-express winston morgan uuid dotenv
# Dev dependencies
npm install --save-dev jest supertest mongodb-memory-server nodemonArchitecture
The project follows the feature-based directory structure established in Module 2, extended with dedicated directories for queues, workers, and tests. The app.js composition root mounts all routers, applies all middleware in the correct order, and exports the app without calling listen(). The server.js entry point binds the port and registers graceful shutdown handlers. Workers run as separate PM2-managed processes.
// Project structure
cricketstore-api/
src/
config/
cloudinary.js swagger.js roles.js
errors/
AppError.js NotFoundError.js
middleware/
authenticate.js authorize.js asyncHandler.js requestId.js
models/
User.js Product.js Order.js
queues/
imageQueue.js emailQueue.js
routes/
v1/
auth.js products.js orders.js jobs.js
index.js
services/
authService.js productService.js orderService.js
utils/
jwt.js logger.js email.js cloudinaryUpload.js
app.js
workers/
imageWorker.js
emailWorker.js
tests/
unit/
services/ utils/
integration/
routes/
setup.js
ecosystem.config.js
server.jsPhase 1: Foundation (Auth + Products Read)
Build auth routes (register, login, refresh, logout), the Product model with Mongoose, the GET endpoints for products with pagination and filtering, and the security middleware stack (Helmet, CORS, rate limiting). Verify with integration tests before proceeding to write operations.
// models/Product.js
const mongoose = require('mongoose');
const productSchema = new mongoose.Schema({
name: { type: String, required: true, trim: true },
description: { type: String },
price: { type: Number, required: true, min: 0 },
category: { type: String, required: true, enum: ['bat','ball','helmet','gloves','pad','other'] },
stock: { type: Number, default: 0, min: 0 },
seller: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
imageUrl: { type: String, default: null },
cloudinaryId: { type: String, default: null },
imageStatus: { type: String, enum: ['none','processing','ready'], default: 'none' }
}, { timestamps: true });
productSchema.index({ category: 1, price: 1 });
module.exports = mongoose.model('Product', productSchema);
// routes/v1/products.js — GET collection with pagination + filtering
router.get('/', asyncHandler(async (req, res) => {
const { page = 1, limit = 12, category, minPrice, maxPrice, sort = '-createdAt' } = req.query;
const filter = {};
if (category) filter.category = category;
if (minPrice || maxPrice) filter.price = {};
if (minPrice) filter.price.$gte = parseFloat(minPrice);
if (maxPrice) filter.price.$lte = parseFloat(maxPrice);
const [products, total] = await Promise.all([
Product.find(filter).sort(sort).skip((page-1)*limit).limit(+limit).lean(),
Product.countDocuments(filter)
]);
const base = req.protocol + '://' + req.get('host') + '/api/v1/products';
res.json({
data: products.map(p => ({ ...p, _links: { self: { href: base + '/' + p._id } } })),
meta: { total, page: +page, pages: Math.ceil(total/limit), limit: +limit },
_links: {
self: { href: base + '?page=' + page },
first: { href: base + '?page=1' },
...(+page > 1 && { prev: { href: base + '?page=' + (+page-1) } }),
...(+page < Math.ceil(total/limit) && { next: { href: base + '?page=' + (+page+1) } })
}
});
}));Phase 2: Write Operations + File Upload Queue
Implement POST, PATCH, DELETE for products with RBAC enforcement. Add the image upload endpoint that accepts a file via Multer, adds a BullMQ job, and returns 202. Start the imageWorker process. Implement the order model and create/list routes. Add the email queue and emailWorker for order confirmation.
// routes/v1/products.js — POST with image upload support
router.post('/',
authenticate, authorize('write:products'),
upload.single('image'),
asyncHandler(async (req, res) => {
const { name, description, price, category, stock } = req.body;
if (!name || !price || !category)
throw new AppError('name, price and category are required', 422);
const product = await Product.create({
name, description, price: parseFloat(price),
category, stock: parseInt(stock || 0),
seller: req.user.sub
});
if (req.file) {
await imageQueue.add('process-image', {
productId: product.id.toString(),
imageBuffer: req.file.buffer.toString('base64'),
mimetype: req.file.mimetype
}, { attempts: 3, backoff: { type: 'exponential', delay: 3000 } });
}
res.status(201)
.location('/api/v1/products/' + product.id)
.json({ data: product });
})
);
// models/Order.js
const orderSchema = new mongoose.Schema({
customer: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
items: [{ product: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' }, qty: Number, price: Number }],
total: { type: Number, required: true },
status: { type: String, enum: ['pending','confirmed','shipped','delivered','cancelled'], default: 'pending' },
address: { type: String, required: true }
}, { timestamps: true });
module.exports = mongoose.model('Order', orderSchema);Phase 3: Testing, Docs, and Production Config
Write unit tests for authService, productService, and pagination utility. Write integration tests for the auth routes and product routes using mongodb-memory-server. Generate OpenAPI documentation with swagger-jsdoc. Configure Winston + Morgan logging. Write ecosystem.config.js for PM2 cluster mode with graceful shutdown in server.js.
// tests/integration/products.test.js (sample)
const request = require('supertest');
const app = require('../../src/app');
const Product = require('../../src/models/Product');
const User = require('../../src/models/User');
const { signAccess } = require('../../src/utils/jwt');
let sellerToken, adminToken;
beforeAll(async () => {
const seller = await User.create({ email: '[email protected]', passwordHash: 'x', role: 'seller' });
const admin = await User.create({ email: '[email protected]', passwordHash: 'x', role: 'admin' });
sellerToken = signAccess({ sub: seller.id, role: 'seller' });
adminToken = signAccess({ sub: admin.id, role: 'admin' });
});
describe('GET /api/v1/products', () => {
beforeEach(async () => {
await Product.insertMany([
{ name: 'Bat', price: 120, category: 'bat', seller: new mongoose.Types.ObjectId() },
{ name: 'Helmet', price: 85, category: 'helmet', seller: new mongoose.Types.ObjectId() }
]);
});
it('returns paginated products with HATEOAS links', async () => {
const res = await request(app).get('/api/v1/products?page=1&limit=10');
expect(res.statusCode).toBe(200);
expect(res.body.data.length).toBeGreaterThan(0);
expect(res.body.data[0]._links.self.href).toContain('/api/v1/products/');
expect(res.body.meta).toHaveProperty('total');
expect(res.body._links).toHaveProperty('self');
});
});
describe('POST /api/v1/products', () => {
it('returns 401 without authentication', async () => {
const res = await request(app).post('/api/v1/products').send({ name: 'X', price: 1, category: 'bat' });
expect(res.statusCode).toBe(401);
});
it('creates product with seller token', async () => {
const res = await request(app)
.post('/api/v1/products')
.set('Authorization', 'Bearer ' + sellerToken)
.send({ name: 'Test Bat', price: 100, category: 'bat', stock: 10 });
expect(res.statusCode).toBe(201);
expect(res.headers.location).toContain('/api/v1/products/');
});
});Evaluation Rubric
- Authentication (20 pts): register, login, refresh, logout all work correctly; tokens are short-lived; refresh uses rotation; logout clears cookie and DB hash
- RBAC (15 pts): customer/seller/admin roles enforced; 401 for missing token; 403 for wrong role; permissions are defined in a central config, not scattered in route files
- REST Design (15 pts): plural nouns, correct HTTP verbs, proper status codes (201 with Location, 204 for DELETE, 422 for validation); collection and item URLs consistent
- Pagination and Filtering (10 pts): page, limit, category, minPrice, maxPrice all work; meta includes total and pages; HATEOAS collection links are accurate
- File Upload + Queue (15 pts): POST /products/:id/image returns 202; imageWorker resizes and uploads to Cloudinary; imageStatus updates to 'ready' on completion
- Testing (15 pts): unit tests for services with mocked DB; integration tests using mongodb-memory-server; at least 70% line coverage; unhappy paths tested
- Production Config (10 pts): Winston JSON logging with requestId; Morgan piped to Winston; graceful shutdown in server.js; ecosystem.config.js with cluster mode
Submission checklist: All 7 rubric areas implemented. npm test passes with no errors. npm start starts the server on PORT env variable. The README.md includes setup steps, environment variable list, and curl examples for each major endpoint. The OpenAPI documentation is accessible at /api-docs when the server is running.
# Final smoke test sequence
npm install
cp .env.example .env # fill in your secrets
npm start & # start server in background
# Auth
TOKEN=$(curl -s -X POST :3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"SecurePass123"}' | jq -r .accessToken)
# Create product
curl -X POST :3000/api/v1/products \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Rohit Sharma Edition Bat","price":299,"category":"bat","stock":25}'
# List products with pagination
curl ":3000/api/v1/products?category=bat&page=1&limit=5&sort=-price"
# View documentation
open http://localhost:3000/api-docs
# Run tests
npm test