A fullstack e-commerce system with a Node.js/Express backend providing authentication, catalog, cart, orders, reviews, admin dashboard metrics, and VNPay payment integration. The frontend(s) are React apps that consume these APIs (briefly covered at the end).
This repository implements an e-commerce backend API for:
- User authentication (email/password) with email verification
- Google OAuth login
- Password reset via email
- Guest checkout initiation via emailed “magic link”
- Product catalog with brands, categories, variants, and images
- Cart + cart items management
- Orders + order items, order status transitions, and loyalty points bookkeeping
- VNPay (sandbox) payment flow (pay URL generation + return + IPN handling)
- Reviews (guests can comment; only logged-in users can rate)
- Product search backed by Elasticsearch (synced from MongoDB)
Backend entrypoint: backend/src/server.js
- Language/runtime: Node.js (ESM modules; see
"type": "module") - Framework: Express (v5)
- Database: MongoDB (Mongoose ODM)
- Auth:
- JWT (
jsonwebtoken) - Cookie-based auth for most user flows (
cookie-parser) - Passport Google OAuth (
passport,passport-google-oauth20)
- JWT (
- Email: Nodemailer (
nodemailer) - Search: Elasticsearch (
@elastic/elasticsearch) + bulk indexing/sync - Payments: VNPay SDK (
vnpay) - Logging: Morgan (
morgan) - Dev tooling: Nodemon (
nodemon)
- React 19 + Vite + Tailwind CSS
- Axios for API calls
- Express app + middleware are configured in
backend/src/server.js. - Route modules in
backend/src/routes/define endpoints and mount controller handlers. - Controllers in
backend/src/controllers/implement business logic using Mongoose models. - Models in
backend/src/models/define MongoDB schemas and relationships. - Auth middleware in
backend/src/middlewares/authMiddleware.jsenforces/derives user identity from JWTs. - Config helpers live in:
- DB connection:
backend/src/config/db.js - Mailer:
backend/src/config/mailer.js - Google OAuth:
backend/src/config/passport.js - Slug generation:
backend/src/config/slugify.js
- DB connection:
- Elasticsearch initialization + sync:
- Client:
backend/src/elastic/esClient.js - Index creation:
backend/src/elastic/esIndex.js - MongoDB→ES synchronization:
backend/src/elastic/mongoToEsSync.js
- Client:
- Seeding script:
backend/src/seed/seed.js
backend/src/routes/: Express routers + auth middleware wiring (mount points are defined in server.js)backend/src/controllers/: Request handling, DB reads/writes, aggregation pipelines, payment flowsbackend/src/models/: Mongoose schemas (refs + validation)backend/src/middlewares/: JWT cookie auth (required/optional) + helper middlewaresbackend/src/config/: DB, mailer, OAuth strategy, slugify helperbackend/src/elastic/: Elasticsearch client + indexing + MongoDB change-stream synchronization
Note: There is no separate “services” layer in this repo; controllers call models directly.
All endpoints below are mounted by backend/src/server.js under:
/api/auth/api/users/api/addresses/api/brands/api/categories/api/products/api/discount-codes/api/product-images/api/product-variants/api/carts/api/cart-items/api/orders/api/order-items/api/dashboard/api/reviews/api/payments/vnpay
- Most authenticated user endpoints use a JWT stored in an HTTP-only cookie named
accessToken(set during login / Google callback). - There is also helper middleware for
Authorization: Bearer <token>auth, but it is not wired to any route in this repo. - “Auth required” below refers to what the route currently enforces in code.
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| POST | /api/auth/signup |
No | — | json\n{"fullName":"Jane Doe","email":"jane@example.com","password":"secret"}\n |
json\n{"message":"Tài khoản được đăng ký thành công! Vui lòng kiểm tra email để xác thực tài khoản."}\n |
| POST | /api/auth/login |
No | — | json\n{"email":"jane@example.com","password":"secret"}\n |
json\n{"message":"Đăng nhập thành công","user":{"id":"...","full_name":"Jane Doe","email":"jane@example.com","role":"customer"}}\n |
| POST | /api/auth/logout |
No | — | — | json\n{"message":"Đăng xuất thành công"}\n |
| GET | /api/auth/me |
Yes (cookie accessToken) |
— | — | json\n{"message":"User authenticated","user":{"_id":"...","fullName":"...","email":"...","role":"...","cartId":"..."}}\n |
| GET | /api/auth/google |
No | — | — | Redirects to Google OAuth (Passport) |
| GET | /api/auth/oauth2/redirect |
No | — | — | Sets cookie then redirects to CLIENT_URL |
| GET | /api/auth/verify-email |
No | token |
— | json\n{"message":"Email đã được xác thực thành công!"}\n |
| POST | /api/auth/resend-verification |
No | — | json\n{"email":"jane@example.com"}\n |
json\n{"message":"Email xác thực đã được gửi lại"}\n |
| POST | /api/auth/guest-checkout-init |
No | — | json\n{"email":"guest@example.com","localCartItems":[{"variantId":"...","quantity":2,"price":1200000}]}\n |
json\n{"message":"Link đăng nhập nhanh đã được gửi. Vui lòng kiểm tra email để tiếp tục."}\n |
| GET | /api/auth/verify-guest-token |
No | token |
— | Sets cookie then redirects to frontend (/checkout or /profile?...) |
| POST | /api/auth/forgot-password |
No | — | json\n{"email":"jane@example.com"}\n |
json\n{"message":"Email đặt lại mật khẩu đã được gửi."}\n |
| POST | /api/auth/reset-password/:token |
No | — | json\n{"password":"newSecret"}\n |
json\n{"message":"Mật khẩu đã được đặt lại thành công."}\n |
| POST | /api/auth/admin/login |
No | — | json\n{"email":"admin@example.com","password":"secret"}\n |
json\n{"message":"Đăng nhập quản trị thành công","token":"<jwt>","user":{"id":"...","full_name":"...","email":"...","role":"admin"}}\n |
Notes:
loginsetsaccessTokencookie withsecure: trueandsameSite: "None".- Email verification tokens are signed with
EMAIL_KEY; auth tokens useJWT_KEY.
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| PUT | /api/users/profile |
Yes (cookie accessToken) |
— | json\n{"fullName":"New Name"}\n |
json\n{"id":"...","fullName":"New Name","email":"...","role":"..."}\n |
| PUT | /api/users/change-password |
Yes (cookie accessToken) |
— | json\n{"currentPassword":"old","newPassword":"new"}\n |
json\n{"message":"Đổi mật khẩu thành công"}\n |
| GET | /api/users/ |
No | — | — | json\n[{"_id":"...","fullName":"...","email":"...","role":"...","banned":false}]\n |
| PUT | /api/users/:id |
No | — | json\n{"fullName":"...","email":"...","role":"admin"}\n |
json\n{"_id":"...","fullName":"...","email":"...","role":"admin","isVerified":true,"banned":false}\n |
| PATCH | /api/users/:id/ban-status |
No | — | json\n{"banned":true}\n |
json\n{"_id":"...","fullName":"...","email":"...","banned":true,"message":"Đã cấm người dùng"}\n |
All routes in this router call authenticateUser via router.use(authenticateUser).
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| POST | /api/addresses/ |
Yes (cookie accessToken) |
— | json\n{"addressLine":"...","province":"...","ward":"...","phoneNumber":"...","isDefault":true}\n |
json\n{"_id":"...","userId":"...","addressLine":"...","province":"...","ward":"...","phoneNumber":"...","isDefault":true}\n |
| GET | /api/addresses/ |
Yes (cookie accessToken) |
— | — | json\n[{"_id":"...","addressLine":"...","isDefault":true}]\n |
| GET | /api/addresses/ (duplicate registration) |
Yes (cookie accessToken) |
— | — | Intended to fetch a single address, but the route is registered as GET / again while the controller expects :id |
| PUT | /api/addresses/:id |
Yes (cookie accessToken) |
— | json\n{"addressLine":"...","province":"...","ward":"...","phoneNumber":"...","isDefault":false}\n |
json\n{"_id":"...","addressLine":"...","isDefault":false}\n |
| DELETE | /api/addresses/:id |
Yes (cookie accessToken) |
— | — | json\n{"message":"Xóa địa chỉ thành công"}\n |
| PUT | /api/addresses/:id/set-default |
Yes (cookie accessToken) |
— | — | json\n{"message":"Cập nhật địa chỉ mặc định thành công","address":{"_id":"...","isDefault":true}}\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/brands/ |
No | — | — | json\n[{"_id":"...","name":"Nike","slug":"nike","description":"...","imageSrc":"..."}]\n |
| GET | /api/brands/:slug |
No | — | — | json\n{"brand":{"_id":"...","name":"Nike","slug":"nike"}}\n |
| POST | /api/brands/ |
No | — | json\n{"name":"Nike","description":"...","imageSrc":"..."}\n |
json\n{"_id":"...","name":"Nike","slug":"nike","description":"...","imageSrc":"..."}\n |
| PUT | /api/brands/:id |
No | — | json\n{"name":"New Name"}\n |
json\n{"_id":"...","name":"New Name","slug":"new-name"}\n |
| DELETE | /api/brands/:id |
No | — | — | json\n{"message":"Nhãn hàng được xóa thành công"}\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/categories/ |
No | — | — | json\n[{"_id":"...","name":"Giày đá bóng","slug":"giay-da-bong"}]\n |
| GET | /api/categories/:slug |
No | — | — | json\n{"category":{"_id":"...","name":"...","slug":"..."}}\n |
| POST | /api/categories/ |
No | — | json\n{"name":"...","description":"...","imageSrc":"..."}\n |
json\n{"_id":"...","name":"...","slug":"..."}\n |
| PUT | /api/categories/:id |
No | — | json\n{"name":"Updated"}\n |
json\n{"_id":"...","name":"Updated","slug":"updated"}\n |
| DELETE | /api/categories/:id |
No | — | — | json\n{"message":"Loại sản phẩm được xóa thành công"}\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/products/ |
No | search, categoryId, brandId, minPrice, maxPrice, color, size, sort |
— | json\n[{"_id":"...","name":"...","basePrice":1000000,"imageUrl":"...","variants":[...]}]\n |
| GET | /api/products/filters |
No | — | — | json\n{"colors":["Black","White"],"sizes":["M","L"],"maxPrice":3500000}\n |
| GET | /api/products/feature |
No | limit |
— | json\n[{"id":"...","name":"...","price":900000,"oldPrice":1000000,"salePercent":10,"imageUrl":"..."}]\n |
| GET | /api/products/discount |
No | limit |
— | json\n[{"id":"...","name":"...","price":800000,"oldPrice":1000000,"salePercent":20,"imageUrl":"..."}]\n |
| GET | /api/products/search |
No | q (required), sort |
— | json\n[{"_id":"...","name":"...","imageUrl":"..."}]\n |
| GET | /api/products/:id |
No | — | — | json\n{"_id":"...","name":"...","description":"...","basePrice":900000,"oldPrice":1000000,"discountPercent":10,"brand":"Nike","images":["..."],"variants":[{"_id":"...","sku":"...","price":...,"stock":...,"size":"M","color":"Black"}]}\n |
| GET | /api/products/brand/:slug |
No | — | — | json\n[{"_id":"...","name":"...","variants":[...],"brand":{...},"category":{...},"imageUrl":"..."}]\n |
| GET | /api/products/category/:slug |
No | — | — | json\n[{"_id":"...","name":"...","variants":[...],"brand":{...},"category":{...},"imageUrl":"..."}]\n |
| GET | /api/products/:productId/variants |
No | — | — | json\n[{"sku":"...","price":1200000,"stock":20,"attributes":{"color":"Black","size":"M"}}]\n |
| GET | /api/products/:productId/images |
No | — | — | json\n[{"_id":"...","productId":"...","url":"...","alt":"..."}]\n |
| POST | /api/products/ |
No | — | json\n{"categoryId":"...","brandId":"...","name":"...","description":"...","basePrice":1000000,"sales":0,"discountPercent":0}\n |
json\n{"_id":"...","categoryId":"...","brandId":"...","name":"...","basePrice":1000000,"discountPercent":0}\n |
| PUT | /api/products/:id |
No | — | json\n{"name":"Updated","discountPercent":15}\n |
json\n{"_id":"...","name":"Updated","discountPercent":15}\n |
| DELETE | /api/products/:id |
No | — | — | json\n{"message":"Sản phẩm được xóa thành công"}\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/discount-codes/ |
No | — | — | json\n[{"_id":"...","code":"SALE10","discount_value":10,"max_usage":100,"times_used":0}]\n |
| POST | /api/discount-codes/ |
No | — | json\n{"code":"SALE10","discount_value":10,"max_usage":100}\n |
json\n{"_id":"...","code":"SALE10","discount_value":10,"max_usage":100,"times_used":0}\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/carts/:id |
No | — | — | json\n{"_id":"...","userId":"...","createdAt":"..."}\n |
| GET | /api/carts/:cartId/items |
No | — | — | json\n[{"_id":"...","quantity":2,"variant":{"_id":"...","price":...,"attributes":{...}},"product":{"_id":"...","name":"..."}}]\n |
| POST | /api/carts/ |
No | — | json\n{"userId":"..."}\n |
json\n{"_id":"...","userId":"..."}\n |
| PUT | /api/carts/:id |
No | — | json\n{"userId":"..."}\n |
json\n{"_id":"...","userId":"..."}\n |
| DELETE | /api/carts/:id |
No | — | — | json\n{"message":"Giỏ hàng được xóa thành công"}\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/cart-items/:cartId |
No | — | — | json\n[{"_id":"...","quantity":1,"variant":{"_id":"...","price":...,"attributes":{...}},"product":{"_id":"...","name":"..."}}]\n |
| POST | /api/cart-items/ |
Yes (cookie accessToken) |
— | json\n{"product_variant_id":"...","quantity":2}\n |
json\n{"_id":"...","cart_id":"...","product_variant_id":"...","quantity":2}\n |
| PUT | /api/cart-items/:id |
No | — | json\n{"quantity":3}\n |
json\n{"_id":"...","quantity":3}\n |
| PUT | /api/cart-items/:id/increase |
No | — | — | json\n{"_id":"...","quantity":2}\n |
| PUT | /api/cart-items/:id/decrease |
No | — | — | json\n{"_id":"...","quantity":1}\n |
| DELETE | /api/cart-items/:id |
No | — | — | json\n{"message":"CartItem deleted successfully"}\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/orders/my-orders |
Yes (cookie accessToken) |
page (default 1), limit (default 5) |
— | json\n{"orders":[...],"currentPage":1,"totalPages":2,"totalOrders":7}\n |
| GET | /api/orders/ |
No | — | — | json\n[{"id":"...","customer":{"fullName":"...","email":"...","phone":"..."},"items":[...],"subtotal":...,"total":...,"status":"pending"}]\n |
| GET | /api/orders/:id |
No | — | — | json\n{"_id":"...","user_id":"...","address_id":"...","total_price":...,"status":"pending","payment_status":"unpaid"}\n |
| GET | /api/orders/:orderId/items |
No | — | — | json\n[{"_id":"...","order_id":"...","product_variant_id":{...},"quantity":2,"price":1000000,"name":"Product name","image":"..."}]\n |
| POST | /api/orders/ |
No | — | json\n{"user_id":"...","address_id":"...","discount_code_id":null,"total_price":1200000,"loyalty_points_used":0,"loyalty_points_earned":0,"payment_method":"cash"}\n |
json\n{"_id":"...","user_id":"...","address_id":"...","total_price":1200000,"status":"pending","payment_status":"unpaid"}\n |
| PUT | /api/orders/:id/status |
No | — | json\n{"status":"confirmed","paymentStatus":"paid"}\n |
json\n{"message":"Cập nhật thành công","order":{"_id":"...","status":"confirmed","payment_status":"paid"}}\n |
| PUT | /api/orders/:id |
No | — | json\n{"status":"shipping"}\n |
json\n{"_id":"...","status":"shipping"}\n |
| DELETE | /api/orders/:id |
No | — | — | json\n{"message":"Đơn hàng được xóa thành công"}\n |
Order status transitions (as implemented in the Order model):
- Confirming a pending order deducts stock for each order item variant and adjusts loyalty points.
- Cancelling may restock (depending on previous status) and refunds used points.
- VNPay-confirmed orders mark
payment_statusaspaid.
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| POST | /api/order-items/ |
No | — | json\n{"order_id":"...","product_variant_id":"...","quantity":2,"price":1000000}\n |
json\n{"_id":"...","order_id":"...","product_variant_id":"...","quantity":2,"price":1000000}\n |
| PUT | /api/order-items/:id |
No | — | json\n{"quantity":3}\n |
json\n{"_id":"...","quantity":3}\n |
| DELETE | /api/order-items/:id |
No | — | — | json\n{"message":"OrderItem deleted successfully"}\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/reviews/:productId |
No | — | — | json\n[{"_id":"...","rating":5,"comment":"Great","author":"Jane Doe","createdAt":"..."}]\n |
| POST | /api/reviews/ |
Optional (cookie accessToken) |
— | json\n{"productId":"...","rating":5,"comment":"Nice!","guest_name":"Guest"}\n |
json\n{"_id":"...","product_id":"...","user_id":"...","rating":5,"comment":"Nice!"}\n |
Review rules (enforced in the model):
- Logged-in users must provide
rating. - Guests cannot provide
rating(it must benull).
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/product-images/:id |
No | — | — | json\n{"_id":"...","productId":"...","url":"...","alt":"..."}\n |
| POST | /api/product-images/ |
No | — | json\n{"productId":"...","url":"...","alt":"..."}\n |
json\n{"_id":"...","productId":"...","url":"...","alt":"..."}\n |
| PUT | /api/product-images/:id |
No | — | json\n{"alt":"Updated alt"}\n |
json\n{"_id":"...","alt":"Updated alt"}\n |
| DELETE | /api/product-images/:id |
No | — | — | json\n{"message":"Ảnh sản phẩm đã được xóa thành công"}\n |
Also available via Products router:
GET /api/products/:productId/images(returns images byproductId)
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/product-variants/:id |
No | — | — | json\n{"_id":"...","productId":"...","sku":"SKU123","price":1200000,"stock":10,"attributes":{"color":"Black","size":"M"}}\n |
| POST | /api/product-variants/ |
No | — | json\n{"productId":"...","sku":"SKU123","price":1200000,"stock":10,"attributes":{"color":"Black","size":"M"}}\n |
json\n{"_id":"...","sku":"SKU123","price":1200000,"stock":10}\n |
| PUT | /api/product-variants/:id |
No | — | json\n{"stock":25}\n |
json\n{"_id":"...","stock":25}\n |
| DELETE | /api/product-variants/:id |
No | — | — | json\n{"message":"Biến thể sản phẩm đã được xóa thành công"}\n |
Also available via Products router:
GET /api/products/:productId/variants(returns variants byproductId)
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| GET | /api/dashboard/metrics |
No | — | — | json\n{"totalUsers":100,"newUsers":5,"totalOrders":20,"totalRevenue":123456789}\n |
| GET | /api/dashboard/topselling |
No | — | — | json\n[{"rank":1,"name":"Product A","sales":50,"revenue":50000000}]\n |
| GET | /api/dashboard/revenue-chart |
No | month (1-12), year |
— | json\n[{"date":"1/4","revenue":0,"profit":0,"orders":0,"products":0}]\n |
| Method | Path | Auth required? | Query params | Request body | Response example |
|---|---|---|---|---|---|
| POST | /api/payments/vnpay/pay/:orderId |
Yes (cookie accessToken) |
— | — | json\n{"paymentUrl":"https://sandbox.vnpayment.vn/..."}\n |
| GET | /api/payments/vnpay/vnpay-return |
No | VNPay return query params | — | Redirects to CLIENT_URL/payment-failed or CLIENT_URL/order-success/:orderId |
| GET | /api/payments/vnpay/vnpay-ipn |
No | VNPay IPN query params | — | Returns VNPay IPN JSON (e.g., success / invalid amount / checksum fail) |
IPN handling (as implemented):
- Verifies VNPay signature and success status.
- Updates order status to
confirmedon success (which can also markpayment_statusaspaidfor VNPay). - Clears the user’s cart items after successful payment.
- Sends an “order success” email.
- Middleware:
authenticateUserinbackend/src/middlewares/authMiddleware.js - Reads
req.cookies.accessToken, verifies withJWT_KEY, and setsreq.user. - Also loads the user’s Cart and adds
cartIdtoreq.userwhen available.
- Middleware:
authenticateUserOptionalsetsreq.userwhen a valid cookie token exists; otherwise keeps requests as guest.
authorizeAdminexists but is not used on any route in this repo.verifyToken(Bearer-token auth) exists but is also not used on any route in this repo.
Mongoose models live in backend/src/models/.
- User: full name, email, hashed password, role (
customer/admin), verification, loyalty points, banned flag, reset tokens - Brand: name, slug (auto-generated), description, image
- Category: name, slug (auto-generated), description, image
- Product: belongs to Brand and Category; base price, discount percent, sales counter
- ProductVariant: belongs to Product; sku, price, stock, attributes (color/size)
- ProductImage: belongs to either Product or ProductVariant; url/alt
- Cart: one per User
- CartItem: belongs to Cart; references ProductVariant; quantity
- Order: belongs to User + Address; optional DiscountCode; totals; loyalty points; status and payment status
- OrderItem: belongs to Order; references ProductVariant; quantity and price snapshot
- Address: belongs to User; address fields + default flag
- Review: belongs to Product; optionally belongs to User (guest allowed)
- Product → Brand (
brandId), Category (categoryId) - ProductVariant → Product (
productId) - ProductImage → Product (
productId) OR ProductVariant (productVariantId) - Cart → User (
userId, unique) - CartItem → Cart (
cart_id), ProductVariant (product_variant_id) - Order → User (
user_id), Address (address_id), DiscountCode (discount_code_id) - OrderItem → Order (
order_id), ProductVariant (product_variant_id) - Address → User (
userId) - Review → Product (
product_id), optionally User (user_id)
There is no centralized Express error-handling middleware registered in backend/src/server.js. Each controller generally:
- Uses
try/catch - Returns JSON errors like
{ "message": "..." }or{ "error": "..." } - Uses standard HTTP status codes (commonly 400/401/403/404/500)
These are read via process.env in the backend code:
| Variable | Used for |
|---|---|
PORT |
Backend listen port (defaults to 5000) |
ATLAS_URI |
MongoDB connection string (Mongoose) |
JWT_KEY |
Signing/verifying auth JWTs |
EMAIL_KEY |
Signing/verifying email verification tokens |
SERVER_URL |
OAuth callback + magic link URL generation |
CLIENT_URL |
CORS allowed origin + frontend redirects (OAuth/VNPay/guest flow) |
ADMIN_CLIENT_URL |
CORS allowed origin for admin frontend |
NODE_ENV |
Cookie flags (secure, sameSite) in some flows |
GOOGLE_CLIENT_ID |
Google OAuth |
GOOGLE_CLIENT_SECRET |
Google OAuth |
MAIL_HOST |
SMTP host |
MAIL_PORT |
SMTP port |
MAIL_USERNAME |
SMTP username |
MAIL_PASSWORD |
SMTP password |
FROM_EMAIL |
“From” email in outbound mails |
ELASTIC_SEARCH_URL |
Elasticsearch node URL |
VNPAY_TMN_CODE |
VNPay merchant code |
VNPAY_HASH_SECRET |
VNPay hash secret |
VNPAY_RETURN_URL |
VNPay return URL (sent to VNPay) |
Prereqs:
- Node.js (Docker uses Node 22; local dev should use a modern Node version)
- MongoDB reachable via
ATLAS_URI - Elasticsearch reachable via
ELASTIC_SEARCH_URL(required by the product search + startup indexing)
Steps:
- Install dependencies:
cd backend npm install - Create backend env file (example keys):
PORT=5000 ATLAS_URI=mongodb+srv://... JWT_KEY=... EMAIL_KEY=... SERVER_URL=http://localhost:5000 CLIENT_URL=http://localhost:5173 ADMIN_CLIENT_URL=http://localhost:5174 GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... MAIL_HOST=... MAIL_PORT=587 MAIL_USERNAME=... MAIL_PASSWORD=... FROM_EMAIL=... ELASTIC_SEARCH_URL=http://localhost:9200 VNPAY_TMN_CODE=... VNPAY_HASH_SECRET=... VNPAY_RETURN_URL=http://localhost:5000/api/payments/vnpay/vnpay-return NODE_ENV=development
- Start the server:
npm run dev
On startup, the backend:
- Connects to MongoDB
- Ensures the Elasticsearch
productsindex exists - Bulk indexes products from MongoDB into Elasticsearch
- Starts a MongoDB change stream to keep Elasticsearch in sync
The seeding script uses ATLAS_URI:
cd backend
npm run seedThe repo contains a compose setup in docker-compose.yml that runs:
- backend (scaled replicas)
- nginx (reverse proxy)
- elasticsearch
- frontend + frontend_admin
- ngrok container
Typical start:
docker compose up --buildNote:
- The compose file references env files (e.g.,
backend/.env.docker,frontend/.env.docker). Create those files with the variables your environment needs.
There are two React/Vite clients:
- User storefront in the
frontendfolder (React 19, Vite, Tailwind, Axios) - Admin UI in the
frontend_adminfolder (React 19, Vite, Tailwind, Axios, Recharts)
They communicate with the backend through the /api/* routes, and the backend’s CORS configuration allows origins from CLIENT_URL and ADMIN_CLIENT_URL.