├── .gitignore ├── .prettierrc ├── babel.config.js ├── .husky └── pre-commit ├── src ├── constants │ └── constants.js ├── loggers │ ├── morgan.logger.js │ └── winston.logger.js ├── utils │ ├── asyncHandler.js │ ├── sendEmail.js │ ├── appError.js │ ├── timeUtils.js │ └── currencyUtils.js ├── config │ ├── db.js │ ├── email.config.js │ ├── config.js │ ├── passport.js │ └── redis.js ├── validators │ ├── common.validator.js │ ├── user.validator.js │ ├── cart.validator.js │ ├── auth.validator.js │ ├── product.validator.js │ └── payment.validator.js ├── routes │ ├── user.routes.js │ ├── cart.routes.js │ ├── product.routes.js │ ├── auth.routes.js │ └── payment.routes.js ├── controllers │ ├── user.controller.js │ ├── cart.controller.js │ ├── product.controller.js │ ├── payment.controller.js │ └── auth.controller.js ├── models │ ├── product.model.js │ ├── cart.model.js │ ├── user.model.js │ └── payment.model.js ├── app.js ├── middlewares │ ├── errorHandler.js │ ├── auth.middleware.js │ ├── validator.middleware.js │ └── rateLimiter.middleware.js ├── __tests__ │ ├── currencyUtils.test.js │ ├── user.test.js │ ├── auth.test.js │ └── product.test.js ├── dao │ ├── user.dao.js │ ├── product.dao.js │ ├── cart.dao.js │ └── payment.dao.js └── services │ ├── product.service.js │ ├── cart.service.js │ └── user.service.js ├── .vscode └── settings.json ├── jest.config.js ├── .env.example ├── eslint.config.js ├── Dockerfile ├── .dockerignore ├── .github └── workflows │ └── deploy.yml ├── readme.md ├── package.json ├── server.js └── jest.setup.js /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }]], 3 | }; 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint 5 | npm run format 6 | npm run test -------------------------------------------------------------------------------- /src/constants/constants.js: -------------------------------------------------------------------------------- 1 | export const ACCESS_TOKEN_EXPIRATION = '1h'; 2 | export const REFRESH_TOKEN_EXPIRATION = '30d'; 3 | export const FORGOT_PASSWORD_TOKEN_EXPIRATION = '15m'; 4 | export const VERIFICATION_TOKEN_EXPIRATION = '10m'; 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "workbench.colorTheme": "Night Owl Black (No Italics)", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": "always", 7 | "source.fixAll.eslint": "always" 8 | }, 9 | "eslint.validate": ["javascript"] 10 | } 11 | -------------------------------------------------------------------------------- /src/loggers/morgan.logger.js: -------------------------------------------------------------------------------- 1 | import morgan from 'morgan'; 2 | import logger from './winston.logger.js'; 3 | 4 | // Create a custom token format for the log 5 | const format = 6 | ':remote-addr :method :url :status :res[content-length] - :response-time ms'; 7 | 8 | // Create Morgan middleware using Winston for logging 9 | const morganLogger = morgan(format, { 10 | stream: logger.stream, 11 | }); 12 | 13 | export default morganLogger; 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // jest.config.js 2 | export default { 3 | testEnvironment: 'node', // Use Node.js environment for testing 4 | transform: { 5 | '^.+\\.js$': 'babel-jest', 6 | }, 7 | moduleFileExtensions: ['js', 'json', 'node'], 8 | clearMocks: true, 9 | coverageDirectory: 'coverage', 10 | coveragePathIgnorePatterns: ['/node_modules/', '/logs/', '/coverage/'], 11 | coverageProvider: 'v8', 12 | setupFilesAfterEnv: ['./jest.setup.js'], 13 | testTimeout: 30000, 14 | }; 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | DB_URL=mongodb://host:example.com 3 | JWT_SECRET=JWT_SECRET_KEY 4 | GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID 5 | GOOGLE_CLIENT_SECRET=GOOGLE_CLIENT_SECRET 6 | GMAIL_USER=GOOGLE_GMAIL_USER 7 | GOOGLE_REFRESH_TOKEN=GOOGLE_REFRESH_TOKEN 8 | GOOGLE_CALLBACK_URL=http://localhost:3000/api/v1/auth/google/callback 9 | REDIS_HOST=127.0.0.1 10 | REDIS_PORT=6379 11 | REDIS_PASSWORD= 12 | REDIS_USERNAME= 13 | REDIS_DB=0 14 | AWS_REGION=ap-south-1 15 | RAZORPAY_KEY_ID=rzp_test_key 16 | RAZORPAY_KEY_SECRET=rzp_test_secret 17 | -------------------------------------------------------------------------------- /src/utils/asyncHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Async handler wrapper to avoid try-catch blocks in route controllers 3 | * Uses promise chaining with .then() and .catch() as requested 4 | * 5 | * @param {Function} fn - The async function to wrap 6 | * @returns {Function} - Express middleware function 7 | */ 8 | const asyncHandler = (fn) => (req, res, next) => { 9 | Promise.resolve(fn(req, res, next)).catch((error) => { 10 | next(error); // Pass any errors to Express error handling middleware 11 | }); 12 | }; 13 | 14 | export default asyncHandler; 15 | -------------------------------------------------------------------------------- /src/config/db.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import config from './config.js'; 3 | import logger from '../loggers/winston.logger.js'; 4 | 5 | function connectToDatabase() { 6 | const dbURI = config.DB_URL; 7 | 8 | mongoose 9 | .connect(dbURI, { 10 | useNewUrlParser: true, 11 | useUnifiedTopology: true, 12 | }) 13 | .then(() => { 14 | logger.info('Connected to MongoDB'); 15 | }) 16 | .catch((err) => { 17 | logger.error('Error connecting to MongoDB:', err); 18 | }); 19 | } 20 | 21 | export default connectToDatabase; 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslintPluginImport from 'eslint-plugin-import'; 2 | 3 | export default [ 4 | { 5 | files: ['**/*.js'], 6 | languageOptions: { 7 | ecmaVersion: 'latest', 8 | sourceType: 'module', 9 | }, 10 | plugins: { 11 | import: eslintPluginImport, 12 | }, 13 | rules: { 14 | 'no-unused-vars': 'warn', 15 | 'no-console': 'warn', 16 | 'import/order': [ 17 | 'warn', 18 | { groups: [['builtin', 'external', 'internal']] }, 19 | ], 20 | 'import/no-unresolved': 'error', 21 | }, 22 | }, 23 | ]; 24 | -------------------------------------------------------------------------------- /src/utils/sendEmail.js: -------------------------------------------------------------------------------- 1 | import { createTransporter } from '../config/email.config.js'; 2 | import config from '../config/config.js'; 3 | 4 | export const sendVerificationEmail = async (to, verificationLink) => { 5 | const transporter = await createTransporter(); 6 | const mailOptions = { 7 | from: `"Testdog" <${config.GMAIL_USER}>`, 8 | to, 9 | subject: 'Verify your email', 10 | html: ` 11 |

Click the link below to verify your email:

12 | ${verificationLink} 13 | `, 14 | }; 15 | 16 | return transporter.sendMail(mailOptions); 17 | }; 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 1. Use an official lightweight Node.js runtime image 2 | FROM node:20-alpine 3 | 4 | # 2. Set working directory inside the container 5 | WORKDIR /app 6 | 7 | # 3. Copy package files first to leverage Docker cache 8 | COPY package*.json ./ 9 | 10 | 11 | ENV NODE_ENV=production 12 | 13 | # 4. Install only production dependencies 14 | RUN npm ci --omit=dev 15 | 16 | # 5. Copy the rest of the app files 17 | COPY . . 18 | 19 | # 6. Set environment to production 20 | ENV NODE_ENV=production 21 | 22 | # 7. Expose the app port 23 | EXPOSE 3000 24 | 25 | # 8. Default command to run the app 26 | CMD ["npm", "start"] 27 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # .dockerignore 2 | 3 | # Git files 4 | .git 5 | .gitignore 6 | 7 | # Node dependencies 8 | node_modules 9 | 10 | # Build output 11 | dist 12 | *.tsbuildinfo 13 | 14 | # Environment files 15 | .env 16 | *.env.* 17 | !*.env.example 18 | 19 | # Log files 20 | *.log 21 | logs 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | pnpm-debug.log* 26 | lerna-debug.log* 27 | 28 | # Editor/OS files 29 | .vscode 30 | .idea 31 | *.suo 32 | *.ntvs* 33 | *.njsproj 34 | *.sln 35 | *.sw? 36 | Thumbs.db 37 | .DS_Store 38 | 39 | # Keys (should be injected as secrets/env vars in production) 40 | *.key 41 | *.pem 42 | 43 | # Development specific files 44 | nodemon.json -------------------------------------------------------------------------------- /src/utils/appError.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom AppError class to standardize error handling. 3 | * Can be thrown anywhere in the code to trigger error middleware. 4 | */ 5 | class AppError extends Error { 6 | /** 7 | * Create an operational error instance. 8 | * @param {string} message - Error message to show to client. 9 | * @param {number} statusCode - HTTP status code (e.g., 400, 404). 10 | */ 11 | constructor(message, statusCode) { 12 | super(message); 13 | 14 | this.statusCode = statusCode; 15 | this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; 16 | this.isOperational = true; 17 | 18 | // Capture stack trace excluding constructor call 19 | Error.captureStackTrace(this, this.constructor); 20 | } 21 | } 22 | 23 | export default AppError; 24 | -------------------------------------------------------------------------------- /src/validators/common.validator.js: -------------------------------------------------------------------------------- 1 | import { param } from 'express-validator'; 2 | import mongoose from 'mongoose'; 3 | 4 | export const validateMongoId = [ 5 | param('id') 6 | .notEmpty() 7 | .withMessage('ID parameter is required') 8 | .custom((value) => { 9 | if (!mongoose.Types.ObjectId.isValid(value)) { 10 | throw new Error('Invalid ID format'); 11 | } 12 | return true; 13 | }), 14 | ]; 15 | 16 | export const validateObjectId = (idField = 'id') => [ 17 | param(idField) 18 | .notEmpty() 19 | .withMessage(`${idField} parameter is required`) 20 | .custom((value) => { 21 | if (!mongoose.Types.ObjectId.isValid(value)) { 22 | throw new Error(`Invalid ${idField} format`); 23 | } 24 | return true; 25 | }), 26 | ]; 27 | -------------------------------------------------------------------------------- /src/config/email.config.js: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer'; 2 | import { google } from 'googleapis'; 3 | import config from './config.js'; 4 | 5 | const OAuth2 = google.auth.OAuth2; 6 | 7 | const oAuth2Client = new OAuth2( 8 | config.GOOGLE_CLIENT_ID, 9 | config.GOOGLE_CLIENT_SECRET, 10 | 'https://developers.google.com/oauthplayground' 11 | ); 12 | 13 | oAuth2Client.setCredentials({ refresh_token: config.GOOGLE_REFRESH_TOKEN }); 14 | 15 | export const createTransporter = async () => { 16 | const { token } = await oAuth2Client.getAccessToken(); 17 | 18 | return nodemailer.createTransport({ 19 | service: 'gmail', 20 | auth: { 21 | type: 'OAuth2', 22 | user: config.GMAIL_USER, 23 | clientId: config.GOOGLE_CLIENT_ID, 24 | clientSecret: config.GOOGLE_CLIENT_SECRET, 25 | refreshToken: config.GOOGLE_REFRESH_TOKEN, 26 | accessToken: token, 27 | }, 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /src/utils/timeUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a time string like '30d', '1h', '15m' to seconds 3 | * @param {string} timeString - Time string in format like '30d', '1h', '15m' 4 | * @returns {number} - Time in seconds 5 | */ 6 | export function timeStringToSeconds(timeString) { 7 | const regex = /^(\d+)([dhms])$/; 8 | const match = timeString.match(regex); 9 | 10 | if (!match) { 11 | throw new Error( 12 | `Invalid time format: ${timeString}. Expected format like '30d', '1h', '15m', '30s'` 13 | ); 14 | } 15 | 16 | const value = parseInt(match[1], 10); 17 | const unit = match[2]; 18 | 19 | switch (unit) { 20 | case 'd': 21 | return value * 24 * 60 * 60; // days to seconds 22 | case 'h': 23 | return value * 60 * 60; // hours to seconds 24 | case 'm': 25 | return value * 60; // minutes to seconds 26 | case 's': 27 | return value; // seconds 28 | default: 29 | return value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/routes/user.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import userController from '../controllers/user.controller.js'; 3 | import { protect, restrictTo } from '../middlewares/auth.middleware.js'; 4 | import { validate } from '../middlewares/validator.middleware.js'; 5 | import { 6 | getAllUsersValidator, 7 | updateUserValidator, 8 | } from '../validators/user.validator.js'; 9 | import { userRateLimiter } from '../middlewares/rateLimiter.middleware.js'; 10 | 11 | const router = express.Router(); 12 | 13 | // Apply rate limiting to all user routes 14 | router.use(userRateLimiter); 15 | 16 | // User routes 17 | router 18 | .route('/update') 19 | .patch(protect, validate(updateUserValidator), userController.updateUser); 20 | 21 | router.route('/getrandomuser').get(userController.getRandomUser); 22 | 23 | // Protected route - only admins should be able to get all users 24 | router 25 | .route('/all') 26 | .get( 27 | protect, 28 | restrictTo('admin'), 29 | validate(getAllUsersValidator), 30 | userController.getAllUsers 31 | ); 32 | 33 | export default router; 34 | -------------------------------------------------------------------------------- /src/routes/cart.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cartController from '../controllers/cart.controller.js'; 3 | import { protect } from '../middlewares/auth.middleware.js'; 4 | import { validate } from '../middlewares/validator.middleware.js'; 5 | import { 6 | addToCartValidator, 7 | updateCartItemValidator, 8 | removeCartItemValidator, 9 | } from '../validators/cart.validator.js'; 10 | 11 | const router = express.Router(); 12 | 13 | // All cart routes require authentication 14 | router.use(protect); 15 | 16 | // Cart summary - Get quick overview of cart 17 | router.route('/summary').get(cartController.getCartSummary); 18 | 19 | // Main cart routes 20 | router 21 | .route('/') 22 | .get(cartController.getCart) // Get cart items 23 | .post(validate(addToCartValidator), cartController.addToCart) // Add product to cart 24 | .delete(cartController.clearCart); // Clear entire cart 25 | 26 | // Cart item specific routes 27 | router 28 | .route('/:id') 29 | .patch(validate(updateCartItemValidator), cartController.updateCartItem) // Update item quantity 30 | .delete(validate(removeCartItemValidator), cartController.removeCartItem); // Remove item from cart 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /src/validators/user.validator.js: -------------------------------------------------------------------------------- 1 | import { body, query } from 'express-validator'; 2 | 3 | export const updateUserValidator = [ 4 | body('username') 5 | .optional() 6 | .isLength({ min: 3, max: 30 }) 7 | .withMessage('Username must be between 3 and 30 characters') 8 | .trim() 9 | .escape(), 10 | body('name') 11 | .optional() 12 | .isLength({ min: 2, max: 50 }) 13 | .withMessage('Name must be between 2 and 50 characters') 14 | .trim(), 15 | body('email') 16 | .optional() 17 | .isEmail() 18 | .withMessage('Please provide a valid email') 19 | .normalizeEmail(), 20 | body('avatar') 21 | .optional() 22 | .isURL() 23 | .withMessage('Avatar must be a valid URL') 24 | .trim(), 25 | // Password field removed as it cannot be updated through this route 26 | // Use dedicated password reset functionality instead 27 | ]; 28 | 29 | export const getAllUsersValidator = [ 30 | query('page') 31 | .optional() 32 | .isInt({ min: 1 }) 33 | .withMessage('Page must be a positive integer starting from 1.'), 34 | 35 | query('limit') 36 | .optional() 37 | .isInt({ min: 1, max: 50 }) 38 | .withMessage('Limit must be between 1 and 50.'), 39 | ]; 40 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | const _config = { 6 | NODE_ENV: process.env.NODE_ENV || 'development', 7 | PORT: process.env.PORT || 3000, 8 | DB_URL: process.env.DB_URL || 'mongodb://localhost:27017/mydatabase', 9 | JWT_SECRET: process.env.JWT_SECRET, 10 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 11 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, 12 | GOOGLE_REFRESH_TOKEN: process.env.GOOGLE_REFRESH_TOKEN, 13 | GOOGLE_CALLBACK_URL: process.env.GOOGLE_CALLBACK_URL, 14 | GMAIL_USER: process.env.GMAIL_USER, 15 | FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:3000', 16 | redis: { 17 | host: process.env.REDIS_HOST || '127.0.0.1', 18 | port: process.env.REDIS_PORT || 6379, 19 | password: process.env.REDIS_PASSWORD || '', 20 | username: process.env.REDIS_USERNAME || '', 21 | db: process.env.REDIS_DB || 0, 22 | }, 23 | razorpay: { 24 | keyId: process.env.RAZORPAY_KEY_ID || '', 25 | keySecret: process.env.RAZORPAY_KEY_SECRET || '', 26 | }, 27 | }; 28 | 29 | let finalConfig; 30 | if (process.env.NODE_ENV === 'testing') { 31 | finalConfig = _config; // Don't freeze in testing mode 32 | } else { 33 | finalConfig = Object.freeze(_config); 34 | } 35 | 36 | export default finalConfig; 37 | -------------------------------------------------------------------------------- /src/routes/product.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import productController from '../controllers/product.controller.js'; 3 | import { protect, restrictTo } from '../middlewares/auth.middleware.js'; 4 | import { validate } from '../middlewares/validator.middleware.js'; 5 | import { createProductValidator } from '../validators/product.validator.js'; 6 | import { productRateLimiter } from '../middlewares/rateLimiter.middleware.js'; 7 | 8 | const router = express.Router(); 9 | 10 | // Public routes - no authentication required 11 | router.route('/search').get(productController.searchProducts); 12 | router.route('/autocomplete').get(productController.getAutocomplete); 13 | router.route('/random').get(productController.getRandomProduct); 14 | 15 | // Get all products (with pagination) - Public 16 | router.route('/').get(productController.getAllProducts); 17 | 18 | // Get specific product by ID - Public 19 | router.route('/:id').get(productController.getProduct); 20 | 21 | // Protected routes - authentication required 22 | // Create product - Only authenticated admins 23 | router.route('/').post( 24 | productRateLimiter, // Rate limiting middleware 25 | protect, // Authentication middleware 26 | restrictTo('admin'), // Only admins can create products 27 | validate(createProductValidator), // Validation middleware 28 | productController.createProduct 29 | ); 30 | 31 | export default router; 32 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to AWS ECS 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | deploy: 9 | name: Build, Push, and Deploy 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repo 14 | uses: actions/checkout@v4 15 | 16 | - name: Configure AWS credentials 17 | uses: aws-actions/configure-aws-credentials@v4 18 | with: 19 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 20 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 21 | aws-region: ${{ secrets.AWS_REGION }} 22 | 23 | - name: Login to Amazon ECR 24 | id: login-ecr 25 | uses: aws-actions/amazon-ecr-login@v2 26 | 27 | - name: Build and Push Docker image to ECR 28 | run: | 29 | echo "deploying TestApp to AWS ECS" 30 | aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 329599656829.dkr.ecr.ap-south-1.amazonaws.com 31 | docker build -t testdog . 32 | docker tag testdog:latest 329599656829.dkr.ecr.ap-south-1.amazonaws.com/testdog:latest 33 | docker push 329599656829.dkr.ecr.ap-south-1.amazonaws.com/testdog:latest 34 | 35 | aws ecs update-service \ 36 | --cluster testdog-cluster \ 37 | --service testdog-api \ 38 | --force-new-deployment \ 39 | --region ap-south-1 \ 40 | --task-definition testdog-api 41 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Testdog API Server 2 | 3 | Testdog is an open-source, community-powered API server offering a growing collection of free-to-use APIs for your projects. Whether you're building a side project, a portfolio piece, or an MVP feel free to plug in and go! 4 | 5 | --- 6 | 7 | ## Use These APIs in Your Projects 8 | 9 | All APIs provided by Testdog are **completely free**. You can integrate them into your personal or commercial projects without any restrictions. 10 | 11 | - No API keys required 12 | - No rate limiting (unless explicitly mentioned) 13 | - Easy to explore and consume 14 | 15 | > ✨ Want a specific API? Raise a request or contribute by adding one yourself! 16 | 17 | --- 18 | 19 | ## Open for Contributions 20 | 21 | Testdog is **open-source**, and we're always looking for contributors! 22 | 23 | ### How to Contribute 24 | 25 | 1. Fork the repository 26 | 2. Create a feature branch 27 | 3. Add your API or improve existing ones 28 | 4. Raise a Pull Request (PR) 29 | 30 | If your PR aligns with our development direction and passes checks we’ll merge it. Simple as that. 31 | 32 | > Please make sure to follow best practices and write clean, modular code. 33 | 34 | --- 35 | 36 | ## Found a Bug or Issue? 37 | 38 | If you encounter any problems or have suggestions: 39 | 40 | - [Open an issue](https://github.com/yourusername/testdog/issues) describing the problem. 41 | - Be as detailed as possible so contributors can replicate and resolve it. 42 | 43 | --- 44 | 45 | ## Note 46 | 47 | Testdog is powered entirely by its contributors. The addition of any new API depends on community support and availability. So if something’s missing feel free to build it! 48 | 49 | --- 50 | -------------------------------------------------------------------------------- /src/loggers/winston.logger.js: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import config from '../config/config.js'; 3 | 4 | // Define custom log levels 5 | const levels = { 6 | error: 0, 7 | warn: 1, 8 | info: 2, 9 | http: 3, 10 | debug: 4, 11 | }; 12 | 13 | // Define different log level based on environment 14 | const getLevel = () => { 15 | const env = config.NODE_ENV || 'development'; 16 | 17 | switch (env) { 18 | case 'production': 19 | return 'info'; // Only logs info and above in production 20 | case 'testing': 21 | return 'warn'; // Only logs warn and above in testing 22 | default: 23 | return 'debug'; // Logs everything in development 24 | } 25 | }; 26 | 27 | // Define colors for each level 28 | const colors = { 29 | error: 'red', 30 | warn: 'yellow', 31 | info: 'blue', 32 | http: 'white', 33 | debug: 'white', 34 | }; 35 | 36 | // Add colors to winston 37 | winston.addColors(colors); 38 | 39 | // Define format for console logs with colors 40 | const consoleFormat = winston.format.combine( 41 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 42 | winston.format.colorize({ all: true }), 43 | winston.format.printf( 44 | (info) => `${info.timestamp} [${info.level}]: ${info.message}` 45 | ) 46 | ); 47 | 48 | // Create the logger with only console transport 49 | const logger = winston.createLogger({ 50 | level: getLevel(), 51 | levels, 52 | transports: [ 53 | // Console transport only 54 | new winston.transports.Console({ 55 | format: consoleFormat, 56 | }), 57 | ], 58 | // Don't exit on uncaught exceptions 59 | exitOnError: false, 60 | }); 61 | 62 | // Create a stream object for morgan integration 63 | logger.stream = { 64 | write: (message) => { 65 | logger.http(message.trim()); 66 | }, 67 | }; 68 | 69 | export default logger; 70 | -------------------------------------------------------------------------------- /src/routes/auth.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import passport from 'passport'; 3 | import authController from '../controllers/auth.controller.js'; 4 | import { protect } from '../middlewares/auth.middleware.js'; 5 | import { validate } from '../middlewares/validator.middleware.js'; 6 | import { 7 | registerRateLimiter, 8 | authRateLimiter, 9 | } from '../middlewares/rateLimiter.middleware.js'; 10 | import { 11 | registerValidator, 12 | loginValidator, 13 | verifyEmailValidator, 14 | verifyEmailTokenValidator, 15 | } from '../validators/auth.validator.js'; 16 | 17 | const router = express.Router(); 18 | 19 | router.use(authRateLimiter); // Apply general auth rate limiting to all routes 20 | 21 | // Authentication routes 22 | router 23 | .route('/register') 24 | .post( 25 | registerRateLimiter, 26 | validate(registerValidator), 27 | authController.register 28 | ); 29 | router.route('/login').post(validate(loginValidator), authController.login); 30 | router.route('/logout').get(protect, authController.logout); 31 | 32 | // User data routes 33 | router.route('/getme').get(protect, authController.getMe); 34 | 35 | // Token management routes 36 | router.route('/access-token').get(authController.generateAccessToken); 37 | 38 | // Email verification routes 39 | router 40 | .route('/verify-email') 41 | .post(validate(verifyEmailValidator), authController.verifyEmail) 42 | .get(validate(verifyEmailTokenValidator), authController.verifyEmailToken); 43 | 44 | // OAuth routes 45 | router.route('/google').get( 46 | passport.authenticate('google', { 47 | scope: ['profile', 'email'], 48 | }) 49 | ); 50 | 51 | router.route('/google/callback').get( 52 | passport.authenticate('google', { 53 | failureRedirect: '/login-failed', // Redirect to failure page if authentication fails 54 | session: false, 55 | }), 56 | authController.googleCallback 57 | ); 58 | 59 | export default router; 60 | -------------------------------------------------------------------------------- /src/routes/payment.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import paymentController from '../controllers/payment.controller.js'; 3 | import { protect } from '../middlewares/auth.middleware.js'; 4 | import { 5 | createCartOrderValidator, 6 | createSingleProductOrderValidator, 7 | verifyPaymentValidator, 8 | getPaymentHistoryValidator, 9 | requestRefundValidator, 10 | getPaymentStatsValidator, 11 | getPaymentDetailsValidator, 12 | validate, 13 | } from '../validators/payment.validator.js'; 14 | 15 | const router = express.Router(); 16 | 17 | // Webhook endpoint (no auth required) 18 | router.post('/webhook', paymentController.handleWebhook); 19 | 20 | // Protected routes - require authentication 21 | router.use(protect); 22 | 23 | // Create orders 24 | router.post( 25 | '/orders/cart', 26 | createCartOrderValidator, 27 | validate, 28 | paymentController.createCartOrder 29 | ); 30 | 31 | router.post( 32 | '/orders/single-product', 33 | createSingleProductOrderValidator, 34 | validate, 35 | paymentController.createSingleProductOrder 36 | ); 37 | 38 | // Verify payment 39 | router.post( 40 | '/verify', 41 | verifyPaymentValidator, 42 | validate, 43 | paymentController.verifyPayment 44 | ); 45 | 46 | // Get payment details 47 | router.get( 48 | '/details/:paymentId', 49 | getPaymentDetailsValidator, 50 | validate, 51 | paymentController.getPaymentDetails 52 | ); 53 | 54 | // Get payment history 55 | router.get( 56 | '/history', 57 | getPaymentHistoryValidator, 58 | validate, 59 | paymentController.getPaymentHistory 60 | ); 61 | 62 | // Request refund 63 | router.post( 64 | '/refund/:paymentId', 65 | requestRefundValidator, 66 | validate, 67 | paymentController.requestRefund 68 | ); 69 | 70 | // Admin only routes 71 | router.get( 72 | '/stats', 73 | getPaymentStatsValidator, 74 | validate, 75 | paymentController.getPaymentStats 76 | ); 77 | 78 | export default router; 79 | -------------------------------------------------------------------------------- /src/config/passport.js: -------------------------------------------------------------------------------- 1 | import passport from 'passport'; 2 | import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; 3 | import userService from '../services/user.service.js'; 4 | import userDao from '../dao/user.dao.js'; 5 | import config from './config.js'; 6 | import logger from '../loggers/winston.logger.js'; 7 | 8 | passport.use( 9 | new GoogleStrategy( 10 | { 11 | clientID: config.GOOGLE_CLIENT_ID, 12 | clientSecret: config.GOOGLE_CLIENT_SECRET, 13 | callbackURL: config.GOOGLE_CALLBACK_URL, 14 | }, 15 | async (accessToken, refreshToken, profile, done) => { 16 | try { 17 | logger.debug('Google profile:', JSON.stringify(profile)); 18 | 19 | // Find user by googleId or email 20 | let user = await userDao.findByGoogleId(profile.id); 21 | 22 | if (!user && profile.emails && profile.emails.length > 0) { 23 | // If not found by googleId, try to find by email 24 | user = await userDao.findByEmail(profile.emails[0].value); 25 | 26 | if (user) { 27 | // If found by email, link Google account 28 | user.googleId = profile.id; 29 | await user.save(); 30 | } 31 | } 32 | 33 | if (!user) { 34 | // Create a new user if not found 35 | user = await userService.registerUser({ 36 | googleId: profile.id, 37 | email: profile.emails[0].value, 38 | name: profile.displayName, 39 | avatar: 40 | profile.photos && profile.photos.length > 0 41 | ? profile.photos[0].value 42 | : undefined, 43 | }); 44 | logger.info(`New user created via Google: ${user.email}`); 45 | } 46 | 47 | return done(null, user); 48 | } catch (err) { 49 | logger.error('Google authentication error:', err); 50 | return done(err, null); 51 | } 52 | } 53 | ) 54 | ); 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "testdog", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "server.js", 6 | "scripts": { 7 | "dev": "nodemon server.js", 8 | "lint": "eslint .", 9 | "lint:fix": "eslint . --fix", 10 | "format": "prettier --write .", 11 | "test": "cross-env NODE_ENV=testing jest", 12 | "prepare": "test \"$NODE_ENV\" != \"production\" && husky install || exit 0", 13 | "start": "NODE_ENV=production node server.js" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "type": "module", 19 | "dependencies": { 20 | "bcryptjs": "^3.0.2", 21 | "cookie-parser": "^1.4.7", 22 | "cors": "^2.8.5", 23 | "dotenv": "^16.5.0", 24 | "express": "^5.1.0", 25 | "express-rate-limit": "^7.5.0", 26 | "express-validator": "^7.2.1", 27 | "googleapis": "^150.0.1", 28 | "gravatar": "^1.8.2", 29 | "helmet": "^8.1.0", 30 | "ioredis": "^5.6.1", 31 | "jsonwebtoken": "^9.0.2", 32 | "libphonenumber-js": "^1.12.9", 33 | "mongoose": "^8.15.1", 34 | "morgan": "^1.10.0", 35 | "node-fetch": "^3.3.2", 36 | "nodemailer": "^7.0.3", 37 | "passport": "^0.7.0", 38 | "passport-google-oauth20": "^2.0.0", 39 | "rate-limit-redis": "^4.2.1", 40 | "razorpay": "^2.9.6", 41 | "winston": "^3.17.0" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.27.4", 45 | "@babel/preset-env": "^7.27.2", 46 | "@eslint/js": "^9.28.0", 47 | "babel-jest": "^30.0.0-beta.3", 48 | "cross-env": "^7.0.3", 49 | "eslint": "^9.28.0", 50 | "eslint-config-prettier": "^10.1.5", 51 | "eslint-plugin-import": "^2.31.0", 52 | "eslint-plugin-prettier": "^5.4.1", 53 | "globals": "^16.2.0", 54 | "husky": "^9.1.7", 55 | "ioredis-mock": "^8.9.0", 56 | "jest": "^29.7.0", 57 | "mongodb-memory-server": "^10.1.4", 58 | "nodemon": "^3.1.10", 59 | "prettier": "^3.5.3", 60 | "supertest": "^7.1.1" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/controllers/user.controller.js: -------------------------------------------------------------------------------- 1 | import userServices from '../services/user.service.js'; 2 | import asyncHandler from '../utils/asyncHandler.js'; 3 | 4 | class UserController { 5 | /** 6 | * Update user details. 7 | * @param {Object} req - Express request object with user ID in the token and updated data in body. 8 | * @param {Object} res - Express response object. 9 | */ 10 | updateUser = asyncHandler(async (req, res) => { 11 | const user = await userServices.updateUser(req.user._id, req.body); 12 | if (!user) { 13 | return res 14 | .status(404) 15 | .json({ success: false, message: 'User not found' }); 16 | } 17 | res.status(200).json({ success: true, data: user }); 18 | }); 19 | 20 | /** 21 | * Get a random user. 22 | * @param {Object} req - Express request object. 23 | * @param {Object} res - Express response object. 24 | */ 25 | getRandomUser = asyncHandler(async (req, res) => { 26 | const user = await userServices.getRandomUser(); 27 | if (!user) { 28 | return res 29 | .status(404) 30 | .json({ success: false, message: 'No users found' }); 31 | } 32 | res.status(200).json({ success: true, data: user }); 33 | }); 34 | 35 | /** 36 | * Get paginated users. 37 | * Handles query parameters: `page`, `limit` 38 | * Delegates pagination, validation, and capping logic to the service layer. 39 | * 40 | * @param {Object} req - Express request object 41 | * @param {Object} res - Express response object 42 | */ 43 | getAllUsers = asyncHandler(async (req, res) => { 44 | const { page, limit } = req.query; 45 | 46 | const result = await userServices.getAllUsersPaginated(page, limit); 47 | 48 | res.status(200).json({ 49 | success: true, 50 | ...(result.message && { message: result.message }), 51 | data: result.data, 52 | pagination: result.pagination, 53 | }); 54 | }); 55 | } 56 | 57 | export default new UserController(); 58 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import app from './src/app.js'; 2 | import config from './src/config/config.js'; 3 | import logger from './src/loggers/winston.logger.js'; 4 | import connectToDatabase from './src/config/db.js'; 5 | import redisService from './src/config/redis.js'; 6 | 7 | // Handle uncaught exceptions 8 | process.on('uncaughtException', (error) => { 9 | logger.error(`Uncaught Exception: ${error.message}`, { stack: error.stack }); 10 | process.exit(1); 11 | }); 12 | 13 | // Handle unhandled promise rejections 14 | process.on('unhandledRejection', (reason, promise) => { 15 | logger.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`); 16 | process.exit(1); 17 | }); 18 | 19 | // Connect to database 20 | connectToDatabase(); 21 | 22 | // Connect to Redis explicitly - this ensures a single connection 23 | redisService.connect().then((connected) => { 24 | if (connected) { 25 | logger.info('Redis client successfully connected'); 26 | } else { 27 | logger.warn('Failed to connect to Redis - check your configuration'); 28 | } 29 | }); 30 | 31 | // Start the server 32 | const server = app.listen(config.PORT, () => { 33 | logger.info(`Server is running on port ${config.PORT}`); 34 | logger.debug(`Environment: ${process.env.NODE_ENV || 'development'}`); 35 | }); 36 | 37 | // Graceful shutdown 38 | const gracefulShutdown = async () => { 39 | logger.info('Starting graceful shutdown...'); 40 | 41 | // Close the server 42 | server.close(async () => { 43 | logger.info('HTTP server closed'); 44 | 45 | // Disconnect from Redis 46 | await redisService.disconnect(); 47 | 48 | logger.info('Graceful shutdown completed'); 49 | process.exit(0); 50 | }); 51 | 52 | // Force shutdown after timeout 53 | setTimeout(() => { 54 | logger.error('Forcing shutdown after timeout'); 55 | process.exit(1); 56 | }, 10000); 57 | }; 58 | 59 | // Listen for termination signals 60 | process.on('SIGTERM', gracefulShutdown); 61 | process.on('SIGINT', gracefulShutdown); 62 | -------------------------------------------------------------------------------- /src/models/product.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const otherAttributeSchema = new mongoose.Schema( 4 | { 5 | name: { type: String, required: true }, 6 | value: { type: String, required: true }, 7 | }, 8 | { _id: false } 9 | ); 10 | 11 | const productSchema = new mongoose.Schema( 12 | { 13 | product_name: { 14 | type: String, 15 | required: true, 16 | trim: true, 17 | }, 18 | description: { 19 | type: String, 20 | required: true, 21 | }, 22 | initial_price: { 23 | type: Number, 24 | required: true, 25 | min: 0, 26 | }, 27 | final_price: { 28 | type: Number, 29 | required: true, 30 | min: 0, 31 | }, 32 | currency: { 33 | type: String, 34 | required: true, 35 | enum: ['USD', 'INR', 'EUR', 'GBP'], // extend as needed 36 | }, 37 | in_stock: { 38 | type: Boolean, 39 | default: true, 40 | }, 41 | color: { 42 | type: String, 43 | }, 44 | size: { 45 | type: String, 46 | }, 47 | main_image: { 48 | type: String, 49 | required: true, 50 | }, 51 | category_tree: { 52 | type: [String], 53 | default: [], 54 | }, 55 | image_count: { 56 | type: Number, 57 | default: 1, 58 | }, 59 | image_urls: { 60 | type: [String], 61 | default: [], 62 | }, 63 | other_attributes: { 64 | type: [otherAttributeSchema], 65 | default: [], 66 | }, 67 | rating: { 68 | type: Number, 69 | default: 0, 70 | min: 0, 71 | max: 5, 72 | }, 73 | root_category: { 74 | type: String, 75 | }, 76 | category: { 77 | type: String, 78 | }, 79 | all_available_sizes: { 80 | type: [String], 81 | default: [], 82 | }, 83 | }, 84 | { 85 | timestamps: true, 86 | } 87 | ); 88 | 89 | const Product = mongoose.model('Product', productSchema); 90 | 91 | export default Product; 92 | -------------------------------------------------------------------------------- /src/models/cart.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const cartItemSchema = new mongoose.Schema( 4 | { 5 | product: { 6 | type: mongoose.Schema.Types.ObjectId, 7 | ref: 'Product', 8 | required: true, 9 | }, 10 | quantity: { 11 | type: Number, 12 | required: true, 13 | min: 1, 14 | max: 50, 15 | default: 1, 16 | }, 17 | price: { 18 | type: Number, 19 | required: true, 20 | min: 0, 21 | }, 22 | // Store selected product attributes 23 | selectedSize: { 24 | type: String, 25 | }, 26 | selectedColor: { 27 | type: String, 28 | }, 29 | }, 30 | { _id: true } 31 | ); 32 | 33 | const cartSchema = new mongoose.Schema( 34 | { 35 | user: { 36 | type: mongoose.Schema.Types.ObjectId, 37 | ref: 'User', 38 | required: true, 39 | unique: true, // One cart per user 40 | index: true, // Single index definition 41 | }, 42 | items: [cartItemSchema], 43 | totalItems: { 44 | type: Number, 45 | default: 0, 46 | }, 47 | totalAmount: { 48 | type: Number, 49 | default: 0, 50 | }, 51 | currency: { 52 | type: String, 53 | default: 'USD', 54 | enum: ['USD', 'INR', 'EUR', 'GBP'], 55 | }, 56 | }, 57 | { 58 | timestamps: true, 59 | } 60 | ); 61 | 62 | // Additional indexes 63 | cartSchema.index({ 'items.product': 1 }); 64 | 65 | // Calculate totals before saving 66 | cartSchema.pre('save', function (next) { 67 | if (this.items && this.items.length > 0) { 68 | this.totalItems = this.items.reduce( 69 | (total, item) => total + item.quantity, 70 | 0 71 | ); 72 | this.totalAmount = this.items.reduce( 73 | (total, item) => total + item.price * item.quantity, 74 | 0 75 | ); 76 | } else { 77 | this.totalItems = 0; 78 | this.totalAmount = 0; 79 | } 80 | next(); 81 | }); 82 | 83 | const Cart = mongoose.model('Cart', cartSchema); 84 | 85 | export default Cart; 86 | -------------------------------------------------------------------------------- /src/validators/cart.validator.js: -------------------------------------------------------------------------------- 1 | import { body, param } from 'express-validator'; 2 | import mongoose from 'mongoose'; 3 | 4 | export const addToCartValidator = [ 5 | body('productId') 6 | .notEmpty() 7 | .withMessage('Product ID is required') 8 | .custom((value) => { 9 | if (!mongoose.Types.ObjectId.isValid(value)) { 10 | throw new Error('Invalid product ID format'); 11 | } 12 | return true; 13 | }), 14 | 15 | body('quantity') 16 | .optional() 17 | .isInt({ min: 1, max: 50 }) 18 | .withMessage('Quantity must be between 1 and 50') 19 | .toInt(), 20 | 21 | body('selectedSize') 22 | .optional() 23 | .isString() 24 | .withMessage('Selected size must be a string') 25 | .isLength({ min: 1, max: 20 }) 26 | .withMessage('Selected size must be between 1 and 20 characters') 27 | .trim(), 28 | 29 | body('selectedColor') 30 | .optional() 31 | .isString() 32 | .withMessage('Selected color must be a string') 33 | .isLength({ min: 1, max: 50 }) 34 | .withMessage('Selected color must be between 1 and 50 characters') 35 | .trim(), 36 | ]; 37 | 38 | export const updateCartItemValidator = [ 39 | param('id') 40 | .notEmpty() 41 | .withMessage('Cart item ID is required') 42 | .custom((value) => { 43 | if (!mongoose.Types.ObjectId.isValid(value)) { 44 | throw new Error('Invalid cart item ID format'); 45 | } 46 | return true; 47 | }), 48 | 49 | body('quantity') 50 | .notEmpty() 51 | .withMessage('Quantity is required') 52 | .isInt({ min: 1, max: 50 }) 53 | .withMessage('Quantity must be between 1 and 50') 54 | .toInt(), 55 | ]; 56 | 57 | export const removeCartItemValidator = [ 58 | param('id') 59 | .notEmpty() 60 | .withMessage('Cart item ID is required') 61 | .custom((value) => { 62 | if (!mongoose.Types.ObjectId.isValid(value)) { 63 | throw new Error('Invalid cart item ID format'); 64 | } 65 | return true; 66 | }), 67 | ]; 68 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | import helmet from 'helmet'; 4 | import cookieParser from 'cookie-parser'; 5 | import passport from 'passport'; 6 | import morganLogger from './loggers/morgan.logger.js'; 7 | import './config/passport.js'; // Ensure passport strategies are loaded 8 | import errorHandler from './middlewares/errorHandler.js'; 9 | import config from './config/config.js'; 10 | import { generalRateLimiter } from './middlewares/rateLimiter.middleware.js'; 11 | 12 | const app = express(); 13 | 14 | // Middleware 15 | app.use(cors()); 16 | app.use(morganLogger); 17 | app.use(helmet()); 18 | app.use( 19 | express.json({ 20 | limit: '100kb', // Limit JSON body size to 100KB 21 | }) 22 | ); 23 | app.use(express.urlencoded({ extended: true, limit: '100kb' })); // Limit URL-encoded body size to 100KB 24 | app.use(cookieParser()); 25 | 26 | // Rate limiting 27 | app.use(generalRateLimiter); 28 | app.use(passport.initialize()); 29 | 30 | // Routes 31 | import authRoutes from './routes/auth.routes.js'; 32 | import userRoutes from './routes/user.routes.js'; 33 | import productRoutes from './routes/product.routes.js'; 34 | import cartRoutes from './routes/cart.routes.js'; 35 | import paymentRoutes from './routes/payment.routes.js'; 36 | 37 | app.use('/api/v1/auth', authRoutes); 38 | app.use('/api/v1/users', userRoutes); 39 | app.use('/api/v1/store/products', productRoutes); 40 | app.use('/api/v1/store/cart', cartRoutes); 41 | app.use('/api/v1/payments', paymentRoutes); 42 | 43 | // // Simple route for checking server status 44 | app.get('/', (req, res) => { 45 | res.status(200).json({ 46 | status: 'success', 47 | message: 'Welcome to the TestDog API', 48 | environment: config.NODE_ENV, 49 | documentation: 'docs.testdog.in', 50 | }); 51 | }); 52 | 53 | // // 404 route handler for undefined routes 54 | app.all('*name', (req, res, next) => { 55 | const err = new Error(`Can't find ${req.originalUrl} on this server!`); 56 | err.statusCode = 404; 57 | err.status = 'fail'; 58 | next(err); 59 | }); 60 | 61 | app.use(errorHandler); 62 | 63 | export default app; 64 | -------------------------------------------------------------------------------- /src/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | import AppError from '../utils/appError.js'; 2 | import logger from '../loggers/winston.logger.js'; 3 | import config from '../config/config.js'; 4 | 5 | /** 6 | * Global error handling middleware 7 | * Handles errors passed from controllers or thrown in the application 8 | */ 9 | const errorHandler = (err, req, res, next) => { 10 | let error = { ...err }; 11 | error.message = err.message; 12 | 13 | // Set default values if not available 14 | error.statusCode = error.statusCode || 500; 15 | error.status = error.status || 'error'; 16 | 17 | // Choose appropriate log level based on error type 18 | if (error.statusCode >= 500) { 19 | // 5xx errors are server errors and should be logged as errors 20 | logger.error('SERVER ERROR 💥', err); 21 | } else if (error.statusCode >= 400 && error.statusCode < 500) { 22 | // 4xx errors are client errors 23 | if (err.isOperational) { 24 | // Expected operational errors (like validation errors) use info level 25 | logger.info('CLIENT ERROR (Operational) 🔍', err); 26 | } else { 27 | // Unexpected client errors use warn level 28 | logger.warn('CLIENT ERROR 🚨', err); 29 | } 30 | } else { 31 | // Fallback for any other cases 32 | logger.error('UNHANDLED ERROR 💥', err); 33 | } 34 | 35 | // Handle specific error types 36 | if (err.name === 'ValidationError') { 37 | error = new AppError(err.message, 400); 38 | } 39 | 40 | if (err.code === 11000) { 41 | const field = Object.keys(err.keyValue)[0]; 42 | error = new AppError( 43 | `Duplicate field value: ${field}. Please use another value.`, 44 | 400 45 | ); 46 | } 47 | 48 | // Mongoose bad ObjectId 49 | if (err.name === 'CastError') { 50 | error = new AppError(`Invalid ${err.path}: ${err.value}.`, 400); 51 | } 52 | 53 | // Send error response 54 | res.status(error.statusCode).json({ 55 | success: false, 56 | status: error.status, 57 | message: error.message, 58 | stack: config.NODE_ENV === 'development' ? err.stack : undefined, 59 | }); 60 | }; 61 | 62 | export default errorHandler; 63 | -------------------------------------------------------------------------------- /src/middlewares/auth.middleware.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { promisify } from 'util'; 3 | import AppError from '../utils/appError.js'; 4 | import config from '../config/config.js'; 5 | import userService from '../services/user.service.js'; 6 | 7 | /** 8 | * Middleware to protect routes that require authentication 9 | * Verifies the JWT token from the request headers or cookies 10 | */ 11 | export const protect = async (req, res, next) => { 12 | try { 13 | // 1) Get token from authorization header or cookies 14 | let token; 15 | 16 | if (req.cookies && req.cookies.accessToken) { 17 | token = req.cookies.accessToken; 18 | } else if ( 19 | req.headers.authorization && 20 | req.headers.authorization.startsWith('Bearer') 21 | ) { 22 | token = req.headers.authorization.split(' ')[1]; 23 | } 24 | 25 | if (!token) { 26 | return next( 27 | new AppError('You are not logged in. Please log in to get access.', 401) 28 | ); 29 | } 30 | 31 | // 2) Verify token 32 | const decoded = await promisify(jwt.verify)(token, config.JWT_SECRET); 33 | 34 | // 3) Check if user still exists 35 | const currentUser = await userService.getMe(decoded.id, 'role'); 36 | 37 | if (!currentUser) { 38 | return next( 39 | new AppError('The user belonging to this token no longer exists.', 401) 40 | ); 41 | } 42 | 43 | // GRANT ACCESS TO PROTECTED ROUTE 44 | req.user = currentUser; 45 | next(); 46 | } catch (error) { 47 | if (error instanceof jwt.JsonWebTokenError) { 48 | return next(new AppError('Invalid token. Please log in again.', 401)); 49 | } 50 | if (error instanceof jwt.TokenExpiredError) { 51 | return next( 52 | new AppError('Your token has expired. Please log in again.', 401) 53 | ); 54 | } 55 | next(error); 56 | } 57 | }; 58 | 59 | /** 60 | * Middleware to restrict access based on user roles 61 | * @param {...string} roles - Roles allowed to access the route 62 | */ 63 | export const restrictTo = (...roles) => { 64 | return (req, res, next) => { 65 | // roles is an array: ['admin', 'user'] 66 | if (!roles.includes(req.user.role)) { 67 | return next( 68 | new AppError('You do not have permission to perform this action', 403) 69 | ); 70 | } 71 | 72 | next(); 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /src/middlewares/validator.middleware.js: -------------------------------------------------------------------------------- 1 | import { validationResult } from 'express-validator'; 2 | import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js'; 3 | 4 | /** 5 | * Middleware to validate request data based on validation rules 6 | * @param {Array} validations - Array of express-validator validation rules 7 | * @returns {Function} Express middleware 8 | */ 9 | export const validate = (validations) => { 10 | return async (req, res, next) => { 11 | // Execute all validations 12 | await Promise.all(validations.map((validation) => validation.run(req))); 13 | 14 | // Check if there are validation errors 15 | const errors = validationResult(req); 16 | if (errors.isEmpty()) { 17 | return next(); 18 | } 19 | 20 | // Format validation errors 21 | const extractedErrors = {}; 22 | errors.array().forEach((err) => { 23 | // Group errors by field 24 | if (!extractedErrors[err.path]) { 25 | extractedErrors[err.path] = err.msg; 26 | } 27 | }); 28 | 29 | return res.status(422).json({ 30 | success: false, 31 | message: 'Validation failed', 32 | errors: extractedErrors, 33 | }); 34 | }; 35 | }; 36 | 37 | /** 38 | * Helper function to check for specific validation rules 39 | * Can be expanded with more custom validators as needed 40 | */ 41 | export const customValidators = { 42 | isStrongPassword: (value) => { 43 | // Check for at least one uppercase, one lowercase, one number, and one special character 44 | const passwordRegex = 45 | /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#^()_+\-=\[\]{}|;:'",.<>\/\\])[A-Za-z\d@$!%*?&#^()_+\-=\[\]{}|;:'",.<>\/\\]{8,}$/; 46 | return passwordRegex.test(value); 47 | }, 48 | 49 | isValidPhone: (value, { req }) => { 50 | // Advanced phone number validation using libphonenumber-js 51 | try { 52 | // If country code is provided in the request, use it for better validation 53 | const countryCode = req?.body?.countryCode || req?.query?.countryCode; 54 | 55 | // If we have a country code, we can do more precise validation 56 | if (countryCode) { 57 | return isValidPhoneNumber(value, countryCode); 58 | } 59 | 60 | // Otherwise try to parse it as an international number 61 | const phoneNumber = parsePhoneNumber(value); 62 | return phoneNumber && phoneNumber.isValid(); 63 | } catch (error) { 64 | // If parsing fails, it's not a valid number 65 | return false; 66 | } 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /src/models/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import bcrypt from 'bcryptjs'; 3 | 4 | const userSchema = new mongoose.Schema( 5 | { 6 | username: { 7 | type: String, 8 | required: function () { 9 | return !this.googleId; 10 | }, // Not required if using Google auth 11 | unique: true, 12 | sparse: true, // Allow multiple null values (for Google users without username) 13 | trim: true, 14 | minlength: 3, 15 | maxlength: 30, 16 | }, 17 | name: { 18 | type: String, 19 | trim: true, 20 | }, 21 | email: { 22 | type: String, 23 | required: [true, 'Email is required'], 24 | unique: true, 25 | lowercase: true, 26 | trim: true, 27 | }, 28 | 29 | isEmailVerified: { 30 | type: Boolean, 31 | default: function () { 32 | return Boolean(this.googleId); // Email verification not required for Google users 33 | }, 34 | select: false, // Don't return this field by default 35 | }, 36 | 37 | password: { 38 | type: String, 39 | required: function () { 40 | return !this.googleId; 41 | }, // Not required if using Google auth 42 | minlength: 6, 43 | select: false, 44 | }, 45 | googleId: { 46 | type: String, 47 | unique: true, 48 | sparse: true, 49 | }, 50 | role: { 51 | type: String, 52 | enum: ['user', 'admin'], 53 | default: 'user', 54 | select: false, 55 | }, 56 | avatar: { 57 | type: String, 58 | default: 'default.jpg', 59 | }, 60 | forgotPasswordToken: { 61 | type: String, 62 | select: false, 63 | }, 64 | 65 | emailVerificationToken: { 66 | type: String, 67 | select: false, 68 | }, 69 | 70 | passwordChangedAt: Date, 71 | }, 72 | { 73 | timestamps: true, 74 | } 75 | ); 76 | 77 | userSchema.pre('save', async function (next) { 78 | // Ensure googleId is never explicitly null 79 | if (this.googleId === null) { 80 | this.googleId = undefined; 81 | } 82 | 83 | if (!this.password) return next(); 84 | if (!this.isModified('password')) return next(); 85 | this.password = await bcrypt.hash(this.password, 12); 86 | next(); 87 | }); 88 | 89 | userSchema.methods.comparePassword = async function (enteredPassword) { 90 | return await bcrypt.compare(enteredPassword, this.password); 91 | }; 92 | 93 | userSchema.methods.isPasswordChangedAfter = function (JWTTimestamp) { 94 | if (this.passwordChangedAt) { 95 | const changedTimestamp = parseInt( 96 | this.passwordChangedAt.getTime() / 1000, 97 | 10 98 | ); 99 | return JWTTimestamp < changedTimestamp; 100 | } 101 | return false; 102 | }; 103 | 104 | const User = mongoose.model('User', userSchema); 105 | export default User; 106 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server'; 2 | import mongoose from 'mongoose'; 3 | import Redis from 'ioredis-mock'; // Import the mock 4 | import config from './src/config/config.js'; // Your app's config 5 | import redisService from './src/config/redis.js'; // Your redis service 6 | 7 | let mongod; 8 | 9 | // Mock ioredis before any of your modules try to import the real one 10 | // Correct way to mock ioredis 11 | jest.mock('ioredis', () => { 12 | // Require 'ioredis-mock' inside the factory function. 13 | // This ensures it's resolved correctly during Jest's mocking phase. 14 | const IORedisMock = require('ioredis-mock'); 15 | return IORedisMock; // The mock will replace the 'ioredis' module 16 | }); 17 | 18 | beforeAll(async () => { 19 | mongod = await MongoMemoryServer.create(); 20 | const uri = mongod.getUri(); 21 | config.DB_URL = uri; // Override DB_URL in your config for tests 22 | 23 | // It's important to disconnect and reconnect mongoose if it was already connected 24 | if (mongoose.connection.readyState !== 0) { 25 | await mongoose.disconnect(); 26 | } 27 | 28 | await mongoose.connect(uri); 29 | 30 | // Re-initialize your Redis service with the mock if needed, 31 | // or ensure it uses the mocked 'ioredis' when it initializes. 32 | // Since we globally mocked 'ioredis', your redisService should pick up the mock. 33 | // You might need to adjust your redisService to be more easily testable 34 | // or ensure it re-initializes its client if it's already created one. 35 | 36 | // For your current redis.js, it creates an instance on import. 37 | // We need to ensure it uses the mocked Redis. 38 | // A simple way is to re-assign its client or re-initialize. 39 | 40 | // If your redisService.connect() can be called multiple times or re-initializes the client: 41 | await redisService.disconnect(); // Disconnect if already connected 42 | redisService.client = new Redis({ 43 | host: config.redis.host, 44 | port: config.redis.port, 45 | password: config.redis.password, 46 | db: config.redis.db, 47 | }); // Manually set the client to the mock instance 48 | // Or, if your redisService.connect() re-creates the client with the (now mocked) ioredis: 49 | // await redisService.connect(); 50 | }); 51 | 52 | afterAll(async () => { 53 | await mongoose.disconnect(); 54 | if (mongod) { 55 | await mongod.stop(); 56 | } 57 | if (redisService && typeof redisService.disconnect === 'function') { 58 | await redisService.disconnect(); 59 | } 60 | jest.clearAllMocks(); // Clear all mocks after tests are done 61 | }); 62 | 63 | // Optional: Clear database between each test 64 | beforeEach(async () => { 65 | const collections = mongoose.connection.collections; 66 | for (const key in collections) { 67 | const collection = collections[key]; 68 | await collection.deleteMany({}); 69 | } 70 | 71 | // Clear Redis mock data before each test 72 | if ( 73 | redisService.client && 74 | typeof redisService.client.flushdb === 'function' 75 | ) { 76 | await redisService.client.flushdb(); 77 | } 78 | }); 79 | -------------------------------------------------------------------------------- /src/utils/currencyUtils.js: -------------------------------------------------------------------------------- 1 | // Currency conversion utility 2 | // In production, you should use a real-time exchange rate API 3 | // like ExchangeRate-API, CurrencyAPI, or similar services 4 | 5 | const EXCHANGE_RATES = { 6 | USD: { 7 | INR: 83.5, // 1 USD = 83.5 INR (approximate rate) 8 | EUR: 0.92, 9 | GBP: 0.79, 10 | USD: 1, 11 | }, 12 | INR: { 13 | USD: 0.012, 14 | EUR: 0.011, 15 | GBP: 0.0095, 16 | INR: 1, 17 | }, 18 | EUR: { 19 | USD: 1.09, 20 | INR: 91.0, 21 | GBP: 0.86, 22 | EUR: 1, 23 | }, 24 | GBP: { 25 | USD: 1.27, 26 | INR: 105.8, 27 | EUR: 1.16, 28 | GBP: 1, 29 | }, 30 | }; 31 | 32 | /** 33 | * Convert amount from one currency to another 34 | * @param {number} amount - Amount to convert 35 | * @param {string} fromCurrency - Source currency (USD, INR, EUR, GBP) 36 | * @param {string} toCurrency - Target currency (USD, INR, EUR, GBP) 37 | * @returns {number} Converted amount 38 | */ 39 | export const convertCurrency = (amount, fromCurrency, toCurrency) => { 40 | if (fromCurrency === toCurrency) { 41 | return amount; 42 | } 43 | 44 | if ( 45 | !EXCHANGE_RATES[fromCurrency] || 46 | !EXCHANGE_RATES[fromCurrency][toCurrency] 47 | ) { 48 | throw new Error( 49 | `Currency conversion not supported: ${fromCurrency} to ${toCurrency}` 50 | ); 51 | } 52 | 53 | const rate = EXCHANGE_RATES[fromCurrency][toCurrency]; 54 | return amount * rate; 55 | }; 56 | 57 | /** 58 | * Get current exchange rate between two currencies 59 | * @param {string} fromCurrency - Source currency 60 | * @param {string} toCurrency - Target currency 61 | * @returns {number} Exchange rate 62 | */ 63 | export const getExchangeRate = (fromCurrency, toCurrency) => { 64 | if (fromCurrency === toCurrency) { 65 | return 1; 66 | } 67 | 68 | if ( 69 | !EXCHANGE_RATES[fromCurrency] || 70 | !EXCHANGE_RATES[fromCurrency][toCurrency] 71 | ) { 72 | throw new Error( 73 | `Exchange rate not available: ${fromCurrency} to ${toCurrency}` 74 | ); 75 | } 76 | 77 | return EXCHANGE_RATES[fromCurrency][toCurrency]; 78 | }; 79 | 80 | /** 81 | * Format amount with currency symbol 82 | * @param {number} amount - Amount to format 83 | * @param {string} currency - Currency code 84 | * @returns {string} Formatted amount 85 | */ 86 | export const formatCurrency = (amount, currency) => { 87 | const currencySymbols = { 88 | USD: '$', 89 | INR: '₹', 90 | EUR: '€', 91 | GBP: '£', 92 | }; 93 | 94 | const symbol = currencySymbols[currency] || currency; 95 | return `${symbol}${amount.toFixed(2)}`; 96 | }; 97 | 98 | /** 99 | * Update exchange rates (in production, this would fetch from an API) 100 | * @param {object} newRates - New exchange rates object 101 | */ 102 | export const updateExchangeRates = (newRates) => { 103 | Object.assign(EXCHANGE_RATES, newRates); 104 | }; 105 | 106 | export default { 107 | convertCurrency, 108 | getExchangeRate, 109 | formatCurrency, 110 | updateExchangeRates, 111 | EXCHANGE_RATES, 112 | }; 113 | -------------------------------------------------------------------------------- /src/validators/auth.validator.js: -------------------------------------------------------------------------------- 1 | import { body, query } from 'express-validator'; 2 | import { customValidators } from '../middlewares/validator.middleware.js'; 3 | 4 | export const registerValidator = [ 5 | body('username') 6 | .optional({ nullable: true }) 7 | .isLength({ min: 3, max: 30 }) 8 | .withMessage('Username must be between 3 and 30 characters') 9 | .matches(/^[a-zA-Z0-9_]+$/) 10 | .withMessage('Username can only contain letters, numbers, and underscores') 11 | .trim() 12 | .escape(), 13 | body('name') 14 | .optional() 15 | .isLength({ min: 2, max: 50 }) 16 | .withMessage('Name must be between 2 and 50 characters') 17 | .trim(), 18 | body('email') 19 | .notEmpty() 20 | .withMessage('Email is required') 21 | .isEmail() 22 | .withMessage('Please provide a valid email') 23 | .normalizeEmail(), 24 | body('password') 25 | .notEmpty() 26 | .withMessage('Password is required') 27 | .isLength({ min: 8 }) 28 | .withMessage('Password must be at least 8 characters') 29 | .custom((value) => { 30 | if (!customValidators.isStrongPassword(value)) { 31 | throw new Error( 32 | 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character' 33 | ); 34 | } 35 | return true; 36 | }), 37 | ]; 38 | 39 | export const loginValidator = [ 40 | body('email') 41 | .notEmpty() 42 | .withMessage('Email is required') 43 | .isEmail() 44 | .withMessage('Please provide a valid email') 45 | .normalizeEmail(), 46 | body('password') 47 | .notEmpty() 48 | .withMessage('Password is required') 49 | .isLength({ min: 8 }) 50 | .withMessage('Password must be at least 8 characters'), 51 | ]; 52 | 53 | export const verifyEmailValidator = [ 54 | body('email') 55 | .notEmpty() 56 | .withMessage('Email is required') 57 | .isEmail() 58 | .withMessage('Please provide a valid email') 59 | .normalizeEmail(), 60 | ]; 61 | 62 | export const verifyEmailTokenValidator = [ 63 | query('token').notEmpty().withMessage('Token is required'), 64 | ]; 65 | 66 | export const forgotPasswordValidator = [ 67 | body('email') 68 | .notEmpty() 69 | .withMessage('Email is required') 70 | .isEmail() 71 | .withMessage('Please provide a valid email') 72 | .normalizeEmail(), 73 | ]; 74 | 75 | export const resetPasswordValidator = [ 76 | body('token').notEmpty().withMessage('Token is required'), 77 | body('password') 78 | .notEmpty() 79 | .withMessage('Password is required') 80 | .isLength({ min: 8 }) 81 | .withMessage('Password must be at least 8 characters') 82 | .custom((value) => { 83 | if (!customValidators.isStrongPassword(value)) { 84 | throw new Error( 85 | 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character' 86 | ); 87 | } 88 | return true; 89 | }), 90 | body('confirmPassword') 91 | .notEmpty() 92 | .withMessage('Confirm password is required') 93 | .custom((value, { req }) => { 94 | if (value !== req.body.password) { 95 | throw new Error('Passwords do not match'); 96 | } 97 | return true; 98 | }), 99 | ]; 100 | -------------------------------------------------------------------------------- /src/__tests__/currencyUtils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | convertCurrency, 3 | getExchangeRate, 4 | formatCurrency, 5 | } from '../utils/currencyUtils.js'; 6 | 7 | describe('Currency Utilities', () => { 8 | describe('convertCurrency', () => { 9 | it('should convert USD to INR correctly', () => { 10 | const result = convertCurrency(100, 'USD', 'INR'); 11 | expect(result).toBe(8350); // 100 USD * 83.5 = 8350 INR 12 | }); 13 | 14 | it('should convert EUR to INR correctly', () => { 15 | const result = convertCurrency(100, 'EUR', 'INR'); 16 | expect(result).toBe(9100); // 100 EUR * 91.0 = 9100 INR 17 | }); 18 | 19 | it('should return same amount for same currency', () => { 20 | const result = convertCurrency(100, 'INR', 'INR'); 21 | expect(result).toBe(100); 22 | }); 23 | 24 | it('should throw error for unsupported currency', () => { 25 | expect(() => { 26 | convertCurrency(100, 'XYZ', 'INR'); 27 | }).toThrow('Currency conversion not supported: XYZ to INR'); 28 | }); 29 | }); 30 | 31 | describe('getExchangeRate', () => { 32 | it('should return correct exchange rate', () => { 33 | const rate = getExchangeRate('USD', 'INR'); 34 | expect(rate).toBe(83.5); 35 | }); 36 | 37 | it('should return 1 for same currency', () => { 38 | const rate = getExchangeRate('USD', 'USD'); 39 | expect(rate).toBe(1); 40 | }); 41 | }); 42 | 43 | describe('formatCurrency', () => { 44 | it('should format USD correctly', () => { 45 | const formatted = formatCurrency(1200.5, 'USD'); 46 | expect(formatted).toBe('$1200.50'); 47 | }); 48 | 49 | it('should format INR correctly', () => { 50 | const formatted = formatCurrency(100200.0, 'INR'); 51 | expect(formatted).toBe('₹100200.00'); 52 | }); 53 | 54 | it('should format EUR correctly', () => { 55 | const formatted = formatCurrency(1100.25, 'EUR'); 56 | expect(formatted).toBe('€1100.25'); 57 | }); 58 | 59 | it('should format GBP correctly', () => { 60 | const formatted = formatCurrency(950.75, 'GBP'); 61 | expect(formatted).toBe('£950.75'); 62 | }); 63 | }); 64 | 65 | describe('Real-world scenarios', () => { 66 | it('should handle typical e-commerce cart conversion', () => { 67 | // Scenario: Cart with mixed currency items 68 | const items = [ 69 | { price: 500, currency: 'USD', quantity: 2 }, // Gaming headset 70 | { price: 800, currency: 'EUR', quantity: 1 }, // Mechanical keyboard 71 | { price: 2000, currency: 'INR', quantity: 1 }, // Local accessory 72 | ]; 73 | 74 | let totalINR = 0; 75 | items.forEach((item) => { 76 | const priceInINR = convertCurrency(item.price, item.currency, 'INR'); 77 | totalINR += priceInINR * item.quantity; 78 | }); 79 | 80 | // Expected: (500*83.5*2) + (800*91.0*1) + (2000*1*1) = 83500 + 72800 + 2000 = 158300 81 | expect(totalINR).toBe(158300); 82 | }); 83 | 84 | it('should handle single product conversion correctly', () => { 85 | // Scenario: Single expensive product in USD 86 | const productPrice = 1299.99; // Gaming laptop 87 | const quantity = 1; 88 | 89 | const priceInINR = convertCurrency(productPrice, 'USD', 'INR'); 90 | const totalInINR = priceInINR * quantity; 91 | 92 | // Expected: 1299.99 * 83.5 = 108549.165 93 | expect(priceInINR).toBeCloseTo(108549.165, 2); 94 | expect(totalInINR).toBeCloseTo(108549.165, 2); 95 | }); 96 | 97 | it('should handle payment amount calculation in paise', () => { 98 | // Scenario: Converting final amount to paise for Razorpay 99 | const amountInINR = 100200.5; 100 | const amountInPaise = Math.round(amountInINR * 100); 101 | 102 | expect(amountInPaise).toBe(10020050); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/dao/user.dao.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user.model.js'; 2 | 3 | /** 4 | * Data Access Object for User operations. 5 | * Handles all direct interactions with the User collection. 6 | */ 7 | class UserDAO { 8 | /** 9 | * Create a new user document in the database. 10 | * @param {Object} userData - Contains username, email, password, etc. 11 | * @returns {Promise} - Newly created user document. 12 | */ 13 | async createUser(userData) { 14 | return await User.create(userData); 15 | } 16 | 17 | /** 18 | * Find a user by email. Useful during login or password reset. 19 | * @param {string} email - User's email address. 20 | * @returns {Promise} - Found user document or null. 21 | */ 22 | async findByEmail(email) { 23 | return await User.findOne({ email }).select('+password'); 24 | } 25 | 26 | /** 27 | * Find a user by Google ID. 28 | * @param {string} googleId - User's Google ID. 29 | * @returns {Promise} - Found user document or null. 30 | */ 31 | async findByGoogleId(googleId) { 32 | return await User.findOne({ googleId }); 33 | } 34 | 35 | /** 36 | * Find a user by ID. 37 | * @param {string} userId - MongoDB user ID. 38 | * @returns {Promise} - Found user document or null. 39 | */ 40 | async findById(userId, selectFields = '') { 41 | if (selectFields) { 42 | // Add '+' prefix to include fields that have select: false in schema 43 | const fieldsWithPrefix = selectFields 44 | .split(' ') 45 | .map((field) => (field.trim() ? `+${field.trim()}` : '')) 46 | .filter(Boolean) 47 | .join(' '); 48 | return await User.findById(userId).select(fieldsWithPrefix); 49 | } 50 | return await User.findById(userId); 51 | } 52 | 53 | /** 54 | * Find a user by username. 55 | * @param {string} username - User's unique username. 56 | * @returns {Promise} - Found user document or null. 57 | */ 58 | async findByUsername(username, selectFields = '') { 59 | return await User.findOne({ 60 | $and: [{ username }, { username: { $ne: null } }], 61 | }).select(selectFields); 62 | } 63 | 64 | /** 65 | * Update user fields by ID. 66 | * @param {string} userId - MongoDB user ID. 67 | * @param {Object} updateData - Fields to update. 68 | * @returns {Promise} - Updated user document or null. 69 | */ 70 | async updateUserById(userId, updateData) { 71 | return await User.findByIdAndUpdate(userId, updateData, { 72 | new: true, 73 | runValidators: true, 74 | }); 75 | } 76 | 77 | /** 78 | * Delete a user by ID. 79 | * @param {string} userId - MongoDB user ID. 80 | * @returns {Promise} - Deleted user document or null. 81 | */ 82 | async deleteUserById(userId) { 83 | return await User.findByIdAndDelete(userId); 84 | } 85 | 86 | /** 87 | * Get a random user from the database. 88 | * @returns {Promise} - Random user document or null if no users exist. 89 | */ 90 | async getRandomUser() { 91 | const user = await User.aggregate([{ $sample: { size: 1 } }]); 92 | return user.length > 0 ? user[0] : null; 93 | } 94 | 95 | /** 96 | * Get all users with optional field selection. 97 | * @param {string} selectFields - Space-separated list of fields to return. 98 | * @returns {Promise} - Array of user documents. 99 | */ 100 | /** 101 | * Get paginated list of users. 102 | * @param {number} page - Page number 103 | * @param {number} limit - Number of users per page 104 | * @returns {Promise} - { data: [], total } 105 | */ 106 | async getAllUsersPaginated(page = 1, limit = 50) { 107 | const skip = (page - 1) * limit; 108 | 109 | // Include role but exclude sensitive fields for security 110 | const selectFields = 111 | '+role -password -isEmailVerified -forgotPasswordToken -emailVerificationToken -__v'; 112 | 113 | const [data, total] = await Promise.all([ 114 | User.find() 115 | .select(selectFields) 116 | .skip(skip) 117 | .limit(limit) 118 | .sort({ createdAt: -1 }), // Sort by newest first 119 | User.countDocuments(), 120 | ]); 121 | return { data, total }; 122 | } 123 | } 124 | 125 | export default new UserDAO(); 126 | -------------------------------------------------------------------------------- /src/controllers/cart.controller.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from '../utils/asyncHandler.js'; 2 | import cartService from '../services/cart.service.js'; 3 | import logger from '../loggers/winston.logger.js'; 4 | 5 | class CartController { 6 | /** 7 | * Add product to cart 8 | * @route POST /api/v1/store/cart 9 | * @access Private (Authenticated users) 10 | */ 11 | addToCart = asyncHandler(async (req, res) => { 12 | const userId = req.user.id; 13 | const { productId, quantity, selectedSize, selectedColor } = req.body; 14 | 15 | logger.info('Adding product to cart', { 16 | userId, 17 | productId, 18 | quantity, 19 | }); 20 | 21 | const cart = await cartService.addToCart(userId, { 22 | productId, 23 | quantity, 24 | selectedSize, 25 | selectedColor, 26 | }); 27 | 28 | logger.info('Product added to cart successfully', { 29 | userId, 30 | productId, 31 | totalItems: cart.totalItems, 32 | }); 33 | 34 | res.status(200).json({ 35 | success: true, 36 | message: 'Product added to cart successfully', 37 | data: { 38 | cart, 39 | }, 40 | }); 41 | }); 42 | 43 | /** 44 | * Get user's cart 45 | * @route GET /api/v1/store/cart 46 | * @access Private (Authenticated users) 47 | */ 48 | getCart = asyncHandler(async (req, res) => { 49 | const userId = req.user.id; 50 | 51 | logger.info('Fetching user cart', { userId }); 52 | 53 | const cart = await cartService.getCart(userId); 54 | 55 | res.status(200).json({ 56 | success: true, 57 | message: 'Cart retrieved successfully', 58 | data: { 59 | cart, 60 | }, 61 | }); 62 | }); 63 | 64 | /** 65 | * Update cart item quantity 66 | * @route PATCH /api/v1/store/cart/:id 67 | * @access Private (Authenticated users) 68 | */ 69 | updateCartItem = asyncHandler(async (req, res) => { 70 | const userId = req.user.id; 71 | const { id: itemId } = req.params; 72 | const { quantity } = req.body; 73 | 74 | logger.info('Updating cart item', { 75 | userId, 76 | itemId, 77 | newQuantity: quantity, 78 | }); 79 | 80 | const cart = await cartService.updateCartItem(userId, itemId, quantity); 81 | 82 | logger.info('Cart item updated successfully', { 83 | userId, 84 | itemId, 85 | newQuantity: quantity, 86 | }); 87 | 88 | res.status(200).json({ 89 | success: true, 90 | message: 'Cart item updated successfully', 91 | data: { 92 | cart, 93 | }, 94 | }); 95 | }); 96 | 97 | /** 98 | * Remove item from cart 99 | * @route DELETE /api/v1/store/cart/:id 100 | * @access Private (Authenticated users) 101 | */ 102 | removeCartItem = asyncHandler(async (req, res) => { 103 | const userId = req.user.id; 104 | const { id: itemId } = req.params; 105 | 106 | logger.info('Removing cart item', { 107 | userId, 108 | itemId, 109 | }); 110 | 111 | const cart = await cartService.removeCartItem(userId, itemId); 112 | 113 | logger.info('Cart item removed successfully', { 114 | userId, 115 | itemId, 116 | }); 117 | 118 | res.status(200).json({ 119 | success: true, 120 | message: 'Cart item removed successfully', 121 | data: { 122 | cart, 123 | }, 124 | }); 125 | }); 126 | 127 | /** 128 | * Clear entire cart 129 | * @route DELETE /api/v1/store/cart 130 | * @access Private (Authenticated users) 131 | */ 132 | clearCart = asyncHandler(async (req, res) => { 133 | const userId = req.user.id; 134 | 135 | logger.info('Clearing cart', { userId }); 136 | 137 | const cart = await cartService.clearCart(userId); 138 | 139 | logger.info('Cart cleared successfully', { userId }); 140 | 141 | res.status(200).json({ 142 | success: true, 143 | message: 'Cart cleared successfully', 144 | data: { 145 | cart, 146 | }, 147 | }); 148 | }); 149 | 150 | /** 151 | * Get cart summary (items count and total) 152 | * @route GET /api/v1/store/cart/summary 153 | * @access Private (Authenticated users) 154 | */ 155 | getCartSummary = asyncHandler(async (req, res) => { 156 | const userId = req.user.id; 157 | 158 | logger.info('Fetching cart summary', { userId }); 159 | 160 | const cart = await cartService.getCart(userId); 161 | 162 | res.status(200).json({ 163 | success: true, 164 | message: 'Cart summary retrieved successfully', 165 | data: { 166 | summary: { 167 | totalItems: cart.totalItems, 168 | totalAmount: cart.totalAmount, 169 | currency: cart.currency, 170 | itemCount: cart.items ? cart.items.length : 0, 171 | }, 172 | }, 173 | }); 174 | }); 175 | } 176 | 177 | export default new CartController(); 178 | -------------------------------------------------------------------------------- /src/validators/product.validator.js: -------------------------------------------------------------------------------- 1 | import { body } from 'express-validator'; 2 | 3 | export const createProductValidator = [ 4 | body('product_name') 5 | .notEmpty() 6 | .withMessage('Product name is required') 7 | .isLength({ min: 3, max: 100 }) 8 | .withMessage('Product name must be between 3 and 100 characters') 9 | .trim(), 10 | 11 | body('description') 12 | .notEmpty() 13 | .withMessage('Product description is required') 14 | .isLength({ min: 10, max: 1000 }) 15 | .withMessage('Description must be between 10 and 1000 characters') 16 | .trim(), 17 | 18 | body('initial_price') 19 | .notEmpty() 20 | .withMessage('Initial price is required') 21 | .isFloat({ min: 0 }) 22 | .withMessage('Initial price must be a positive number') 23 | .toFloat(), 24 | 25 | body('final_price') 26 | .notEmpty() 27 | .withMessage('Final price is required') 28 | .isFloat({ min: 0 }) 29 | .withMessage('Final price must be a positive number') 30 | .toFloat() 31 | .custom((value, { req }) => { 32 | if (value > req.body.initial_price) { 33 | throw new Error('Final price cannot be greater than initial price'); 34 | } 35 | return true; 36 | }), 37 | 38 | body('currency') 39 | .notEmpty() 40 | .withMessage('Currency is required') 41 | .isIn(['USD', 'INR', 'EUR', 'GBP']) 42 | .withMessage('Currency must be one of: USD, INR, EUR, GBP'), 43 | 44 | body('in_stock') 45 | .optional() 46 | .isBoolean() 47 | .withMessage('In stock must be a boolean value') 48 | .toBoolean(), 49 | 50 | body('color') 51 | .optional() 52 | .isLength({ max: 50 }) 53 | .withMessage('Color must not exceed 50 characters') 54 | .trim(), 55 | 56 | body('size') 57 | .optional() 58 | .isLength({ max: 20 }) 59 | .withMessage('Size must not exceed 20 characters') 60 | .trim(), 61 | 62 | body('main_image') 63 | .notEmpty() 64 | .withMessage('Main image URL is required') 65 | .isURL() 66 | .withMessage('Main image must be a valid URL'), 67 | 68 | body('category_tree') 69 | .optional() 70 | .isArray() 71 | .withMessage('Category tree must be an array') 72 | .custom((value) => { 73 | if (value && value.length > 10) { 74 | throw new Error('Category tree cannot have more than 10 levels'); 75 | } 76 | return true; 77 | }), 78 | 79 | body('category_tree.*') 80 | .optional() 81 | .isString() 82 | .withMessage('Each category in tree must be a string') 83 | .isLength({ min: 1, max: 50 }) 84 | .withMessage('Each category must be between 1 and 50 characters') 85 | .trim(), 86 | 87 | body('image_count') 88 | .optional() 89 | .isInt({ min: 1, max: 20 }) 90 | .withMessage('Image count must be between 1 and 20') 91 | .toInt(), 92 | 93 | body('image_urls') 94 | .optional() 95 | .isArray() 96 | .withMessage('Image URLs must be an array') 97 | .custom((value) => { 98 | if (value && value.length > 20) { 99 | throw new Error('Cannot have more than 20 image URLs'); 100 | } 101 | return true; 102 | }), 103 | 104 | body('image_urls.*') 105 | .optional() 106 | .isURL() 107 | .withMessage('Each image URL must be valid'), 108 | 109 | body('other_attributes') 110 | .optional() 111 | .isArray() 112 | .withMessage('Other attributes must be an array') 113 | .custom((value) => { 114 | if (value && value.length > 50) { 115 | throw new Error('Cannot have more than 50 other attributes'); 116 | } 117 | return true; 118 | }), 119 | 120 | body('other_attributes.*.name') 121 | .optional() 122 | .isString() 123 | .withMessage('Attribute name must be a string') 124 | .isLength({ min: 1, max: 100 }) 125 | .withMessage('Attribute name must be between 1 and 100 characters') 126 | .trim(), 127 | 128 | body('other_attributes.*.value') 129 | .optional() 130 | .isString() 131 | .withMessage('Attribute value must be a string') 132 | .isLength({ min: 1, max: 200 }) 133 | .withMessage('Attribute value must be between 1 and 200 characters') 134 | .trim(), 135 | 136 | body('root_category') 137 | .optional() 138 | .isString() 139 | .withMessage('Root category must be a string') 140 | .isLength({ min: 1, max: 100 }) 141 | .withMessage('Root category must be between 1 and 100 characters') 142 | .trim(), 143 | 144 | body('category') 145 | .optional() 146 | .isString() 147 | .withMessage('Category must be a string') 148 | .isLength({ min: 1, max: 100 }) 149 | .withMessage('Category must be between 1 and 100 characters') 150 | .trim(), 151 | 152 | body('all_available_sizes') 153 | .optional() 154 | .isArray() 155 | .withMessage('Available sizes must be an array'), 156 | 157 | body('all_available_sizes.*') 158 | .optional() 159 | .isString() 160 | .withMessage('Each size must be a string') 161 | .isLength({ min: 1, max: 20 }) 162 | .withMessage('Each size must be between 1 and 20 characters') 163 | .trim(), 164 | ]; 165 | -------------------------------------------------------------------------------- /src/controllers/product.controller.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from '../utils/asyncHandler.js'; 2 | import productService from '../services/product.service.js'; 3 | import logger from '../loggers/winston.logger.js'; 4 | 5 | class ProductController { 6 | /** 7 | * Create a new product 8 | * @route POST /api/v1/store/products 9 | * @access Private (Admin/Store Manager) 10 | */ 11 | createProduct = asyncHandler(async (req, res) => { 12 | logger.info('Creating new product', { 13 | productName: req.body.product_name, 14 | userId: req.user?.id, 15 | }); 16 | 17 | const product = await productService.createProduct(req.body); 18 | 19 | logger.info('Product created successfully', { 20 | productId: product._id, 21 | productName: product.product_name, 22 | userId: req.user?.id, 23 | }); 24 | 25 | res.status(201).json({ 26 | success: true, 27 | message: 'Product created successfully', 28 | data: { 29 | product, 30 | }, 31 | }); 32 | }); 33 | 34 | /** 35 | * Get product by ID 36 | * @route GET /api/v1/store/products/:id 37 | * @access Public 38 | */ 39 | getProduct = asyncHandler(async (req, res) => { 40 | const { id } = req.params; 41 | 42 | logger.info('Fetching product by ID', { productId: id }); 43 | 44 | const product = await productService.getProductById(id); 45 | 46 | res.status(200).json({ 47 | success: true, 48 | message: 'Product retrieved successfully', 49 | data: { 50 | product, 51 | }, 52 | }); 53 | }); 54 | 55 | /** 56 | * Get all products with pagination 57 | * @route GET /api/v1/store/products 58 | * @access Public 59 | */ 60 | getAllProducts = asyncHandler(async (req, res) => { 61 | const page = parseInt(req.query.page) || 0; 62 | const limit = parseInt(req.query.limit) || 20; 63 | 64 | // Validate pagination parameters 65 | if (page < 0) { 66 | return res.status(400).json({ 67 | success: false, 68 | message: 'Page number must be non-negative', 69 | }); 70 | } 71 | 72 | if (limit < 1 || limit > 100) { 73 | return res.status(400).json({ 74 | success: false, 75 | message: 'Limit must be between 1 and 100', 76 | }); 77 | } 78 | 79 | logger.info('Fetching products with pagination', { page, limit }); 80 | 81 | const result = await productService.getAllProducts(page, limit); 82 | 83 | res.status(200).json({ 84 | success: true, 85 | message: 'Products retrieved successfully', 86 | data: result, 87 | }); 88 | }); 89 | 90 | /** 91 | * Search products 92 | * @route GET /api/v1/store/products/search 93 | * @access Public 94 | */ 95 | searchProducts = asyncHandler(async (req, res) => { 96 | const { q: keyword, limit = 20 } = req.query; 97 | 98 | if (!keyword || keyword.trim().length === 0) { 99 | return res.status(400).json({ 100 | success: false, 101 | message: 'Search keyword is required', 102 | }); 103 | } 104 | 105 | logger.info('Searching products', { keyword, limit }); 106 | 107 | const products = await productService.searchProducts( 108 | keyword, 109 | parseInt(limit) 110 | ); 111 | 112 | res.status(200).json({ 113 | success: true, 114 | message: 'Search completed successfully', 115 | data: { 116 | keyword, 117 | products, 118 | total: products.length, 119 | }, 120 | }); 121 | }); 122 | 123 | /** 124 | * Get product autocomplete suggestions 125 | * @route GET /api/v1/store/products/autocomplete 126 | * @access Public 127 | */ 128 | getAutocomplete = asyncHandler(async (req, res) => { 129 | const { q: keyword, limit = 7 } = req.query; 130 | 131 | if (!keyword || keyword.trim().length < 2) { 132 | return res.status(400).json({ 133 | success: false, 134 | message: 'Search keyword must be at least 2 characters long', 135 | }); 136 | } 137 | 138 | logger.info('Getting autocomplete suggestions', { keyword, limit }); 139 | 140 | const suggestions = await productService.getAutocompleteSuggestions( 141 | keyword, 142 | parseInt(limit) 143 | ); 144 | 145 | res.status(200).json({ 146 | success: true, 147 | message: 'Autocomplete suggestions retrieved successfully', 148 | data: { 149 | keyword, 150 | suggestions, 151 | }, 152 | }); 153 | }); 154 | 155 | /** 156 | * Get random product 157 | * @route GET /api/v1/store/products/random 158 | * @access Public 159 | */ 160 | getRandomProduct = asyncHandler(async (req, res) => { 161 | logger.info('Fetching random product'); 162 | 163 | const product = await productService.getRandomProduct(); 164 | 165 | if (!product) { 166 | return res.status(404).json({ 167 | success: false, 168 | message: 'No products available', 169 | }); 170 | } 171 | 172 | res.status(200).json({ 173 | success: true, 174 | message: 'Random product retrieved successfully', 175 | data: { 176 | product, 177 | }, 178 | }); 179 | }); 180 | } 181 | 182 | export default new ProductController(); 183 | -------------------------------------------------------------------------------- /src/validators/payment.validator.js: -------------------------------------------------------------------------------- 1 | import { body, query, param, validationResult } from 'express-validator'; 2 | import AppError from '../utils/appError.js'; 3 | 4 | // Validation middleware to handle errors 5 | export const validate = (req, res, next) => { 6 | const errors = validationResult(req); 7 | if (!errors.isEmpty()) { 8 | const errorMessages = errors.array().map((error) => error.msg); 9 | throw new AppError(errorMessages.join(', '), 400); 10 | } 11 | next(); 12 | }; 13 | 14 | // Validate shipping address 15 | const shippingAddressValidator = [ 16 | body('shippingAddress.fullName') 17 | .optional() 18 | .isLength({ min: 2, max: 100 }) 19 | .withMessage('Full name must be between 2 and 100 characters'), 20 | body('shippingAddress.address') 21 | .optional() 22 | .isLength({ min: 5, max: 200 }) 23 | .withMessage('Address must be between 5 and 200 characters'), 24 | body('shippingAddress.city') 25 | .optional() 26 | .isLength({ min: 2, max: 50 }) 27 | .withMessage('City must be between 2 and 50 characters'), 28 | body('shippingAddress.state') 29 | .optional() 30 | .isLength({ min: 2, max: 50 }) 31 | .withMessage('State must be between 2 and 50 characters'), 32 | body('shippingAddress.zipCode') 33 | .optional() 34 | .matches(/^[0-9]{5,10}$/) 35 | .withMessage('ZIP code must be 5-10 digits'), 36 | body('shippingAddress.country') 37 | .optional() 38 | .isLength({ min: 2, max: 50 }) 39 | .withMessage('Country must be between 2 and 50 characters'), 40 | body('shippingAddress.phone') 41 | .optional() 42 | .matches(/^[+]?[1-9]\d{1,14}$/) 43 | .withMessage('Phone number must be valid'), 44 | ]; 45 | 46 | // Create cart order validation 47 | export const createCartOrderValidator = [ 48 | ...shippingAddressValidator, 49 | body('notes') 50 | .optional() 51 | .isLength({ max: 500 }) 52 | .withMessage('Notes must not exceed 500 characters'), 53 | ]; 54 | 55 | // Create single product order validation 56 | export const createSingleProductOrderValidator = [ 57 | body('productId') 58 | .notEmpty() 59 | .withMessage('Product ID is required') 60 | .isMongoId() 61 | .withMessage('Product ID must be a valid MongoDB ObjectId'), 62 | body('quantity') 63 | .optional() 64 | .isInt({ min: 1, max: 50 }) 65 | .withMessage('Quantity must be between 1 and 50'), 66 | body('selectedSize') 67 | .optional() 68 | .isLength({ min: 1, max: 10 }) 69 | .withMessage('Selected size must be between 1 and 10 characters'), 70 | body('selectedColor') 71 | .optional() 72 | .isLength({ min: 1, max: 20 }) 73 | .withMessage('Selected color must be between 1 and 20 characters'), 74 | ...shippingAddressValidator, 75 | body('notes') 76 | .optional() 77 | .isLength({ max: 500 }) 78 | .withMessage('Notes must not exceed 500 characters'), 79 | ]; 80 | 81 | // Verify payment validation 82 | export const verifyPaymentValidator = [ 83 | body('razorpay_order_id') 84 | .notEmpty() 85 | .withMessage('Razorpay order ID is required') 86 | .isLength({ min: 1 }) 87 | .withMessage('Razorpay order ID cannot be empty'), 88 | body('razorpay_payment_id') 89 | .notEmpty() 90 | .withMessage('Razorpay payment ID is required') 91 | .isLength({ min: 1 }) 92 | .withMessage('Razorpay payment ID cannot be empty'), 93 | body('razorpay_signature') 94 | .notEmpty() 95 | .withMessage('Razorpay signature is required') 96 | .isLength({ min: 1 }) 97 | .withMessage('Razorpay signature cannot be empty'), 98 | ]; 99 | 100 | // Get payment history validation 101 | export const getPaymentHistoryValidator = [ 102 | query('status') 103 | .optional() 104 | .isIn(['created', 'attempted', 'paid', 'failed', 'cancelled', 'refunded']) 105 | .withMessage( 106 | 'Status must be one of: created, attempted, paid, failed, cancelled, refunded' 107 | ), 108 | query('page') 109 | .optional() 110 | .isInt({ min: 1 }) 111 | .withMessage('Page must be a positive integer'), 112 | query('limit') 113 | .optional() 114 | .isInt({ min: 1, max: 100 }) 115 | .withMessage('Limit must be between 1 and 100'), 116 | ]; 117 | 118 | // Request refund validation 119 | export const requestRefundValidator = [ 120 | param('paymentId') 121 | .notEmpty() 122 | .withMessage('Payment ID is required') 123 | .isMongoId() 124 | .withMessage('Payment ID must be a valid MongoDB ObjectId'), 125 | body('refundAmount') 126 | .optional() 127 | .isFloat({ min: 0.01 }) 128 | .withMessage('Refund amount must be greater than 0.01'), 129 | body('reason') 130 | .optional() 131 | .isLength({ min: 1, max: 200 }) 132 | .withMessage('Reason must be between 1 and 200 characters'), 133 | ]; 134 | 135 | // Get payment details validation 136 | export const getPaymentDetailsValidator = [ 137 | param('paymentId') 138 | .notEmpty() 139 | .withMessage('Payment ID is required') 140 | .isMongoId() 141 | .withMessage('Payment ID must be a valid MongoDB ObjectId'), 142 | ]; 143 | 144 | // Get payment stats validation (admin only) 145 | export const getPaymentStatsValidator = [ 146 | query('startDate') 147 | .optional() 148 | .isISO8601() 149 | .withMessage('Start date must be a valid ISO 8601 date'), 150 | query('endDate') 151 | .optional() 152 | .isISO8601() 153 | .withMessage('End date must be a valid ISO 8601 date'), 154 | ]; 155 | -------------------------------------------------------------------------------- /src/models/payment.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const paymentSchema = new mongoose.Schema( 4 | { 5 | user: { 6 | type: mongoose.Schema.Types.ObjectId, 7 | ref: 'User', 8 | required: true, 9 | index: true, 10 | }, 11 | orderId: { 12 | type: String, 13 | required: true, 14 | unique: true, 15 | }, 16 | razorpayOrderId: { 17 | type: String, 18 | required: true, 19 | unique: true, 20 | }, 21 | razorpayPaymentId: { 22 | type: String, 23 | sparse: true, // Allow null values but ensure uniqueness when present 24 | }, 25 | razorpaySignature: { 26 | type: String, 27 | }, 28 | amount: { 29 | type: Number, 30 | required: true, 31 | min: 0, 32 | }, 33 | currency: { 34 | type: String, 35 | required: true, 36 | default: 'INR', 37 | enum: ['USD', 'INR', 'EUR', 'GBP'], 38 | }, 39 | status: { 40 | type: String, 41 | required: true, 42 | enum: ['created', 'attempted', 'paid', 'failed', 'cancelled', 'refunded'], 43 | default: 'created', 44 | }, 45 | orderType: { 46 | type: String, 47 | required: true, 48 | enum: ['cart', 'single_product'], 49 | }, 50 | // For cart payments 51 | cartItems: [ 52 | { 53 | product: { 54 | type: mongoose.Schema.Types.ObjectId, 55 | ref: 'Product', 56 | }, 57 | quantity: { 58 | type: Number, 59 | min: 1, 60 | }, 61 | price: { 62 | type: Number, 63 | min: 0, 64 | }, 65 | priceInINR: { 66 | type: Number, 67 | min: 0, 68 | }, 69 | originalCurrency: { 70 | type: String, 71 | enum: ['USD', 'INR', 'EUR', 'GBP'], 72 | }, 73 | selectedSize: String, 74 | selectedColor: String, 75 | }, 76 | ], 77 | // For single product payments 78 | singleProduct: { 79 | product: { 80 | type: mongoose.Schema.Types.ObjectId, 81 | ref: 'Product', 82 | }, 83 | quantity: { 84 | type: Number, 85 | min: 1, 86 | }, 87 | price: { 88 | type: Number, 89 | min: 0, 90 | }, 91 | priceInINR: { 92 | type: Number, 93 | min: 0, 94 | }, 95 | originalCurrency: { 96 | type: String, 97 | enum: ['USD', 'INR', 'EUR', 'GBP'], 98 | }, 99 | selectedSize: String, 100 | selectedColor: String, 101 | }, 102 | // Delivery information 103 | shippingAddress: { 104 | fullName: String, 105 | address: String, 106 | city: String, 107 | state: String, 108 | zipCode: String, 109 | country: String, 110 | phone: String, 111 | }, 112 | notes: { 113 | type: String, 114 | maxlength: 500, 115 | }, 116 | // Payment timestamps 117 | createdAt: { 118 | type: Date, 119 | default: Date.now, 120 | }, 121 | paidAt: { 122 | type: Date, 123 | }, 124 | failedAt: { 125 | type: Date, 126 | }, 127 | // Razorpay webhook data 128 | webhookData: { 129 | type: mongoose.Schema.Types.Mixed, 130 | }, 131 | // Refund information 132 | refundInfo: { 133 | refundId: String, 134 | refundAmount: Number, 135 | refundReason: String, 136 | refundedAt: Date, 137 | }, 138 | }, 139 | { 140 | timestamps: true, 141 | toJSON: { virtuals: true }, 142 | toObject: { virtuals: true }, 143 | } 144 | ); 145 | 146 | // Indexes for better query performance 147 | paymentSchema.index({ user: 1, status: 1 }); 148 | paymentSchema.index({ createdAt: -1 }); 149 | 150 | // Virtual for formatted amount 151 | paymentSchema.virtual('formattedAmount').get(function () { 152 | return `${this.currency} ${(this.amount / 100).toFixed(2)}`; 153 | }); 154 | 155 | // Methods 156 | paymentSchema.methods.markAsPaid = function (paymentId, signature) { 157 | this.status = 'paid'; 158 | this.razorpayPaymentId = paymentId; 159 | this.razorpaySignature = signature; 160 | this.paidAt = new Date(); 161 | return this.save(); 162 | }; 163 | 164 | paymentSchema.methods.markAsFailed = function (reason) { 165 | this.status = 'failed'; 166 | this.failedAt = new Date(); 167 | this.notes = reason; 168 | return this.save(); 169 | }; 170 | 171 | paymentSchema.methods.markAsRefunded = function (refundData) { 172 | this.status = 'refunded'; 173 | this.refundInfo = { 174 | refundId: refundData.refundId, 175 | refundAmount: refundData.amount, 176 | refundReason: refundData.reason, 177 | refundedAt: new Date(), 178 | }; 179 | return this.save(); 180 | }; 181 | 182 | // Static methods 183 | paymentSchema.statics.findByOrderId = function (orderId) { 184 | return this.findOne({ orderId }); 185 | }; 186 | 187 | paymentSchema.statics.findByRazorpayOrderId = function (razorpayOrderId) { 188 | return this.findOne({ razorpayOrderId }); 189 | }; 190 | 191 | paymentSchema.statics.getUserPayments = function (userId, status = null) { 192 | const query = { user: userId }; 193 | if (status) query.status = status; 194 | return this.find(query) 195 | .populate('cartItems.product', 'product_name main_image final_price') 196 | .populate('singleProduct.product', 'product_name main_image final_price') 197 | .sort({ createdAt: -1 }); 198 | }; 199 | 200 | const Payment = mongoose.model('Payment', paymentSchema); 201 | 202 | export default Payment; 203 | -------------------------------------------------------------------------------- /src/dao/product.dao.js: -------------------------------------------------------------------------------- 1 | import Product from '../models/product.model.js'; 2 | 3 | /** 4 | * Data Access Object for Product operations. 5 | * Handles all direct interactions with the Product collection. 6 | */ 7 | class ProductDAO { 8 | /** 9 | * Create a new product document in the database. 10 | * @param {Object} productData - Contains product details like name, price, etc. 11 | * @returns {Promise} - Newly created product document. 12 | */ 13 | async createProduct(productData) { 14 | return await Product.create(productData); 15 | } 16 | 17 | /** 18 | * Count all products in the database. 19 | * @returns {Promise} - Total count of products. 20 | */ 21 | async countAllProducts() { 22 | return await Product.countDocuments(); 23 | } 24 | /** 25 | * Find a product by ID. 26 | * @param {string} productId - MongoDB product ID. 27 | * @returns {Promise} - Found product document or null. 28 | */ 29 | async findProductById(productId) { 30 | return await Product.findById(productId); 31 | } 32 | /** 33 | * Find products by category. 34 | * @param {string} category - Product category. 35 | * @returns {Promise} - Array of found product documents. 36 | */ 37 | async findProductsByCategory(category) { 38 | return await Product.find({ category }); 39 | } 40 | /** 41 | * Update a product by ID. 42 | * @param {string} productId - MongoDB product ID. 43 | * @param {Object} updateData - Fields to update. 44 | * @returns {Promise} - Updated product document or null if not found. 45 | * 46 | */ 47 | async updateProductById(productId, updateData) { 48 | return await Product.findByIdAndUpdate(productId, updateData, { 49 | new: true, 50 | runValidators: true, 51 | }); 52 | } 53 | /** 54 | * Delete a product by ID. 55 | * @param {string} productId - MongoDB product ID. 56 | * @returns {Promise} - Deleted product document or null if not found. 57 | */ 58 | async deleteProductById(productId) { 59 | return await Product.findByIdAndDelete(productId); 60 | } 61 | /** 62 | * Find all products with pagination. 63 | * @param {number} page - Page number for pagination. 64 | * @param {number} limit - Number of products per page. 65 | * @returns {Promise} - Array of found product documents. 66 | */ 67 | async findAllProducts(page = 0, limit = 20) { 68 | const skip = page * limit; 69 | return await Product.find().skip(skip).limit(limit); 70 | } 71 | /** 72 | * Fuzzy search for products based on a keyword. 73 | * Uses MongoDB Atlas Search for advanced text search capabilities. 74 | * @param {string} keyword - Search keyword. 75 | * @param {number} limit - Number of products per page. 76 | * @returns {Promise} - Array of found product documents. 77 | */ 78 | async fuzzySearch(keyword, limit = 20) { 79 | const results = await Product.aggregate([ 80 | { 81 | $search: { 82 | index: 'productSearchIndex', 83 | text: { 84 | query: keyword, 85 | path: [ 86 | 'product_name', 87 | 'description', 88 | 'category_tree', 89 | 'other_attributes.name', 90 | 'other_attributes.value', 91 | ], 92 | fuzzy: { 93 | maxEdits: 2, // 1 or 2 is common 94 | prefixLength: 2, // minimum characters before fuzziness kicks in 95 | }, 96 | }, 97 | }, 98 | }, 99 | { 100 | $sort: { score: { $meta: 'textScore' } }, 101 | }, 102 | { 103 | $project: { 104 | _id: 1, 105 | product_name: 1, 106 | description: 1, 107 | initial_price: 1, 108 | final_price: 1, 109 | currency: 1, 110 | in_stock: 1, 111 | color: 1, 112 | size: 1, 113 | main_image: 1, 114 | }, 115 | }, 116 | { 117 | $limit: limit, 118 | }, 119 | ]); 120 | 121 | return results; 122 | } 123 | /** 124 | * Autocomplete search for products using MongoDB Atlas Search. 125 | * @param {string} keyword - The search term typed by the user. 126 | * @param {number} limit - Max number of results to return. 127 | * @returns {Promise} Matching products with lightweight projection. 128 | */ 129 | async autocompleteSearch(keyword, limit = 7) { 130 | const results = await Product.aggregate([ 131 | { 132 | $search: { 133 | index: 'productSearchIndex', 134 | autocomplete: { 135 | query: keyword, 136 | path: ['product_name'], 137 | fuzzy: { 138 | maxEdits: 1, 139 | prefixLength: 2, 140 | }, 141 | }, 142 | }, 143 | }, 144 | { 145 | $project: { 146 | product_name: 1, 147 | score: { $meta: 'searchScore' }, 148 | }, 149 | }, 150 | { $sort: { score: -1 } }, 151 | { $limit: limit }, 152 | ]); 153 | 154 | return results.map((product) => product.product_name); 155 | } 156 | /** 157 | * Get a random product from the database. 158 | * @returns {Promise} - Random product document or null if no products exist. 159 | */ 160 | async getRandomProduct() { 161 | const randomProduct = await Product.aggregate([{ $sample: { size: 1 } }]); 162 | return randomProduct.length > 0 ? randomProduct[0] : null; 163 | } 164 | /** 165 | * Find products by name using basic text search (fallback method) 166 | * @param {string} keyword - Search keyword 167 | * @param {number} limit - Number of results 168 | * @returns {Promise} - Search results 169 | */ 170 | async findProductsByName(keyword, limit = 20) { 171 | const results = await Product.find({ 172 | $or: [ 173 | { product_name: { $regex: keyword, $options: 'i' } }, 174 | { description: { $regex: keyword, $options: 'i' } }, 175 | { category: { $regex: keyword, $options: 'i' } }, 176 | { root_category: { $regex: keyword, $options: 'i' } }, 177 | ], 178 | }).limit(limit); 179 | 180 | return results.map((product) => product.product_name); 181 | } 182 | } 183 | 184 | export default new ProductDAO(); 185 | -------------------------------------------------------------------------------- /src/__tests__/user.test.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import request from 'supertest'; 3 | import { MongoMemoryServer } from 'mongodb-memory-server'; 4 | import app from '../app.js'; 5 | import User from '../models/user.model.js'; 6 | 7 | let mongoServer; 8 | let adminToken; 9 | let userToken; 10 | 11 | beforeAll(async () => { 12 | mongoServer = await MongoMemoryServer.create(); 13 | const uri = mongoServer.getUri(); 14 | 15 | // Close existing connection if already open (from app.js maybe) 16 | if (mongoose.connection.readyState !== 0) { 17 | await mongoose.disconnect(); 18 | } 19 | 20 | await mongoose.connect(uri); 21 | }); 22 | 23 | afterAll(async () => { 24 | await mongoose.disconnect(); 25 | await mongoServer.stop(); 26 | }); 27 | 28 | beforeEach(async () => { 29 | // Create admin user 30 | await User.create({ 31 | username: 'admin', 32 | name: 'Admin User', 33 | email: 'admin@test.com', 34 | password: 'Password123!', 35 | role: 'admin', 36 | isEmailVerified: true, 37 | }); 38 | 39 | // Create regular users 40 | await User.create([ 41 | { 42 | username: 'user1', 43 | name: 'Xavier Rodgriues', 44 | email: 'user1@example.com', 45 | password: 'Password@123', 46 | role: 'user', 47 | isEmailVerified: true, 48 | }, 49 | { 50 | username: 'user2', 51 | name: 'Ali Ansari', 52 | email: 'user2@example.com', 53 | password: 'Password@123', 54 | role: 'user', 55 | isEmailVerified: true, 56 | }, 57 | { 58 | username: 'user3', 59 | name: 'Dev Tester', 60 | email: 'user3@example.com', 61 | password: 'Password@123', 62 | role: 'user', 63 | isEmailVerified: true, 64 | }, 65 | ]); 66 | 67 | // Login admin to get token 68 | const adminResponse = await request(app).post('/api/v1/auth/login').send({ 69 | email: 'admin@test.com', 70 | password: 'Password123!', 71 | }); 72 | adminToken = adminResponse.body.accessToken; 73 | 74 | // Login regular user to get token 75 | const userResponse = await request(app).post('/api/v1/auth/login').send({ 76 | email: 'user1@example.com', 77 | password: 'Password@123', 78 | }); 79 | userToken = userResponse.body.accessToken; 80 | }); 81 | 82 | afterEach(async () => { 83 | await User.deleteMany(); 84 | }); 85 | 86 | describe('User API - GET /api/v1/users/all', () => { 87 | describe('Authentication Tests', () => { 88 | it('should return 401 when no token provided', async () => { 89 | const res = await request(app).get('/api/v1/users/all'); 90 | expect(res.statusCode).toBe(401); 91 | expect(res.body.success).toBe(false); 92 | expect(res.body.message).toMatch(/not logged in/i); 93 | }); 94 | 95 | it('should return 403 when regular user tries to access', async () => { 96 | const res = await request(app) 97 | .get('/api/v1/users/all') 98 | .set('Authorization', `Bearer ${userToken}`); 99 | expect(res.statusCode).toBe(403); 100 | expect(res.body.success).toBe(false); 101 | expect(res.body.message).toMatch(/not have permission/i); 102 | }); 103 | 104 | it('should allow admin access', async () => { 105 | const res = await request(app) 106 | .get('/api/v1/users/all') 107 | .set('Authorization', `Bearer ${adminToken}`); 108 | expect(res.statusCode).toBe(200); 109 | expect(res.body.success).toBe(true); 110 | }); 111 | }); 112 | 113 | describe('Pagination Tests', () => { 114 | it('should return all users with default pagination', async () => { 115 | const res = await request(app) 116 | .get('/api/v1/users/all') 117 | .set('Authorization', `Bearer ${adminToken}`); 118 | expect(res.statusCode).toBe(200); 119 | expect(res.body.success).toBe(true); 120 | expect(res.body.data.length).toBeGreaterThan(0); 121 | expect(res.body.pagination).toHaveProperty('total'); 122 | expect(res.body.pagination.page).toBe(1); 123 | }); 124 | 125 | it('should return users with given limit', async () => { 126 | const res = await request(app) 127 | .get('/api/v1/users/all?limit=2') 128 | .set('Authorization', `Bearer ${adminToken}`); 129 | expect(res.statusCode).toBe(200); 130 | expect(res.body.data.length).toBeLessThanOrEqual(2); 131 | expect(res.body.pagination.limit).toBe(2); 132 | }); 133 | 134 | it('should return 422 if requested limit exceeds max allowed', async () => { 135 | const res = await request(app) 136 | .get('/api/v1/users/all?limit=100') 137 | .set('Authorization', `Bearer ${adminToken}`); 138 | expect(res.statusCode).toBe(422); 139 | expect(res.body.success).toBe(false); 140 | expect(res.body.errors.limit).toMatch(/Limit must be between 1 and 50/i); 141 | }); 142 | 143 | it('should return 422 for invalid page number', async () => { 144 | const res = await request(app) 145 | .get('/api/v1/users/all?page=0') 146 | .set('Authorization', `Bearer ${adminToken}`); 147 | expect(res.statusCode).toBe(422); 148 | expect(res.body.success).toBe(false); 149 | expect(res.body.errors.page).toMatch(/Page must be a positive integer/i); 150 | }); 151 | 152 | it('should return 422 for invalid limit', async () => { 153 | const res = await request(app) 154 | .get('/api/v1/users/all?limit=-5') 155 | .set('Authorization', `Bearer ${adminToken}`); 156 | expect(res.statusCode).toBe(422); 157 | expect(res.body.success).toBe(false); 158 | expect(res.body.errors.limit).toMatch(/Limit must be between 1 and 50/i); 159 | }); 160 | 161 | it('should return error for requesting non-existent page', async () => { 162 | const res = await request(app) 163 | .get('/api/v1/users/all?page=1000&limit=1') 164 | .set('Authorization', `Bearer ${adminToken}`); 165 | expect(res.statusCode).toBe(400); 166 | expect(res.body.success).toBe(false); 167 | expect(res.body.message).toMatch(/Only \d+ page/i); 168 | }); 169 | }); 170 | 171 | describe('Data Security Tests', () => { 172 | it('should not expose sensitive fields', async () => { 173 | const res = await request(app) 174 | .get('/api/v1/users/all') 175 | .set('Authorization', `Bearer ${adminToken}`); 176 | expect(res.statusCode).toBe(200); 177 | 178 | // Check that sensitive fields are not exposed 179 | const firstUser = res.body.data[0]; 180 | expect(firstUser).not.toHaveProperty('password'); 181 | expect(firstUser).not.toHaveProperty('isEmailVerified'); 182 | expect(firstUser).not.toHaveProperty('__v'); 183 | 184 | // Check that safe fields are present 185 | expect(firstUser).toHaveProperty('username'); 186 | expect(firstUser).toHaveProperty('email'); 187 | expect(firstUser).toHaveProperty('name'); 188 | expect(firstUser).toHaveProperty('role'); 189 | }); 190 | 191 | it('should return users in descending order by creation date', async () => { 192 | const res = await request(app) 193 | .get('/api/v1/users/all') 194 | .set('Authorization', `Bearer ${adminToken}`); 195 | expect(res.statusCode).toBe(200); 196 | 197 | const users = res.body.data; 198 | expect(users.length).toBeGreaterThan(1); 199 | 200 | // Check that users are sorted by newest first 201 | for (let i = 0; i < users.length - 1; i++) { 202 | const currentUserDate = new Date(users[i].createdAt); 203 | const nextUserDate = new Date(users[i + 1].createdAt); 204 | expect(currentUserDate >= nextUserDate).toBe(true); 205 | } 206 | }); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /src/dao/cart.dao.js: -------------------------------------------------------------------------------- 1 | import Cart from '../models/cart.model.js'; 2 | 3 | /** 4 | * Data Access Object for Cart operations. 5 | * Handles all direct interactions with the Cart collection. 6 | */ 7 | class CartDAO { 8 | /** 9 | * Find cart by user ID 10 | * @param {string} userId - User ID 11 | * @returns {Promise} - Found cart document or null 12 | */ 13 | async findCartByUserId(userId) { 14 | return await Cart.findOne({ user: userId }).populate({ 15 | path: 'items.product', 16 | select: 17 | 'product_name final_price main_image in_stock all_available_sizes color currency', 18 | }); 19 | } 20 | 21 | /** 22 | * Create a new cart for user 23 | * @param {string} userId - User ID 24 | * @returns {Promise} - Created cart document 25 | */ 26 | async createCart(userId) { 27 | return await Cart.create({ 28 | user: userId, 29 | items: [], 30 | totalItems: 0, 31 | totalAmount: 0, 32 | }); 33 | } 34 | 35 | /** 36 | * Add item to cart or update existing item 37 | * @param {string} userId - User ID 38 | * @param {Object} itemData - Item data to add 39 | * @returns {Promise} - Updated cart document 40 | */ 41 | async addItemToCart(userId, itemData) { 42 | const updatedCart = await Cart.findOneAndUpdate( 43 | { user: userId }, 44 | { $push: { items: itemData } }, 45 | { 46 | new: true, 47 | runValidators: true, 48 | upsert: true, 49 | } 50 | ).populate({ 51 | path: 'items.product', 52 | select: 53 | 'product_name final_price main_image in_stock all_available_sizes color currency', 54 | }); 55 | 56 | // Manually calculate totals since findOneAndUpdate doesn't trigger pre('save') 57 | if (updatedCart && updatedCart.items) { 58 | updatedCart.totalItems = updatedCart.items.reduce( 59 | (total, item) => total + item.quantity, 60 | 0 61 | ); 62 | updatedCart.totalAmount = updatedCart.items.reduce( 63 | (total, item) => total + item.price * item.quantity, 64 | 0 65 | ); 66 | 67 | // Save to persist the calculated totals 68 | await updatedCart.save(); 69 | } 70 | 71 | return updatedCart; 72 | } 73 | 74 | /** 75 | * Update cart item quantity 76 | * @param {string} userId - User ID 77 | * @param {string} itemId - Cart item ID 78 | * @param {number} quantity - New quantity 79 | * @returns {Promise} - Updated cart document or null 80 | */ 81 | async updateCartItemQuantity(userId, itemId, quantity) { 82 | const updatedCart = await Cart.findOneAndUpdate( 83 | { 84 | user: userId, 85 | 'items._id': itemId, 86 | }, 87 | { 88 | $set: { 'items.$.quantity': quantity }, 89 | }, 90 | { 91 | new: true, 92 | runValidators: true, 93 | } 94 | ).populate({ 95 | path: 'items.product', 96 | select: 97 | 'product_name final_price main_image in_stock all_available_sizes color currency', 98 | }); 99 | 100 | // Manually calculate totals since findOneAndUpdate doesn't trigger pre('save') 101 | if (updatedCart && updatedCart.items) { 102 | updatedCart.totalItems = updatedCart.items.reduce( 103 | (total, item) => total + item.quantity, 104 | 0 105 | ); 106 | updatedCart.totalAmount = updatedCart.items.reduce( 107 | (total, item) => total + item.price * item.quantity, 108 | 0 109 | ); 110 | 111 | // Save to persist the calculated totals 112 | await updatedCart.save(); 113 | } 114 | 115 | return updatedCart; 116 | } 117 | 118 | /** 119 | * Remove item from cart 120 | * @param {string} userId - User ID 121 | * @param {string} itemId - Cart item ID 122 | * @returns {Promise} - Updated cart document or null 123 | */ 124 | async removeCartItem(userId, itemId) { 125 | const updatedCart = await Cart.findOneAndUpdate( 126 | { user: userId }, 127 | { 128 | $pull: { items: { _id: itemId } }, 129 | }, 130 | { 131 | new: true, 132 | runValidators: true, 133 | } 134 | ).populate({ 135 | path: 'items.product', 136 | select: 137 | 'product_name final_price main_image in_stock all_available_sizes color currency', 138 | }); 139 | 140 | // Manually calculate totals since findOneAndUpdate doesn't trigger pre('save') 141 | if (updatedCart && updatedCart.items) { 142 | updatedCart.totalItems = updatedCart.items.reduce( 143 | (total, item) => total + item.quantity, 144 | 0 145 | ); 146 | updatedCart.totalAmount = updatedCart.items.reduce( 147 | (total, item) => total + item.price * item.quantity, 148 | 0 149 | ); 150 | 151 | // Save to persist the calculated totals 152 | await updatedCart.save(); 153 | } 154 | 155 | return updatedCart; 156 | } 157 | 158 | /** 159 | * Clear entire cart 160 | * @param {string} userId - User ID 161 | * @returns {Promise} - Updated cart document or null 162 | */ 163 | async clearCart(userId) { 164 | return await Cart.findOneAndUpdate( 165 | { user: userId }, 166 | { 167 | $set: { 168 | items: [], 169 | totalItems: 0, 170 | totalAmount: 0, 171 | }, 172 | }, 173 | { 174 | new: true, 175 | runValidators: true, 176 | } 177 | ); 178 | } 179 | 180 | /** 181 | * Check if item exists in cart 182 | * @param {string} userId - User ID 183 | * @param {string} productId - Product ID 184 | * @param {string} selectedSize - Selected size (optional) 185 | * @param {string} selectedColor - Selected color (optional) 186 | * @returns {Promise} - Found cart with matching item or null 187 | */ 188 | async findCartItemByProduct( 189 | userId, 190 | productId, 191 | selectedSize = null, 192 | selectedColor = null 193 | ) { 194 | const query = { 195 | user: userId, 196 | 'items.product': productId, 197 | }; 198 | 199 | if (selectedSize) { 200 | query['items.selectedSize'] = selectedSize; 201 | } 202 | if (selectedColor) { 203 | query['items.selectedColor'] = selectedColor; 204 | } 205 | 206 | return await Cart.findOne(query); 207 | } 208 | 209 | /** 210 | * Update existing cart item quantity by incrementing 211 | * @param {string} userId - User ID 212 | * @param {string} productId - Product ID 213 | * @param {string} selectedSize - Selected size (optional) 214 | * @param {string} selectedColor - Selected color (optional) 215 | * @param {number} quantityToAdd - Quantity to add 216 | * @returns {Promise} - Updated cart document or null 217 | */ 218 | async incrementCartItemQuantity( 219 | userId, 220 | productId, 221 | selectedSize, 222 | selectedColor, 223 | quantityToAdd 224 | ) { 225 | const matchQuery = { 226 | user: userId, 227 | 'items.product': productId, 228 | }; 229 | 230 | if (selectedSize) { 231 | matchQuery['items.selectedSize'] = selectedSize; 232 | } 233 | if (selectedColor) { 234 | matchQuery['items.selectedColor'] = selectedColor; 235 | } 236 | 237 | const updatedCart = await Cart.findOneAndUpdate( 238 | matchQuery, 239 | { 240 | $inc: { 'items.$.quantity': quantityToAdd }, 241 | }, 242 | { 243 | new: true, 244 | runValidators: true, 245 | } 246 | ).populate({ 247 | path: 'items.product', 248 | select: 249 | 'product_name final_price main_image in_stock all_available_sizes color currency', 250 | }); 251 | 252 | // Manually calculate totals since findOneAndUpdate doesn't trigger pre('save') 253 | if (updatedCart && updatedCart.items) { 254 | updatedCart.totalItems = updatedCart.items.reduce( 255 | (total, item) => total + item.quantity, 256 | 0 257 | ); 258 | updatedCart.totalAmount = updatedCart.items.reduce( 259 | (total, item) => total + item.price * item.quantity, 260 | 0 261 | ); 262 | 263 | // Save to persist the calculated totals 264 | await updatedCart.save(); 265 | } 266 | 267 | return updatedCart; 268 | } 269 | } 270 | 271 | export default new CartDAO(); 272 | -------------------------------------------------------------------------------- /src/middlewares/rateLimiter.middleware.js: -------------------------------------------------------------------------------- 1 | import rateLimit from 'express-rate-limit'; 2 | import RedisStore from 'rate-limit-redis'; 3 | import redisService from '../config/redis.js'; 4 | import logger from '../loggers/winston.logger.js'; 5 | 6 | /** 7 | * Create Redis store for rate limiter 8 | * @param {string} prefix - Redis key prefix 9 | * @returns {RedisStore|undefined} Redis store instance or undefined if Redis is not available 10 | */ 11 | const createRedisStore = (prefix) => { 12 | try { 13 | // Check if Redis client is available and connected 14 | if (redisService.client && redisService.isConnected()) { 15 | return new RedisStore({ 16 | sendCommand: (...args) => redisService.client.call(...args), 17 | prefix: prefix, 18 | }); 19 | } 20 | return undefined; 21 | } catch (error) { 22 | logger.warn(`Redis store creation failed: ${error.message}`); 23 | return undefined; 24 | } 25 | }; 26 | 27 | /** 28 | * Rate limiter factory that creates rate limiters with Redis or memory store 29 | * In testing environment, rate limiting is disabled to prevent test failures 30 | */ 31 | const createRateLimiterWithFallback = (options) => { 32 | let limiter = null; 33 | 34 | return (req, res, next) => { 35 | // Bypass rate limiting in testing environment 36 | if (process.env.NODE_ENV === 'testing') { 37 | return next(); 38 | } 39 | 40 | // Create the rate limiter only once per middleware instance 41 | if (!limiter) { 42 | const redisStore = createRedisStore(options.storePrefix); 43 | if (redisStore) { 44 | logger.info( 45 | `Using Redis store for rate limiting: ${options.storePrefix}` 46 | ); 47 | limiter = rateLimit({ 48 | ...options, 49 | store: redisStore, 50 | }); 51 | } else { 52 | logger.warn( 53 | `Using memory store for rate limiting: ${options.storePrefix}` 54 | ); 55 | limiter = rateLimit({ 56 | ...options, 57 | // No store specified, will use default memory store 58 | }); 59 | } 60 | } 61 | 62 | return limiter(req, res, next); 63 | }; 64 | }; 65 | 66 | /** 67 | * Rate limiter for registration route 68 | * Limits requests to 5 per minute per IP address 69 | */ 70 | export const registerRateLimiter = createRateLimiterWithFallback({ 71 | windowMs: 60 * 1000, // 1 minute 72 | max: 5, // 5 requests per minute per IP 73 | standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers 74 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers 75 | storePrefix: 'rl:register:', // Redis key prefix for registration rate limiting 76 | 77 | // Custom message when rate limit is exceeded 78 | message: { 79 | status: 'error', 80 | statusCode: 429, 81 | message: 82 | 'Too many registration attempts from this IP, please try again in a minute.', 83 | details: { 84 | retryAfter: '60 seconds', 85 | maxRequests: 5, 86 | windowMs: 60000, 87 | }, 88 | }, 89 | 90 | // Custom handler for rate limit exceeded 91 | handler: (req, res) => { 92 | res.status(429).json({ 93 | status: 'error', 94 | statusCode: 429, 95 | message: 96 | 'Too many registration attempts from this IP, please try again in a minute.', 97 | details: { 98 | retryAfter: '60 seconds', 99 | maxRequests: 5, 100 | windowMs: 60000, 101 | ip: req.ip, 102 | timestamp: new Date().toISOString(), 103 | }, 104 | }); 105 | }, 106 | }); 107 | 108 | /** 109 | * General authentication rate limiter for other auth routes 110 | * More lenient than registration rate limiter 111 | */ 112 | export const authRateLimiter = createRateLimiterWithFallback({ 113 | windowMs: 15 * 60 * 1000, // 15 minutes 114 | max: 20, // 20 requests per 15 minutes per IP 115 | standardHeaders: true, 116 | legacyHeaders: false, 117 | storePrefix: 'rl:auth:', // Redis key prefix for general auth rate limiting 118 | 119 | message: { 120 | status: 'error', 121 | statusCode: 429, 122 | message: 123 | 'Too many authentication attempts from this IP, please try again later.', 124 | details: { 125 | retryAfter: '15 minutes', 126 | maxRequests: 20, 127 | windowMs: 900000, 128 | }, 129 | }, 130 | 131 | handler: (req, res) => { 132 | res.status(429).json({ 133 | status: 'error', 134 | statusCode: 429, 135 | message: 136 | 'Too many authentication attempts from this IP, please try again later.', 137 | details: { 138 | retryAfter: '15 minutes', 139 | maxRequests: 20, 140 | windowMs: 900000, 141 | ip: req.ip, 142 | timestamp: new Date().toISOString(), 143 | }, 144 | }); 145 | }, 146 | }); 147 | 148 | export const generalRateLimiter = createRateLimiterWithFallback({ 149 | windowMs: 15 * 60 * 1000, // 15 minutes 150 | max: 100, // 100 requests per 15 minutes per IP 151 | standardHeaders: true, 152 | legacyHeaders: false, 153 | storePrefix: 'rl:general:', // Redis key prefix for general rate limiting 154 | 155 | message: { 156 | status: 'error', 157 | statusCode: 429, 158 | message: 'Too many requests from this IP, please try again later.', 159 | details: { 160 | retryAfter: '15 minutes', 161 | maxRequests: 100, 162 | windowMs: 900000, 163 | }, 164 | }, 165 | 166 | handler: (req, res) => { 167 | res.status(429).json({ 168 | status: 'error', 169 | statusCode: 429, 170 | message: 'Too many requests from this IP, please try again later.', 171 | details: { 172 | retryAfter: '15 minutes', 173 | maxRequests: 100, 174 | windowMs: 900000, 175 | ip: req.ip, 176 | timestamp: new Date().toISOString(), 177 | }, 178 | }); 179 | }, 180 | }); 181 | 182 | export const productRateLimiter = createRateLimiterWithFallback({ 183 | windowMs: 10 * 60 * 1000, // 10 minutes 184 | max: 10, // 10 requests per 10 minutes per IP 185 | standardHeaders: true, 186 | legacyHeaders: false, 187 | storePrefix: 'rl:product:', // Redis key prefix for product rate limiting 188 | 189 | message: { 190 | status: 'error', 191 | statusCode: 429, 192 | message: 'Too many product requests from this IP, please try again later.', 193 | details: { 194 | retryAfter: '10 minutes', 195 | maxRequests: 10, 196 | windowMs: 600000, 197 | }, 198 | }, 199 | 200 | handler: (req, res) => { 201 | res.status(429).json({ 202 | status: 'error', 203 | statusCode: 429, 204 | message: 205 | 'Too many product requests from this IP, please try again later.', 206 | details: { 207 | retryAfter: '10 minutes', 208 | maxRequests: 10, 209 | windowMs: 600000, 210 | ip: req.ip, 211 | timestamp: new Date().toISOString(), 212 | }, 213 | }); 214 | }, 215 | }); 216 | 217 | export const userRateLimiter = createRateLimiterWithFallback({ 218 | windowMs: 5 * 60 * 1000, // 5 minutes 219 | max: 100, // limit each IP to 100 requests per windowMs for user endpoints 220 | standardHeaders: true, 221 | legacyHeaders: false, 222 | store: createRedisStore('rate_limit:user:'), 223 | keyGenerator: (req) => `${req.ip}:user`, 224 | skipSuccessfulRequests: false, 225 | skipFailedRequests: false, 226 | message: { 227 | status: 'error', 228 | statusCode: 429, 229 | message: 'Too many user requests from this IP, please try again later.', 230 | details: { 231 | retryAfter: '5 minutes', 232 | maxRequests: 100, 233 | windowMs: 300000, 234 | }, 235 | }, 236 | onLimitReached: (req, res, options) => { 237 | logger.warn('User rate limit exceeded', { 238 | ip: req.ip, 239 | userAgent: req.get('User-Agent'), 240 | path: req.path, 241 | method: req.method, 242 | limit: options.max, 243 | windowMs: options.windowMs, 244 | }); 245 | }, 246 | handler: (req, res) => { 247 | res.status(429).json({ 248 | status: 'error', 249 | statusCode: 429, 250 | message: 'Too many user requests from this IP, please try again later.', 251 | details: { 252 | retryAfter: '5 minutes', 253 | maxRequests: 100, 254 | windowMs: 300000, 255 | ip: req.ip, 256 | timestamp: new Date().toISOString(), 257 | }, 258 | }); 259 | }, 260 | }); 261 | 262 | export default { 263 | registerRateLimiter, 264 | authRateLimiter, 265 | generalRateLimiter, 266 | productRateLimiter, 267 | userRateLimiter, 268 | }; 269 | -------------------------------------------------------------------------------- /src/__tests__/auth.test.js: -------------------------------------------------------------------------------- 1 | // src/__tests__/auth.test.js 2 | import request from 'supertest'; 3 | import app from '../app.js'; // Your main Express app 4 | import User from '../models/user.model.js'; // Your User model 5 | import redisService from '../config/redis.js'; // Your Redis service 6 | 7 | describe('Auth API Routes', () => { 8 | // Test for User Registration 9 | describe('POST /api/v1/auth/register', () => { 10 | it('should register a new user successfully', async () => { 11 | const userData = { 12 | username: 'testuser', 13 | email: 'test@example.com', 14 | password: 'Password123!', 15 | name: 'Test User', 16 | }; 17 | 18 | const response = await request(app) 19 | .post('/api/v1/auth/register') 20 | .send(userData) 21 | .expect('Content-Type', /json/) 22 | .expect(201); 23 | 24 | expect(response.body.success).toBe(true); 25 | expect(response.body.data.email).toBe(userData.email); 26 | expect(response.body.data.username).toBe(userData.username); 27 | expect(response.body.data).not.toHaveProperty('password'); 28 | expect(response.body.accessToken).toBeDefined(); 29 | expect(response.body.refreshToken).toBeDefined(); 30 | 31 | // Verify cookies are set 32 | const cookies = response.headers['set-cookie']; 33 | expect(cookies.some((cookie) => cookie.startsWith('accessToken='))).toBe( 34 | true 35 | ); 36 | expect(cookies.some((cookie) => cookie.startsWith('refreshToken='))).toBe( 37 | true 38 | ); 39 | 40 | // Verify user in database 41 | const dbUser = await User.findOne({ email: userData.email }); 42 | expect(dbUser).not.toBeNull(); 43 | expect(dbUser.username).toBe(userData.username); 44 | 45 | // Verify refresh token in Redis 46 | const redisToken = await redisService.get(`refreshToken:${dbUser._id}`); 47 | expect(redisToken).toBe(response.body.refreshToken); 48 | }); 49 | 50 | it('should return 400 if email already exists', async () => { 51 | // First, create a user 52 | await User.create({ 53 | username: 'existinguser', 54 | email: 'exists@example.com', 55 | password: 'Password123!', 56 | }); 57 | 58 | const userData = { 59 | username: 'newuser', 60 | email: 'exists@example.com', // Duplicate email 61 | password: 'Password456!', 62 | name: 'New User', 63 | }; 64 | 65 | const response = await request(app) 66 | .post('/api/v1/auth/register') 67 | .send(userData) 68 | .expect('Content-Type', /json/) 69 | .expect(400); 70 | 71 | expect(response.body.success).toBe(false); 72 | expect(response.body.message).toBe('Email already registered.'); 73 | }); 74 | 75 | it('should return 422 for invalid registration data (e.g., missing email)', async () => { 76 | const userData = { 77 | username: 'testuserInvalid', 78 | // email is missing 79 | password: 'Password123!', 80 | name: 'Test User Invalid', 81 | }; 82 | 83 | const response = await request(app) 84 | .post('/api/v1/auth/register') 85 | .send(userData) 86 | .expect('Content-Type', /json/) 87 | .expect(422); // Validation error 88 | 89 | expect(response.body.success).toBe(false); 90 | expect(response.body.message).toBe('Validation failed'); 91 | expect(response.body.errors.email).toBe('Email is required'); 92 | }); 93 | }); 94 | 95 | // Test for User Login 96 | describe('POST /api/v1/auth/login', () => { 97 | const loginCredentials = { 98 | email: 'login@example.com', 99 | password: 'PasswordSecure1!', 100 | }; 101 | 102 | beforeEach(async () => { 103 | // Create a user to login with 104 | await User.create({ 105 | username: 'loginuser', 106 | email: loginCredentials.email, 107 | password: loginCredentials.password, // Password will be hashed by pre-save hook 108 | name: 'Login User', 109 | }); 110 | }); 111 | 112 | it('should login an existing user successfully', async () => { 113 | const response = await request(app) 114 | .post('/api/v1/auth/login') 115 | .send(loginCredentials) 116 | .expect('Content-Type', /json/) 117 | .expect(200); 118 | 119 | expect(response.body.success).toBe(true); 120 | expect(response.body.data.email).toBe(loginCredentials.email); 121 | expect(response.body.accessToken).toBeDefined(); 122 | expect(response.body.refreshToken).toBeDefined(); 123 | 124 | // Verify cookies 125 | const cookies = response.headers['set-cookie']; 126 | expect(cookies.some((cookie) => cookie.startsWith('accessToken='))).toBe( 127 | true 128 | ); 129 | expect(cookies.some((cookie) => cookie.startsWith('refreshToken='))).toBe( 130 | true 131 | ); 132 | 133 | // Verify refresh token in Redis 134 | const dbUser = await User.findOne({ email: loginCredentials.email }); 135 | const redisToken = await redisService.get(`refreshToken:${dbUser._id}`); 136 | expect(redisToken).toBe(response.body.refreshToken); 137 | }); 138 | 139 | it('should return 401 for invalid credentials (wrong password)', async () => { 140 | const response = await request(app) 141 | .post('/api/v1/auth/login') 142 | .send({ email: loginCredentials.email, password: 'WrongPassword!' }) 143 | .expect('Content-Type', /json/) 144 | .expect(401); 145 | 146 | expect(response.body.success).toBe(false); 147 | expect(response.body.message).toBe('Invalid email or password.'); 148 | }); 149 | 150 | it('should return 401 for non-existent user', async () => { 151 | const response = await request(app) 152 | .post('/api/v1/auth/login') 153 | .send({ email: 'nonexistent@example.com', password: 'Password123!' }) 154 | .expect('Content-Type', /json/) 155 | .expect(401); 156 | 157 | expect(response.body.success).toBe(false); 158 | expect(response.body.message).toBe('Invalid email or password.'); 159 | }); 160 | 161 | it('should return 422 for invalid login data (e.g., invalid email format)', async () => { 162 | const response = await request(app) 163 | .post('/api/v1/auth/login') 164 | .send({ email: 'notanemail', password: 'Password123!' }) 165 | .expect('Content-Type', /json/) 166 | .expect(422); 167 | 168 | expect(response.body.success).toBe(false); 169 | expect(response.body.message).toBe('Validation failed'); 170 | expect(response.body.errors.email).toBe('Please provide a valid email'); 171 | }); 172 | }); 173 | 174 | // Add more describe blocks for other auth routes like /getme, /logout, /access-token etc. 175 | // Example for /getme (protected route) 176 | describe('GET /api/v1/auth/getme', () => { 177 | let token; 178 | let userId; 179 | 180 | beforeEach(async () => { 181 | // Register and login a user to get a token 182 | const userData = { 183 | username: 'getmeuser', 184 | email: 'getme@example.com', 185 | password: 'PasswordGetMe1!', 186 | name: 'GetMe User', 187 | }; 188 | const registerResponse = await request(app) 189 | .post('/api/v1/auth/register') 190 | .send(userData); 191 | 192 | token = registerResponse.body.accessToken; 193 | userId = registerResponse.body.data._id; // Assuming _id is returned 194 | }); 195 | 196 | it('should return user details for a logged-in user', async () => { 197 | const response = await request(app) 198 | .get('/api/v1/auth/getme') 199 | .set('Cookie', [`accessToken=${token}`]) // Set cookie for authentication 200 | // OR .set('Authorization', `Bearer ${token}`) if you also support header auth 201 | .expect('Content-Type', /json/) 202 | .expect(200); 203 | 204 | expect(response.body.success).toBe(true); 205 | expect(response.body.data.email).toBe('getme@example.com'); 206 | expect(response.body.data._id).toBe(userId); 207 | }); 208 | 209 | it('should return 401 if no token is provided', async () => { 210 | const response = await request(app) 211 | .get('/api/v1/auth/getme') 212 | .expect('Content-Type', /json/) 213 | .expect(401); 214 | 215 | expect(response.body.success).toBe(false); 216 | expect(response.body.message).toBe( 217 | 'You are not logged in. Please log in to get access.' 218 | ); 219 | }); 220 | }); 221 | }); 222 | -------------------------------------------------------------------------------- /src/controllers/payment.controller.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from '../utils/asyncHandler.js'; 2 | import paymentService from '../services/payment.service.js'; 3 | import AppError from '../utils/appError.js'; 4 | import Payment from '../models/payment.model.js'; 5 | 6 | // Create order for cart payment 7 | export const createCartOrder = asyncHandler(async (req, res) => { 8 | const userId = req.user.id; 9 | const { shippingAddress, notes } = req.body; 10 | 11 | const orderData = await paymentService.createCartOrder( 12 | userId, 13 | shippingAddress, 14 | notes 15 | ); 16 | 17 | res.status(201).json({ 18 | status: 'success', 19 | message: 'Cart order created successfully', 20 | data: orderData, 21 | }); 22 | }); 23 | 24 | // Create order for single product payment 25 | export const createSingleProductOrder = asyncHandler(async (req, res) => { 26 | const userId = req.user.id; 27 | const { 28 | productId, 29 | quantity, 30 | selectedSize, 31 | selectedColor, 32 | shippingAddress, 33 | notes, 34 | } = req.body; 35 | 36 | if (!productId) { 37 | throw new AppError('Product ID is required', 400); 38 | } 39 | 40 | const orderData = await paymentService.createSingleProductOrder( 41 | userId, 42 | productId, 43 | quantity || 1, 44 | selectedSize, 45 | selectedColor, 46 | shippingAddress, 47 | notes 48 | ); 49 | 50 | res.status(201).json({ 51 | status: 'success', 52 | message: 'Single product order created successfully', 53 | data: orderData, 54 | }); 55 | }); 56 | 57 | // Verify payment 58 | export const verifyPayment = asyncHandler(async (req, res) => { 59 | const { razorpay_order_id, razorpay_payment_id, razorpay_signature } = 60 | req.body; 61 | 62 | if (!razorpay_order_id || !razorpay_payment_id || !razorpay_signature) { 63 | throw new AppError('Missing payment verification data', 400); 64 | } 65 | 66 | const verificationResult = await paymentService.verifyPayment( 67 | razorpay_order_id, 68 | razorpay_payment_id, 69 | razorpay_signature 70 | ); 71 | 72 | res.status(200).json({ 73 | status: 'success', 74 | message: 'Payment verified successfully', 75 | data: verificationResult, 76 | }); 77 | }); 78 | 79 | // Get payment details 80 | export const getPaymentDetails = asyncHandler(async (req, res) => { 81 | const { paymentId } = req.params; 82 | 83 | if (!paymentId) { 84 | throw new AppError('Payment ID is required', 400); 85 | } 86 | 87 | const payment = await paymentService.getPaymentDetails(paymentId); 88 | 89 | // Check if user owns this payment 90 | if (payment.user._id.toString() !== req.user.id) { 91 | throw new AppError('Access denied', 403); 92 | } 93 | 94 | res.status(200).json({ 95 | status: 'success', 96 | data: { payment }, 97 | }); 98 | }); 99 | 100 | // Get user payment history 101 | export const getPaymentHistory = asyncHandler(async (req, res) => { 102 | const userId = req.user.id; 103 | const { status, page = 1, limit = 10 } = req.query; 104 | 105 | const result = await paymentService.getUserPaymentHistory( 106 | userId, 107 | status, 108 | parseInt(page), 109 | parseInt(limit) 110 | ); 111 | 112 | res.status(200).json({ 113 | status: 'success', 114 | data: result, 115 | }); 116 | }); 117 | 118 | // Request refund (Admin only or specific conditions) 119 | export const requestRefund = asyncHandler(async (req, res) => { 120 | const { paymentId } = req.params; 121 | const { refundAmount, reason } = req.body; 122 | 123 | if (!paymentId) { 124 | throw new AppError('Payment ID is required', 400); 125 | } 126 | 127 | // Get payment to check ownership 128 | const payment = await paymentService.getPaymentDetails(paymentId); 129 | 130 | // Check if user owns this payment or is admin 131 | if ( 132 | payment.user._id.toString() !== req.user.id && 133 | req.user.role !== 'admin' 134 | ) { 135 | throw new AppError('Access denied', 403); 136 | } 137 | 138 | const refundResult = await paymentService.refundPayment( 139 | paymentId, 140 | refundAmount, 141 | reason 142 | ); 143 | 144 | res.status(200).json({ 145 | status: 'success', 146 | message: 'Refund processed successfully', 147 | data: refundResult, 148 | }); 149 | }); 150 | 151 | // Handle Razorpay webhooks 152 | export const handleWebhook = asyncHandler(async (req, res) => { 153 | const signature = req.headers['x-razorpay-signature']; 154 | const payload = req.body; 155 | 156 | if (!signature) { 157 | throw new AppError('Missing webhook signature', 400); 158 | } 159 | 160 | const result = await paymentService.handleWebhook(signature, payload); 161 | 162 | res.status(200).json({ 163 | status: 'success', 164 | message: 'Webhook processed successfully', 165 | data: result, 166 | }); 167 | }); 168 | 169 | // Get payment statistics (Admin only) 170 | export const getPaymentStats = asyncHandler(async (req, res) => { 171 | if (req.user.role !== 'admin') { 172 | throw new AppError('Access denied', 403); 173 | } 174 | 175 | const { startDate, endDate } = req.query; 176 | 177 | // Build date filter 178 | const dateFilter = {}; 179 | 180 | // Add start date filter if provided 181 | if (startDate && startDate.trim() !== '') { 182 | const start = new Date(startDate); 183 | if (isNaN(start.getTime())) { 184 | throw new AppError('Invalid start date format', 400); 185 | } 186 | dateFilter.$gte = start; 187 | } 188 | 189 | // Add end date filter if provided 190 | if (endDate && endDate.trim() !== '') { 191 | const end = new Date(endDate); 192 | if (isNaN(end.getTime())) { 193 | throw new AppError('Invalid end date format', 400); 194 | } 195 | dateFilter.$lte = end; 196 | } 197 | 198 | // Build match stage for aggregation 199 | const matchStage = {}; 200 | if (Object.keys(dateFilter).length > 0) { 201 | matchStage.createdAt = dateFilter; 202 | } 203 | 204 | const stats = await Payment.aggregate([ 205 | { $match: matchStage }, 206 | { 207 | $group: { 208 | _id: null, 209 | totalPayments: { $sum: 1 }, 210 | totalAmount: { $sum: '$amount' }, 211 | successfulPayments: { 212 | $sum: { $cond: [{ $eq: ['$status', 'paid'] }, 1, 0] }, 213 | }, 214 | failedPayments: { 215 | $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] }, 216 | }, 217 | refundedPayments: { 218 | $sum: { $cond: [{ $eq: ['$status', 'refunded'] }, 1, 0] }, 219 | }, 220 | averageAmount: { $avg: '$amount' }, 221 | }, 222 | }, 223 | { 224 | $addFields: { 225 | successRate: { 226 | $multiply: [ 227 | { $divide: ['$successfulPayments', '$totalPayments'] }, 228 | 100, 229 | ], 230 | }, 231 | totalAmountFormatted: { $divide: ['$totalAmount', 100] }, 232 | averageAmountFormatted: { $divide: ['$averageAmount', 100] }, 233 | }, 234 | }, 235 | ]); 236 | 237 | // Get payment method breakdown 238 | const methodStats = await Payment.aggregate([ 239 | { $match: { status: 'paid', ...matchStage } }, 240 | { 241 | $group: { 242 | _id: '$paymentMethod', 243 | count: { $sum: 1 }, 244 | amount: { $sum: '$amount' }, 245 | }, 246 | }, 247 | { 248 | $addFields: { 249 | amountFormatted: { $divide: ['$amount', 100] }, 250 | }, 251 | }, 252 | ]); 253 | 254 | // Get order type breakdown 255 | const orderTypeStats = await Payment.aggregate([ 256 | { $match: matchStage }, 257 | { 258 | $group: { 259 | _id: '$orderType', 260 | count: { $sum: 1 }, 261 | amount: { $sum: '$amount' }, 262 | }, 263 | }, 264 | { 265 | $addFields: { 266 | amountFormatted: { $divide: ['$amount', 100] }, 267 | }, 268 | }, 269 | ]); 270 | 271 | res.status(200).json({ 272 | status: 'success', 273 | data: { 274 | overview: stats[0] || { 275 | totalPayments: 0, 276 | totalAmount: 0, 277 | successfulPayments: 0, 278 | failedPayments: 0, 279 | refundedPayments: 0, 280 | averageAmount: 0, 281 | successRate: 0, 282 | totalAmountFormatted: 0, 283 | averageAmountFormatted: 0, 284 | }, 285 | paymentMethods: methodStats, 286 | orderTypes: orderTypeStats, 287 | }, 288 | }); 289 | }); 290 | 291 | export default { 292 | createCartOrder, 293 | createSingleProductOrder, 294 | verifyPayment, 295 | getPaymentDetails, 296 | getPaymentHistory, 297 | requestRefund, 298 | handleWebhook, 299 | getPaymentStats, 300 | }; 301 | -------------------------------------------------------------------------------- /src/services/product.service.js: -------------------------------------------------------------------------------- 1 | import productDAO from '../dao/product.dao.js'; 2 | import AppError from '../utils/appError.js'; 3 | import logger from '../loggers/winston.logger.js'; 4 | 5 | class ProductService { 6 | /** 7 | * Create a new product with validation and business logic 8 | * @param {Object} productData - Product data from request 9 | * @returns {Promise} - Created product 10 | */ 11 | async createProduct(productData) { 12 | try { 13 | // Business logic validations 14 | await this.validateProductBusinessRules(productData); 15 | 16 | // Set default values if not provided 17 | const processedData = this.processProductData(productData); 18 | 19 | // Create product through DAO 20 | const createdProduct = await productDAO.createProduct(processedData); 21 | 22 | // Return sanitized product data 23 | return this.sanitizeProductResponse(createdProduct); 24 | } catch (error) { 25 | if (error.code === 11000) { 26 | // Handle duplicate key error 27 | throw new AppError('Product with this name already exists', 409); 28 | } 29 | throw error; 30 | } 31 | } 32 | 33 | /** 34 | * Validate business rules for product creation 35 | * @param {Object} productData - Product data to validate 36 | */ 37 | async validateProductBusinessRules(productData) { 38 | // Check if final price is reasonable compared to initial price 39 | if (productData.final_price > productData.initial_price) { 40 | throw new AppError( 41 | 'Final price cannot be greater than initial price', 42 | 400 43 | ); 44 | } 45 | 46 | // Check for minimum discount if prices are different 47 | if (productData.initial_price !== productData.final_price) { 48 | const discountPercentage = 49 | ((productData.initial_price - productData.final_price) / 50 | productData.initial_price) * 51 | 100; 52 | if (discountPercentage < 1) { 53 | throw new AppError('Minimum discount should be at least 1%', 400); 54 | } 55 | } 56 | 57 | // Validate image consistency 58 | if (productData.image_urls && productData.image_count) { 59 | if (productData.image_urls.length !== productData.image_count) { 60 | throw new AppError( 61 | 'Image count must match the number of image URLs provided', 62 | 400 63 | ); 64 | } 65 | } 66 | 67 | // Validate category hierarchy 68 | if (productData.category_tree && productData.category_tree.length > 0) { 69 | if ( 70 | productData.root_category && 71 | !productData.category_tree.includes(productData.root_category) 72 | ) { 73 | throw new AppError( 74 | 'Root category must be present in category tree', 75 | 400 76 | ); 77 | } 78 | } 79 | } 80 | 81 | /** 82 | * Process and set default values for product data 83 | * @param {Object} productData - Raw product data 84 | * @returns {Object} - Processed product data 85 | */ 86 | processProductData(productData) { 87 | const processedData = { ...productData }; 88 | 89 | // Set image count based on image URLs if not provided 90 | if (processedData.image_urls && !processedData.image_count) { 91 | processedData.image_count = processedData.image_urls.length; 92 | } 93 | 94 | // Set default image count if not provided 95 | if (!processedData.image_count) { 96 | processedData.image_count = 1; 97 | } 98 | 99 | // Ensure main image is included in image URLs 100 | if (processedData.main_image && processedData.image_urls) { 101 | if (!processedData.image_urls.includes(processedData.main_image)) { 102 | processedData.image_urls.unshift(processedData.main_image); 103 | processedData.image_count = processedData.image_urls.length; 104 | } 105 | } 106 | 107 | // Set root category from category tree if not provided 108 | if ( 109 | !processedData.root_category && 110 | processedData.category_tree && 111 | processedData.category_tree.length > 0 112 | ) { 113 | processedData.root_category = processedData.category_tree[0]; 114 | } 115 | 116 | // Set category from category tree if not provided 117 | if ( 118 | !processedData.category && 119 | processedData.category_tree && 120 | processedData.category_tree.length > 0 121 | ) { 122 | processedData.category = 123 | processedData.category_tree[processedData.category_tree.length - 1]; 124 | } 125 | 126 | // Add current size to available sizes if not present 127 | if (processedData.size && processedData.all_available_sizes) { 128 | if (!processedData.all_available_sizes.includes(processedData.size)) { 129 | processedData.all_available_sizes.push(processedData.size); 130 | } 131 | } else if (processedData.size && !processedData.all_available_sizes) { 132 | processedData.all_available_sizes = [processedData.size]; 133 | } 134 | 135 | return processedData; 136 | } 137 | 138 | /** 139 | * Remove sensitive data from product response 140 | * @param {Object} product - Product document 141 | * @returns {Object} - Sanitized product data 142 | */ 143 | sanitizeProductResponse(product) { 144 | const productObj = product.toObject ? product.toObject() : product; 145 | 146 | // Add calculated fields 147 | if (productObj.initial_price && productObj.final_price) { 148 | const discount = productObj.initial_price - productObj.final_price; 149 | const discountPercentage = (discount / productObj.initial_price) * 100; 150 | 151 | productObj.discount_amount = parseFloat(discount.toFixed(2)); 152 | productObj.discount_percentage = parseFloat( 153 | discountPercentage.toFixed(2) 154 | ); 155 | } 156 | 157 | return productObj; 158 | } 159 | 160 | /** 161 | * Get product by ID 162 | * @param {string} productId - Product ID 163 | * @returns {Promise} - Product data 164 | */ 165 | async getProductById(productId) { 166 | const product = await productDAO.findProductById(productId); 167 | if (!product) { 168 | throw new AppError('Product not found', 404); 169 | } 170 | return this.sanitizeProductResponse(product); 171 | } 172 | 173 | /** 174 | * Get all products with pagination 175 | * @param {number} page - Page number 176 | * @param {number} limit - Items per page 177 | * @returns {Promise} - Products with pagination info 178 | */ 179 | async getAllProducts(page = 0, limit = 20) { 180 | const products = await productDAO.findAllProducts(page, limit); 181 | const totalProducts = await productDAO.countAllProducts(); 182 | 183 | return { 184 | products: products.map((product) => 185 | this.sanitizeProductResponse(product) 186 | ), 187 | pagination: { 188 | currentPage: page, 189 | limit, 190 | totalProducts, 191 | totalPages: Math.ceil(totalProducts / limit), 192 | hasNextPage: (page + 1) * limit < totalProducts, 193 | hasPrevPage: page > 0, 194 | }, 195 | }; 196 | } 197 | 198 | /** 199 | * Search products using fuzzy search 200 | * @param {string} keyword - Search keyword 201 | * @param {number} limit - Number of results 202 | * @returns {Promise} - Search results 203 | */ 204 | async searchProducts(keyword, limit = 20) { 205 | try { 206 | const products = await productDAO.fuzzySearch(keyword, limit); 207 | return products.map((product) => this.sanitizeProductResponse(product)); 208 | } catch (error) { 209 | // Fallback to basic text search if Atlas Search is not available 210 | logger.warn( 211 | 'Atlas Search not available, falling back to basic search:', 212 | error.message 213 | ); 214 | const products = await productDAO.findProductsByName(keyword, limit); 215 | return products.map((product) => this.sanitizeProductResponse(product)); 216 | } 217 | } 218 | 219 | /** 220 | * Get autocomplete suggestions 221 | * @param {string} keyword - Search keyword 222 | * @param {number} limit - Number of suggestions 223 | * @returns {Promise} - Autocomplete suggestions 224 | */ 225 | async getAutocompleteSuggestions(keyword, limit = 7) { 226 | try { 227 | const suggestions = await productDAO.autocompleteSearch(keyword, limit); 228 | return suggestions; 229 | } catch (error) { 230 | // Fallback to basic search if Atlas Search is not available 231 | logger.warn( 232 | 'Atlas Search autocomplete not available, falling back to basic search:', 233 | error.message 234 | ); 235 | const products = await productDAO.findProductsByName(keyword, limit); 236 | return products; 237 | } 238 | } 239 | 240 | /** 241 | * Get random product 242 | * @returns {Promise} - Random product 243 | */ 244 | async getRandomProduct() { 245 | const product = await productDAO.getRandomProduct(); 246 | return product ? this.sanitizeProductResponse(product) : null; 247 | } 248 | } 249 | 250 | export default new ProductService(); 251 | -------------------------------------------------------------------------------- /src/config/redis.js: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | import config from './config.js'; 3 | import logger from '../loggers/winston.logger.js'; 4 | 5 | /** 6 | * Redis service class 7 | * Using ioredis package for Redis operations 8 | */ 9 | class RedisService { 10 | static #instance; 11 | 12 | /** 13 | * Get the singleton instance of RedisService 14 | * @param {Object} options - Redis connection options 15 | * @returns {RedisService} - The singleton instance 16 | */ 17 | static getInstance(options = {}) { 18 | if (!RedisService.#instance) { 19 | RedisService.#instance = new RedisService(options); 20 | } 21 | return RedisService.#instance; 22 | } 23 | 24 | /** 25 | * Create a new RedisService instance (private constructor) 26 | * @param {Object} options - Redis connection options 27 | */ 28 | constructor(options = {}) { 29 | // Prevent multiple instances 30 | if (RedisService.#instance) { 31 | return RedisService.#instance; 32 | } 33 | 34 | this.options = { 35 | host: options.host || config.redis?.host || '127.0.0.1', 36 | port: options.port || config.redis?.port || 6379, 37 | username: options.username || config.redis?.username || '', 38 | password: options.password || config.redis?.password || '', 39 | db: options.db || config.redis?.db || 0, 40 | onConnect: options.onConnect || (() => {}), 41 | onError: 42 | options.onError || 43 | ((err) => logger.error(`Redis client error: ${err.message}`)), 44 | }; 45 | 46 | this.client = null; 47 | RedisService.#instance = this; 48 | } 49 | 50 | /** 51 | * Initialize Redis client if not already initialized 52 | * @returns {Promise} - True if successfully connected or already connected 53 | */ 54 | async connect() { 55 | if (this.client && this.isConnected()) { 56 | logger.info('Redis already connected, reusing existing connection'); 57 | return true; 58 | } 59 | 60 | try { 61 | // Create Redis instance 62 | this.client = new Redis({ 63 | port: this.options.port, 64 | host: this.options.host, 65 | username: this.options.username, 66 | password: this.options.password, 67 | db: this.options.db, 68 | retryStrategy: (times) => { 69 | // Custom retry strategy 70 | const delay = Math.min(times * 50, 2000); 71 | return delay; 72 | }, 73 | }); 74 | 75 | // Redis connection events 76 | this.client.on('connect', () => { 77 | logger.info('Redis client connected'); 78 | this.options.onConnect(); 79 | }); 80 | 81 | this.client.on('error', (err) => { 82 | logger.error(`Redis client error: ${err.message}`); 83 | this.options.onError(err); 84 | }); 85 | 86 | this.client.on('ready', () => { 87 | logger.info('Redis client ready'); 88 | }); 89 | 90 | this.client.on('reconnecting', () => { 91 | logger.info('Redis client reconnecting'); 92 | }); 93 | 94 | this.client.on('end', () => { 95 | logger.info('Redis client connection closed'); 96 | }); 97 | 98 | return true; 99 | } catch (err) { 100 | logger.error(`Redis initialization error: ${err.message}`); 101 | return false; 102 | } 103 | } 104 | 105 | /** 106 | * Get Redis connection status 107 | * @returns {boolean} - True if connected, false otherwise 108 | */ 109 | isConnected() { 110 | return this.client && this.client.status === 'ready'; 111 | } 112 | 113 | /** 114 | * Close Redis connection 115 | * @returns {Promise} 116 | */ 117 | async disconnect() { 118 | if (this.client) { 119 | await this.client.quit(); 120 | this.client = null; 121 | logger.info('Redis connection closed gracefully'); 122 | } 123 | } 124 | 125 | /** 126 | * Get a value from Redis 127 | * @param {string} key - Key to get value for 128 | * @returns {Promise} - Promise resolving to the value or null if not found 129 | */ 130 | async get(key) { 131 | try { 132 | const value = await this.client.get(key); 133 | return value ? JSON.parse(value) : null; 134 | } catch (err) { 135 | logger.error(`Redis GET error for key ${key}: ${err.message}`); 136 | return null; 137 | } 138 | } 139 | 140 | /** 141 | * Set a value in Redis 142 | * @param {string} key - Key to set 143 | * @param {any} value - Value to store (will be stringified) 144 | * @param {number} [expiry] - Expiry time in seconds (optional) 145 | * @returns {Promise} - Promise resolving to true if successful 146 | */ 147 | async set(key, value, expiry = null) { 148 | try { 149 | const stringValue = JSON.stringify(value); 150 | if (expiry) { 151 | await this.client.set(key, stringValue, 'EX', expiry); 152 | } else { 153 | await this.client.set(key, stringValue); 154 | } 155 | return true; 156 | } catch (err) { 157 | logger.error(`Redis SET error for key ${key}: ${err.message}`); 158 | return false; 159 | } 160 | } 161 | 162 | /** 163 | * Delete a key from Redis 164 | * @param {string} key - Key to delete 165 | * @returns {Promise} - Promise resolving to true if successful 166 | */ 167 | async del(key) { 168 | try { 169 | await this.client.del(key); 170 | return true; 171 | } catch (err) { 172 | logger.error(`Redis DEL error for key ${key}: ${err.message}`); 173 | return false; 174 | } 175 | } 176 | 177 | /** 178 | * Check if a key exists in Redis 179 | * @param {string} key - Key to check 180 | * @returns {Promise} - Promise resolving to true if key exists 181 | */ 182 | async exists(key) { 183 | try { 184 | const result = await this.client.exists(key); 185 | return result === 1; 186 | } catch (err) { 187 | logger.error(`Redis EXISTS error for key ${key}: ${err.message}`); 188 | return false; 189 | } 190 | } 191 | 192 | /** 193 | * Set expiration time on a key 194 | * @param {string} key - Key to expire 195 | * @param {number} seconds - Seconds until expiration 196 | * @returns {Promise} - Promise resolving to true if successful 197 | */ 198 | async expire(key, seconds) { 199 | try { 200 | await this.client.expire(key, seconds); 201 | return true; 202 | } catch (err) { 203 | logger.error(`Redis EXPIRE error for key ${key}: ${err.message}`); 204 | return false; 205 | } 206 | } 207 | 208 | /** 209 | * Increment a key's value 210 | * @param {string} key - Key to increment 211 | * @returns {Promise} - Promise resolving to the new value 212 | */ 213 | async incr(key) { 214 | try { 215 | return await this.client.incr(key); 216 | } catch (err) { 217 | logger.error(`Redis INCR error for key ${key}: ${err.message}`); 218 | return null; 219 | } 220 | } 221 | 222 | /** 223 | * Set multiple hash fields to multiple values 224 | * @param {string} key - Hash key 225 | * @param {Object} fields - Object containing field-value pairs 226 | * @returns {Promise} - Promise resolving to true if successful 227 | */ 228 | async hmset(key, fields) { 229 | try { 230 | const args = [key]; 231 | for (const [field, value] of Object.entries(fields)) { 232 | args.push( 233 | field, 234 | typeof value === 'object' ? JSON.stringify(value) : value 235 | ); 236 | } 237 | await this.client.hmset(...args); 238 | return true; 239 | } catch (err) { 240 | logger.error(`Redis HMSET error for key ${key}: ${err.message}`); 241 | return false; 242 | } 243 | } 244 | 245 | /** 246 | * Get all fields and values in a hash 247 | * @param {string} key - Hash key 248 | * @returns {Promise} - Promise resolving to an object with all field-value pairs 249 | */ 250 | async hgetall(key) { 251 | try { 252 | return await this.client.hgetall(key); 253 | } catch (err) { 254 | logger.error(`Redis HGETALL error for key ${key}: ${err.message}`); 255 | return null; 256 | } 257 | } 258 | 259 | /** 260 | * Push a value to the end of a list 261 | * @param {string} key - List key 262 | * @param {any} value - Value to push 263 | * @returns {Promise} - Promise resolving to the new length of the list 264 | */ 265 | async rpush(key, value) { 266 | try { 267 | return await this.client.rpush( 268 | key, 269 | typeof value === 'object' ? JSON.stringify(value) : value 270 | ); 271 | } catch (err) { 272 | logger.error(`Redis RPUSH error for key ${key}: ${err.message}`); 273 | return null; 274 | } 275 | } 276 | 277 | /** 278 | * Get a range of elements from a list 279 | * @param {string} key - List key 280 | * @param {number} start - Start index 281 | * @param {number} stop - Stop index 282 | * @returns {Promise} - Promise resolving to array of elements 283 | */ 284 | async lrange(key, start, stop) { 285 | try { 286 | const result = await this.client.lrange(key, start, stop); 287 | return result.map((item) => { 288 | try { 289 | return JSON.parse(item); 290 | } catch { 291 | return item; 292 | } 293 | }); 294 | } catch (err) { 295 | logger.error(`Redis LRANGE error for key ${key}: ${err.message}`); 296 | return null; 297 | } 298 | } 299 | } 300 | 301 | // Export the Redis service singleton instance (not connected yet) 302 | const redisService = new RedisService(); 303 | 304 | export default redisService; 305 | -------------------------------------------------------------------------------- /src/services/cart.service.js: -------------------------------------------------------------------------------- 1 | import cartDAO from '../dao/cart.dao.js'; 2 | import productDAO from '../dao/product.dao.js'; 3 | import AppError from '../utils/appError.js'; 4 | import logger from '../loggers/winston.logger.js'; 5 | 6 | class CartService { 7 | /** 8 | * Add product to cart with validation and business logic 9 | * @param {string} userId - User ID 10 | * @param {Object} cartData - Cart item data 11 | * @returns {Promise} - Updated cart 12 | */ 13 | async addToCart(userId, cartData) { 14 | const { productId, quantity = 1, selectedSize, selectedColor } = cartData; 15 | 16 | try { 17 | // Validate product exists and is available 18 | const product = await productDAO.findProductById(productId); 19 | if (!product) { 20 | throw new AppError('Product not found', 404); 21 | } 22 | 23 | if (!product.in_stock) { 24 | throw new AppError('Product is currently out of stock', 400); 25 | } 26 | 27 | // Validate selected size if provided 28 | if ( 29 | selectedSize && 30 | product.all_available_sizes && 31 | product.all_available_sizes.length > 0 32 | ) { 33 | if (!product.all_available_sizes.includes(selectedSize)) { 34 | throw new AppError( 35 | `Size ${selectedSize} is not available for this product`, 36 | 400 37 | ); 38 | } 39 | } 40 | 41 | // Check if item already exists in cart with same attributes 42 | const existingCartWithItem = await cartDAO.findCartItemByProduct( 43 | userId, 44 | productId, 45 | selectedSize, 46 | selectedColor 47 | ); 48 | 49 | if (existingCartWithItem) { 50 | // Update quantity of existing item 51 | const existingItem = existingCartWithItem.items.find( 52 | (item) => 53 | item.product.toString() === productId && 54 | item.selectedSize === selectedSize && 55 | item.selectedColor === selectedColor 56 | ); 57 | 58 | const newQuantity = existingItem.quantity + quantity; 59 | if (newQuantity > 50) { 60 | throw new AppError( 61 | 'Cannot add more than 50 items of the same product', 62 | 400 63 | ); 64 | } 65 | 66 | const updatedCart = await cartDAO.incrementCartItemQuantity( 67 | userId, 68 | productId, 69 | selectedSize, 70 | selectedColor, 71 | quantity 72 | ); 73 | 74 | logger.info('Cart item quantity updated', { 75 | userId, 76 | productId, 77 | newQuantity, 78 | }); 79 | 80 | return this.sanitizeCartResponse(updatedCart); 81 | } 82 | 83 | // Add new item to cart 84 | const itemData = { 85 | product: productId, 86 | quantity, 87 | price: product.final_price, 88 | selectedSize, 89 | selectedColor, 90 | }; 91 | 92 | // Check if cart exists, if not create one 93 | let cart = await cartDAO.findCartByUserId(userId); 94 | if (!cart) { 95 | cart = await cartDAO.createCart(userId); 96 | } 97 | 98 | const updatedCart = await cartDAO.addItemToCart(userId, itemData); 99 | 100 | logger.info('Product added to cart', { 101 | userId, 102 | productId, 103 | quantity, 104 | }); 105 | 106 | return this.sanitizeCartResponse(updatedCart); 107 | } catch (error) { 108 | logger.error('Error adding product to cart:', error); 109 | throw error; 110 | } 111 | } 112 | 113 | /** 114 | * Get user's cart 115 | * @param {string} userId - User ID 116 | * @returns {Promise} - User's cart 117 | */ 118 | async getCart(userId) { 119 | try { 120 | let cart = await cartDAO.findCartByUserId(userId); 121 | 122 | if (!cart) { 123 | // Create empty cart if doesn't exist 124 | cart = await cartDAO.createCart(userId); 125 | } 126 | 127 | // Validate cart items and remove unavailable products 128 | const validatedCart = await this.validateCartItems(cart); 129 | 130 | return this.sanitizeCartResponse(validatedCart); 131 | } catch (error) { 132 | logger.error('Error fetching cart:', error); 133 | throw error; 134 | } 135 | } 136 | 137 | /** 138 | * Update cart item quantity 139 | * @param {string} userId - User ID 140 | * @param {string} itemId - Cart item ID 141 | * @param {number} quantity - New quantity 142 | * @returns {Promise} - Updated cart 143 | */ 144 | async updateCartItem(userId, itemId, quantity) { 145 | try { 146 | const cart = await cartDAO.findCartByUserId(userId); 147 | if (!cart) { 148 | throw new AppError('Cart not found', 404); 149 | } 150 | 151 | const item = cart.items.find((item) => item._id.toString() === itemId); 152 | if (!item) { 153 | throw new AppError('Cart item not found', 404); 154 | } 155 | 156 | // Validate product availability 157 | const product = await productDAO.findProductById( 158 | item.product._id || item.product 159 | ); 160 | if (!product || !product.in_stock) { 161 | throw new AppError('Product is no longer available', 400); 162 | } 163 | 164 | const updatedCart = await cartDAO.updateCartItemQuantity( 165 | userId, 166 | itemId, 167 | quantity 168 | ); 169 | if (!updatedCart) { 170 | throw new AppError('Failed to update cart item', 400); 171 | } 172 | 173 | logger.info('Cart item updated', { 174 | userId, 175 | itemId, 176 | newQuantity: quantity, 177 | }); 178 | 179 | return this.sanitizeCartResponse(updatedCart); 180 | } catch (error) { 181 | logger.error('Error updating cart item:', error); 182 | throw error; 183 | } 184 | } 185 | 186 | /** 187 | * Remove item from cart 188 | * @param {string} userId - User ID 189 | * @param {string} itemId - Cart item ID 190 | * @returns {Promise} - Updated cart 191 | */ 192 | async removeCartItem(userId, itemId) { 193 | try { 194 | const cart = await cartDAO.findCartByUserId(userId); 195 | if (!cart) { 196 | throw new AppError('Cart not found', 404); 197 | } 198 | 199 | const item = cart.items.find((item) => item._id.toString() === itemId); 200 | if (!item) { 201 | throw new AppError('Cart item not found', 404); 202 | } 203 | 204 | const updatedCart = await cartDAO.removeCartItem(userId, itemId); 205 | 206 | logger.info('Cart item removed', { 207 | userId, 208 | itemId, 209 | }); 210 | 211 | return this.sanitizeCartResponse(updatedCart); 212 | } catch (error) { 213 | logger.error('Error removing cart item:', error); 214 | throw error; 215 | } 216 | } 217 | 218 | /** 219 | * Clear entire cart 220 | * @param {string} userId - User ID 221 | * @returns {Promise} - Cleared cart 222 | */ 223 | async clearCart(userId) { 224 | try { 225 | const clearedCart = await cartDAO.clearCart(userId); 226 | 227 | logger.info('Cart cleared', { userId }); 228 | 229 | return this.sanitizeCartResponse(clearedCart); 230 | } catch (error) { 231 | logger.error('Error clearing cart:', error); 232 | throw error; 233 | } 234 | } 235 | 236 | /** 237 | * Validate cart items and remove unavailable products 238 | * @param {Object} cart - Cart document 239 | * @returns {Promise} - Validated cart 240 | */ 241 | async validateCartItems(cart) { 242 | if (!cart.items || cart.items.length === 0) { 243 | return cart; 244 | } 245 | 246 | const validItems = []; 247 | let hasRemovedItems = false; 248 | 249 | for (const item of cart.items) { 250 | try { 251 | const product = await productDAO.findProductById( 252 | item.product._id || item.product 253 | ); 254 | 255 | if (product && product.in_stock) { 256 | // Update price if it has changed 257 | if (item.price !== product.final_price) { 258 | item.price = product.final_price; 259 | } 260 | validItems.push(item); 261 | } else { 262 | hasRemovedItems = true; 263 | logger.warn('Removed unavailable product from cart', { 264 | userId: cart.user, 265 | productId: item.product._id || item.product, 266 | }); 267 | } 268 | } catch (error) { 269 | hasRemovedItems = true; 270 | logger.warn('Removed invalid product from cart', { 271 | userId: cart.user, 272 | productId: item.product._id || item.product, 273 | error: error.message, 274 | }); 275 | } 276 | } 277 | 278 | if (hasRemovedItems) { 279 | cart.items = validItems; 280 | await cart.save(); 281 | } 282 | 283 | return cart; 284 | } 285 | 286 | /** 287 | * Sanitize cart response for client 288 | * @param {Object} cart - Cart document 289 | * @returns {Object} - Sanitized cart data 290 | */ 291 | sanitizeCartResponse(cart) { 292 | if (!cart) { 293 | return { 294 | items: [], 295 | totalItems: 0, 296 | totalAmount: 0, 297 | currency: 'USD', 298 | }; 299 | } 300 | 301 | const cartObj = cart.toObject ? cart.toObject() : cart; 302 | 303 | // Add calculated fields for each item 304 | if (cartObj.items) { 305 | cartObj.items = cartObj.items.map((item) => ({ 306 | ...item, 307 | subtotal: parseFloat((item.price * item.quantity).toFixed(2)), 308 | })); 309 | } 310 | 311 | return { 312 | _id: cartObj._id, 313 | items: cartObj.items || [], 314 | totalItems: cartObj.totalItems || 0, 315 | totalAmount: parseFloat((cartObj.totalAmount || 0).toFixed(2)), 316 | currency: cartObj.currency || 'USD', 317 | updatedAt: cartObj.updatedAt, 318 | }; 319 | } 320 | } 321 | 322 | export default new CartService(); 323 | -------------------------------------------------------------------------------- /src/controllers/auth.controller.js: -------------------------------------------------------------------------------- 1 | import asyncHandler from '../utils/asyncHandler.js'; 2 | import config from '../config/config.js'; 3 | import userService from '../services/user.service.js'; 4 | import { sendVerificationEmail } from '../utils/sendEmail.js'; 5 | import redisService from '../config/redis.js'; 6 | import * as CONSTANTS from '../constants/constants.js'; 7 | import { timeStringToSeconds } from '../utils/timeUtils.js'; 8 | 9 | class AuthController { 10 | /** 11 | * Register a new user. 12 | * @param {Object} req - Express request object containing user data. 13 | * @param {Object} res - Express response object. 14 | */ 15 | register = asyncHandler(async (req, res) => { 16 | const user = await userService.registerUser(req.body); 17 | const accessToken = userService.generateAccessToken({ 18 | userId: user._id, 19 | username: user.username, 20 | email: user.email, 21 | }); 22 | 23 | const refreshToken = await userService.generateRefreshToken({ 24 | userId: user._id, 25 | }); 26 | 27 | // Store refresh token in Redis 28 | const refreshExpirySecs = timeStringToSeconds( 29 | CONSTANTS.REFRESH_TOKEN_EXPIRATION 30 | ); 31 | await redisService.set( 32 | `refreshToken:${user._id}`, 33 | refreshToken, 34 | refreshExpirySecs 35 | ); 36 | 37 | res.cookie('refreshToken', refreshToken, { 38 | httpOnly: true, 39 | secure: true, // Use secure cookies in production 40 | sameSite: 'none', 41 | maxAge: refreshExpirySecs * 1000, // Convert to milliseconds for cookie maxAge 42 | }); 43 | 44 | const accessExpirySecs = timeStringToSeconds( 45 | CONSTANTS.ACCESS_TOKEN_EXPIRATION 46 | ); 47 | res.cookie('accessToken', accessToken, { 48 | httpOnly: true, 49 | secure: true, // Use secure cookies in production 50 | sameSite: 'none', 51 | maxAge: accessExpirySecs * 1000, // Convert to milliseconds for cookie maxAge 52 | }); 53 | 54 | res 55 | .status(201) 56 | .json({ success: true, data: user, accessToken, refreshToken }); 57 | }); 58 | 59 | /** 60 | * Login a user. 61 | * @param {Object} req - Express request object containing email and password. 62 | * @param {Object} res - Express response object. 63 | */ 64 | login = asyncHandler(async (req, res) => { 65 | const { email, password } = req.body; 66 | const { user } = await userService.loginUser(email, password); 67 | 68 | const accessToken = userService.generateAccessToken({ 69 | userId: user._id, 70 | username: user.username, 71 | email: user.email, 72 | }); 73 | 74 | const refreshToken = await userService.generateRefreshToken({ 75 | userId: user._id, 76 | }); 77 | 78 | // Store refresh token in Redis 79 | const refreshExpirySecs = timeStringToSeconds( 80 | CONSTANTS.REFRESH_TOKEN_EXPIRATION 81 | ); 82 | await redisService.set( 83 | `refreshToken:${user._id}`, 84 | refreshToken, 85 | refreshExpirySecs 86 | ); 87 | res.cookie('refreshToken', refreshToken, { 88 | httpOnly: true, 89 | secure: true, // Use secure cookies in production 90 | sameSite: 'none', 91 | maxAge: refreshExpirySecs * 1000, // Convert to milliseconds for cookie maxAge 92 | }); 93 | 94 | const accessExpirySecs = timeStringToSeconds( 95 | CONSTANTS.ACCESS_TOKEN_EXPIRATION 96 | ); 97 | res.cookie('accessToken', accessToken, { 98 | httpOnly: true, 99 | secure: true, // Use secure cookies in production 100 | sameSite: 'none', 101 | maxAge: accessExpirySecs * 1000, // Convert to milliseconds for cookie maxAge 102 | }); 103 | 104 | res 105 | .status(200) 106 | .json({ success: true, data: user, accessToken, refreshToken }); 107 | }); 108 | 109 | /** 110 | * Get current logged-in user details. 111 | * @param {Object} req - Express request object with user ID in the token. 112 | * @param {Object} res - Express response object. 113 | */ 114 | getMe = asyncHandler(async (req, res) => { 115 | const user = await userService.getMe(req.user._id); 116 | if (!user) { 117 | return res 118 | .status(404) 119 | .json({ success: false, message: 'User not found' }); 120 | } 121 | res.status(200).json({ success: true, data: user }); 122 | }); 123 | 124 | /** 125 | * generate access token for user. 126 | * @param {Object} req - Express request object with user ID in the token. 127 | * @param {Object} res - Express response object. 128 | */ 129 | generateAccessToken = asyncHandler(async (req, res) => { 130 | const refreshToken = req.cookies.refreshToken; 131 | if (!refreshToken) { 132 | return res 133 | .status(401) 134 | .json({ success: false, message: 'No refresh token provided' }); 135 | } 136 | 137 | const user = await userService.verifyRefreshToken(refreshToken); 138 | 139 | const token = userService.generateAccessToken({ 140 | userId: user._id, 141 | username: user.username, 142 | email: user.email, 143 | }); 144 | 145 | res.cookie('accessToken', token, { 146 | httpOnly: true, 147 | secure: true, // Use secure cookies in production 148 | sameSite: 'none', 149 | maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days 150 | }); 151 | 152 | res.status(200).json({ success: true, accessToken: token }); 153 | }); 154 | 155 | /** 156 | * Handle the Google OAuth callback. 157 | * Creates and sets access and refresh tokens for the authenticated user. 158 | * @param {Object} req - Express request object with user from passport. 159 | * @param {Object} res - Express response object. 160 | */ 161 | googleCallback = asyncHandler(async (req, res) => { 162 | // The user information is available in req.user thanks to passport 163 | const user = req.user; 164 | 165 | if (!user) { 166 | return res.status(401).json({ 167 | success: false, 168 | message: 'Authentication failed', 169 | }); 170 | } 171 | 172 | // Generate tokens 173 | const accessToken = userService.generateAccessToken({ 174 | userId: user._id, 175 | username: user.username || user.name, // Google might provide name instead of username 176 | email: user.email, 177 | }); 178 | 179 | const refreshToken = await userService.generateRefreshToken({ 180 | userId: user._id, 181 | }); 182 | 183 | // Store refresh token in Redis 184 | const refreshExpirySecs = timeStringToSeconds( 185 | CONSTANTS.REFRESH_TOKEN_EXPIRATION 186 | ); 187 | await redisService.set( 188 | `refreshToken:${user._id}`, 189 | refreshToken, 190 | refreshExpirySecs 191 | ); 192 | 193 | // Set tokens in cookies 194 | res.cookie('refreshToken', refreshToken, { 195 | httpOnly: true, 196 | secure: config.NODE_ENV === 'production', // Secure in production 197 | sameSite: 'none', 198 | maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days 199 | }); 200 | 201 | res.cookie('accessToken', accessToken, { 202 | httpOnly: true, 203 | secure: config.NODE_ENV === 'production', // Secure in production 204 | sameSite: 'none', 205 | maxAge: 60 * 60 * 1000, // 1 hour 206 | }); 207 | 208 | res.status(200).json({ 209 | success: true, 210 | message: 'Authentication successful', 211 | user: { 212 | id: user._id, 213 | name: user.name || user.username, 214 | email: user.email, 215 | }, 216 | accessToken, 217 | refreshToken, 218 | }); 219 | }); 220 | 221 | /** 222 | * Logout user by clearing cookies 223 | * @param {Object} req - Express request object 224 | * @param {Object} res - Express response object 225 | */ 226 | logout = asyncHandler(async (req, res) => { 227 | // Clear cookies 228 | res.clearCookie('accessToken'); 229 | res.clearCookie('refreshToken'); 230 | 231 | // Optionally, you can also invalidate the refresh token in the database or cache 232 | const userId = req.user._id; 233 | await redisService.del(`refreshToken:${userId}`); 234 | 235 | res.status(200).json({ 236 | success: true, 237 | message: 'Logged out successfully', 238 | }); 239 | }); 240 | 241 | /** 242 | * Verify user's email. 243 | * @param {Object} req - Express request object containing email. 244 | * @param {Object} res - Express response object. 245 | */ 246 | verifyEmail = asyncHandler(async (req, res) => { 247 | const { email } = req.body; 248 | if (!email) { 249 | return res 250 | .status(400) 251 | .json({ success: false, message: 'Email is required' }); 252 | } 253 | 254 | // Generate verification token 255 | const verificationToken = await userService.generateVerificationToken({ 256 | email, 257 | }); 258 | 259 | // Send verification email 260 | await sendVerificationEmail( 261 | email, 262 | `${config.NODE_ENV === 'production' ? 'https://testdog.in' : 'http://localhost:3000'}/api/v1/auth/verify-email?token=${verificationToken}` 263 | ); 264 | 265 | res.status(200).json({ 266 | success: true, 267 | message: 'Verification email sent', 268 | }); 269 | }); 270 | 271 | /** 272 | * Verify user's email using the token. 273 | * @param {Object} req - Express request object containing token. 274 | * @param {Object} res - Express response object. 275 | */ 276 | verifyEmailToken = asyncHandler(async (req, res) => { 277 | const { token } = req.query; 278 | if (!token) { 279 | return res 280 | .status(400) 281 | .json({ success: false, message: 'Token is required' }); 282 | } 283 | 284 | const user = await userService.verifyEmail(token); 285 | if (!user) { 286 | return res 287 | .status(401) 288 | .json({ success: false, message: 'Invalid or expired token' }); 289 | } 290 | 291 | res 292 | .status(200) 293 | .json({ success: true, message: 'Email verified successfully', user }); 294 | }); 295 | } 296 | 297 | export default new AuthController(); 298 | -------------------------------------------------------------------------------- /src/services/user.service.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import gravatar from 'gravatar'; 3 | import userDAO from '../dao/user.dao.js'; 4 | import AppError from '../utils/appError.js'; // Optional: custom error handler 5 | import config from '../config/config.js'; 6 | import * as CONSTANT from '../constants/constants.js'; 7 | import redisService from '../config/redis.js'; 8 | import logger from '../loggers/winston.logger.js'; 9 | 10 | /** 11 | * Service class for handling user-related business logic. 12 | */ 13 | class UserService { 14 | /** 15 | * Register a new user after checking for existing email/username. 16 | * @param {Object} userData - { googleId?, username, email, password,name?, avatar? } 17 | * @returns {Promise} - Newly created user (without password). 18 | */ 19 | async registerUser(userData) { 20 | const existingEmail = await userDAO.findByEmail(userData.email); 21 | if (existingEmail) { 22 | throw new AppError('Email already registered.', 400); 23 | } 24 | 25 | const existingUsername = await userDAO.findByUsername(userData.username); 26 | if (existingUsername) { 27 | throw new AppError('Username already taken.', 400); 28 | } 29 | 30 | const newUser = await userDAO.createUser({ 31 | googleId: userData.googleId || null, // Optional for Google users 32 | username: userData.username, 33 | email: userData.email, 34 | password: userData.password, 35 | name: userData.name || '', 36 | role: userData.role || 'user', // Default role 37 | avatar: 38 | userData.avatar || 39 | gravatar.url(userData.email, { s: '100', r: 'x', d: 'retro' }, true), 40 | }); 41 | 42 | newUser.password = undefined; 43 | return newUser; 44 | } 45 | 46 | /** 47 | * Login user by checking email and comparing password. 48 | * @param {string} email 49 | * @param {string} password 50 | * @returns {Promise} - User and token if authenticated. 51 | */ 52 | async loginUser(email, password) { 53 | const user = await userDAO.findByEmail(email); 54 | if (!user || !(await user.comparePassword(password))) { 55 | throw new AppError('Invalid email or password.', 401); 56 | } 57 | 58 | user.password = undefined; 59 | 60 | return { user }; 61 | } 62 | 63 | /** 64 | * Return current logged-in user details. 65 | * @param {string} userId - MongoDB user ID from token 66 | * @returns {Promise} - User details or null 67 | */ 68 | async getMe(userId, selectFields = '') { 69 | return await userDAO.findById(userId, selectFields); 70 | } 71 | 72 | /** 73 | * Generate JWT token from user ID. 74 | * @param {string} userId 75 | * @returns {string} - JWT Token 76 | */ 77 | generateAccessToken({ userId = null, username = null, email = null }) { 78 | return jwt.sign({ id: userId, username, email }, config.JWT_SECRET, { 79 | expiresIn: CONSTANT.ACCESS_TOKEN_EXPIRATION, 80 | }); 81 | } 82 | 83 | /** 84 | * generate JWT refresh token. 85 | * @param {string} userId 86 | * @returns {string} - JWT Refresh Token 87 | */ 88 | async generateRefreshToken({ userId = null }) { 89 | const token = jwt.sign({ id: userId }, config.JWT_SECRET, { 90 | expiresIn: CONSTANT.REFRESH_TOKEN_EXPIRATION, 91 | }); 92 | 93 | return token; 94 | } 95 | 96 | /** 97 | * Check if a user exists with given email. 98 | * Useful for forgot-password flow. 99 | * @param {string} email 100 | * @returns {Promise} - Found user or null 101 | */ 102 | async checkUserByEmail(email) { 103 | return await userDAO.findByEmail(email); 104 | } 105 | 106 | /** 107 | * Reset user password (after validating token externally). 108 | * @param {string} userId 109 | * @param {string} newPassword 110 | * @returns {Promise} - Updated user object 111 | */ 112 | async resetPassword(userId, newPassword) { 113 | const user = await userDAO.findById(userId); 114 | if (!user) throw new AppError('User not found.', 404); 115 | 116 | user.password = newPassword; 117 | await user.save(); // triggers pre-save hashing 118 | return user; 119 | } 120 | 121 | /** 122 | * Update user details like username or email. 123 | * @param {string} userId - MongoDB user ID 124 | * @param {Object} updateData - Fields to update 125 | * @returns {Promise} - Updated user object 126 | */ 127 | async updateUser(userId, updateData) { 128 | const user = await userDAO.findById(userId); 129 | if (!user) throw new AppError('User not found.', 404); 130 | 131 | // Prevent updating password directly 132 | if (updateData.password) { 133 | throw new AppError('Password cannot be updated directly.', 400); 134 | } 135 | 136 | // Update only allowed fields 137 | Object.keys(updateData).forEach((key) => { 138 | if (['username', 'email', 'name'].includes(key)) { 139 | user[key] = updateData[key]; 140 | } 141 | }); 142 | 143 | await user.save(); 144 | user.password = undefined; // Exclude password from response 145 | return user; 146 | } 147 | 148 | /** 149 | * Verify Refresh Token and return user details. 150 | * @param {string} refreshToken - Refresh token 151 | * @returns {Promise} - User object 152 | */ 153 | async verifyRefreshToken(refreshToken) { 154 | try { 155 | const decoded = jwt.verify(refreshToken, config.JWT_SECRET); 156 | if (!decoded || !decoded.id) { 157 | throw new AppError('Invalid refresh token.', 401); 158 | } 159 | 160 | const isTokenExists = await redisService.get( 161 | `refreshToken:${decoded.id}` 162 | ); 163 | 164 | if (!isTokenExists) { 165 | throw new AppError('Refresh token not found.', 401); 166 | } 167 | 168 | // Check if user exists and token matches 169 | if (refreshToken !== isTokenExists) { 170 | throw new AppError('Invalid refresh token.', 401); 171 | } 172 | 173 | const user = await userDAO.findById(decoded.id); 174 | if (!user) { 175 | throw new AppError('Invalid refresh token.', 401); 176 | } 177 | return user; 178 | } catch (err) { 179 | // Log the original error details to aid debugging 180 | logger.warn('Refresh token verification failed', { 181 | error: err.message, 182 | stack: err.stack, 183 | tokenId: err.decoded?.id || 'unknown', 184 | }); 185 | 186 | // Throw a more detailed AppError that preserves error info 187 | throw new AppError('Invalid or expired refresh token.', 401); 188 | } 189 | } 190 | 191 | /** 192 | * Generate user verification token by ID. 193 | * @param {string} userId - MongoDB user ID 194 | * @returns {Promise} - Verification token 195 | */ 196 | async generateVerificationToken({ email }) { 197 | const user = await userDAO.findByEmail(email); 198 | if (!user) throw new AppError('User not found.', 404); 199 | 200 | const verificationToken = jwt.sign({ id: user._id }, config.JWT_SECRET, { 201 | expiresIn: CONSTANT.VERIFICATION_TOKEN_EXPIRATION, 202 | }); 203 | 204 | user.emailVerificationToken = verificationToken; 205 | await user.save(); 206 | 207 | return verificationToken; 208 | } 209 | 210 | /** 211 | * Verify user email using verification token. 212 | * @param {string} token - Verification token 213 | * @returns {Promise} - Verified user object 214 | */ 215 | async verifyEmail(token) { 216 | try { 217 | const decoded = jwt.verify(token, config.JWT_SECRET); 218 | 219 | if (!decoded || !decoded.id) { 220 | throw new AppError('Invalid verification token.', 401); 221 | } 222 | 223 | const user = await userDAO.findById( 224 | decoded.id, 225 | '+emailVerificationToken' 226 | ); 227 | if (!user || user.emailVerificationToken !== token) { 228 | throw new AppError('Invalid verification token.', 401); 229 | } 230 | 231 | user.isEmailVerified = true; 232 | user.emailVerificationToken = undefined; // Clear token after verification 233 | await user.save(); 234 | 235 | return user; 236 | } catch (err) { 237 | // Log the original error details to aid debugging 238 | logger.warn('Email verification failed', { 239 | error: err.message, 240 | stack: err.stack, 241 | userId: err.decoded?.id || 'unknown', 242 | tokenFragment: token ? `${token.substring(0, 8)}...` : 'undefined', 243 | }); 244 | 245 | // Throw a more descriptive AppError while preserving error context 246 | throw new AppError('Invalid or expired verification token.', 401); 247 | } 248 | } 249 | 250 | /** 251 | * Get a random user from the database. 252 | * @returns {Promise} - Random user or null if no users exist 253 | */ 254 | async getRandomUser() { 255 | const user = await userDAO.getRandomUser(); 256 | return user; 257 | } 258 | 259 | /** 260 | * Get all users with pagination. 261 | * @param {number} page 262 | * @param {number} limit 263 | * @returns {Promise} - Paginated users with total count 264 | */ 265 | 266 | async getAllUsersPaginated(page = 1, limit = 10) { 267 | const MAX_LIMIT = 50; // Increased from 5 to 50 for better usability 268 | let cappedMessage; 269 | 270 | page = parseInt(page); 271 | limit = parseInt(limit); 272 | 273 | if (limit > MAX_LIMIT) { 274 | cappedMessage = `Limit capped to ${MAX_LIMIT}. You requested ${limit}.`; 275 | limit = MAX_LIMIT; 276 | } 277 | 278 | const { data, total } = await userDAO.getAllUsersPaginated(page, limit); 279 | const totalPages = Math.ceil(total / limit); 280 | 281 | if (page > totalPages && total > 0) { 282 | throw new AppError( 283 | `Only ${totalPages} page(s) available. You requested page ${page}.`, 284 | 400 285 | ); 286 | } 287 | 288 | return { 289 | data, 290 | message: cappedMessage, 291 | pagination: { 292 | total, 293 | page, 294 | limit, 295 | totalPages, 296 | }, 297 | }; 298 | } 299 | } 300 | 301 | export default new UserService(); 302 | -------------------------------------------------------------------------------- /src/__tests__/product.test.js: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import app from '../app.js'; 3 | import User from '../models/user.model.js'; 4 | import Product from '../models/product.model.js'; 5 | 6 | describe('Product API Routes', () => { 7 | let adminToken; 8 | let userToken; 9 | 10 | beforeEach(async () => { 11 | // Create admin user 12 | await User.create({ 13 | username: 'admin', 14 | name: 'Admin User', 15 | email: 'admin@test.com', 16 | password: 'Password123!', 17 | role: 'admin', 18 | isEmailVerified: true, 19 | }); 20 | 21 | // Create regular user 22 | await User.create({ 23 | username: 'user', 24 | name: 'Regular User', 25 | email: 'user@test.com', 26 | password: 'Password123!', 27 | role: 'user', 28 | isEmailVerified: true, 29 | }); 30 | 31 | // Login admin to get token 32 | const adminResponse = await request(app).post('/api/v1/auth/login').send({ 33 | email: 'admin@test.com', 34 | password: 'Password123!', 35 | }); 36 | 37 | // Check if login was successful 38 | if (adminResponse.status !== 200 || !adminResponse.body.accessToken) { 39 | throw new Error(`Admin login failed. Status: ${adminResponse.status}`); 40 | } 41 | 42 | adminToken = adminResponse.body.accessToken; 43 | 44 | // Login user to get token 45 | const userResponse = await request(app).post('/api/v1/auth/login').send({ 46 | email: 'user@test.com', 47 | password: 'Password123!', 48 | }); 49 | 50 | if (userResponse.status !== 200 || !userResponse.body.accessToken) { 51 | throw new Error(`User login failed. Status: ${userResponse.status}`); 52 | } 53 | 54 | userToken = userResponse.body.accessToken; 55 | }); 56 | 57 | afterEach(async () => { 58 | // Clean up test data 59 | await User.deleteMany({}); 60 | await Product.deleteMany({}); 61 | }); 62 | 63 | describe('POST /api/v1/store/products', () => { 64 | const validProductData = { 65 | product_name: 'Test Product', 66 | description: 'This is a test product with a detailed description.', 67 | initial_price: 100.0, 68 | final_price: 80.0, 69 | currency: 'USD', 70 | main_image: 'https://example.com/test-image.jpg', 71 | category_tree: ['Electronics', 'Smartphones'], 72 | color: 'Black', 73 | size: 'M', 74 | }; 75 | 76 | it('should create a new product successfully with admin role', async () => { 77 | const response = await request(app) 78 | .post('/api/v1/store/products') 79 | .set('Authorization', `Bearer ${adminToken}`) 80 | .send(validProductData) 81 | .expect(201); 82 | 83 | expect(response.body.success).toBe(true); 84 | expect(response.body.message).toBe('Product created successfully'); 85 | expect(response.body.data.product).toHaveProperty('_id'); 86 | expect(response.body.data.product.product_name).toBe( 87 | validProductData.product_name 88 | ); 89 | expect(response.body.data.product.discount_amount).toBe(20.0); 90 | expect(response.body.data.product.discount_percentage).toBe(20.0); 91 | }); 92 | 93 | it('should return 403 for non-admin users', async () => { 94 | const response = await request(app) 95 | .post('/api/v1/store/products') 96 | .set('Authorization', `Bearer ${userToken}`) 97 | .send(validProductData) 98 | .expect(403); 99 | 100 | expect(response.body.success).toBe(false); 101 | expect(response.body.message).toBe( 102 | 'You do not have permission to perform this action' 103 | ); 104 | }); 105 | 106 | it('should return 401 for unauthenticated requests', async () => { 107 | const response = await request(app) 108 | .post('/api/v1/store/products') 109 | .send(validProductData) 110 | .expect(401); 111 | 112 | expect(response.body.success).toBe(false); 113 | }); 114 | 115 | it('should return 422 for invalid product data', async () => { 116 | const invalidData = { 117 | product_name: 'T', // Too short 118 | description: 'Short', // Too short 119 | initial_price: -10, // Negative price 120 | final_price: 200, // Greater than initial price 121 | currency: 'INVALID', // Invalid currency 122 | }; 123 | 124 | const response = await request(app) 125 | .post('/api/v1/store/products') 126 | .set('Authorization', `Bearer ${adminToken}`) 127 | .send(invalidData) 128 | .expect(422); 129 | 130 | expect(response.body.success).toBe(false); 131 | expect(response.body.message).toBe('Validation failed'); 132 | expect(response.body.errors).toHaveProperty('product_name'); 133 | expect(response.body.errors).toHaveProperty('description'); 134 | expect(response.body.errors).toHaveProperty('initial_price'); 135 | expect(response.body.errors).toHaveProperty('currency'); 136 | }); 137 | 138 | it('should return 400 for business logic violations', async () => { 139 | const invalidBusinessLogic = { 140 | product_name: 'Test Product 2', 141 | description: 'This is another test product with detailed description.', 142 | initial_price: 100.0, 143 | final_price: 150.0, // Greater than initial price 144 | currency: 'USD', 145 | main_image: 'https://example.com/test-image2.jpg', 146 | }; 147 | 148 | const response = await request(app) 149 | .post('/api/v1/store/products') 150 | .set('Authorization', `Bearer ${adminToken}`) 151 | .send(invalidBusinessLogic) 152 | .expect(422); // This will be caught by validator first 153 | 154 | expect(response.body.success).toBe(false); 155 | }); 156 | }); 157 | 158 | describe('GET /api/v1/store/products', () => { 159 | it('should get all products with pagination', async () => { 160 | const response = await request(app) 161 | .get('/api/v1/store/products?page=0&limit=10') 162 | .expect(200); 163 | 164 | expect(response.body.success).toBe(true); 165 | expect(response.body.message).toBe('Products retrieved successfully'); 166 | expect(response.body.data).toHaveProperty('products'); 167 | expect(response.body.data).toHaveProperty('pagination'); 168 | expect(Array.isArray(response.body.data.products)).toBe(true); 169 | }); 170 | 171 | it('should return 400 for invalid pagination parameters', async () => { 172 | const response = await request(app) 173 | .get('/api/v1/store/products?page=-1&limit=150') 174 | .expect(400); 175 | 176 | expect(response.body.success).toBe(false); 177 | }); 178 | }); 179 | 180 | describe('GET /api/v1/store/products/:id', () => { 181 | it('should get a product by ID', async () => { 182 | // First create a product to test with 183 | const createResponse = await request(app) 184 | .post('/api/v1/store/products') 185 | .set('Authorization', `Bearer ${adminToken}`) 186 | .send({ 187 | product_name: 'Get Test Product', 188 | description: 'This product is for testing GET by ID.', 189 | initial_price: 50.0, 190 | final_price: 40.0, 191 | currency: 'USD', 192 | main_image: 'https://example.com/get-test-image.jpg', 193 | }); 194 | 195 | const productId = createResponse.body.data.product._id; 196 | 197 | const response = await request(app) 198 | .get(`/api/v1/store/products/${productId}`) 199 | .expect(200); 200 | 201 | expect(response.body.success).toBe(true); 202 | expect(response.body.message).toBe('Product retrieved successfully'); 203 | expect(response.body.data.product._id).toBe(productId); 204 | }); 205 | 206 | it('should return 404 for non-existent product', async () => { 207 | const nonExistentId = '60d5ecb54b24a12f8c8d4321'; // Valid ObjectId format 208 | const response = await request(app) 209 | .get(`/api/v1/store/products/${nonExistentId}`) 210 | .expect(404); 211 | 212 | expect(response.body.success).toBe(false); 213 | }); 214 | }); 215 | 216 | describe('GET /api/v1/store/products/search', () => { 217 | it('should search products by keyword', async () => { 218 | const response = await request(app) 219 | .get('/api/v1/store/products/search?q=test&limit=10') 220 | .expect(200); 221 | 222 | expect(response.body.success).toBe(true); 223 | expect(response.body.message).toBe('Search completed successfully'); 224 | expect(response.body.data).toHaveProperty('keyword'); 225 | expect(response.body.data).toHaveProperty('products'); 226 | expect(Array.isArray(response.body.data.products)).toBe(true); 227 | }); 228 | 229 | it('should return 400 for missing search keyword', async () => { 230 | const response = await request(app) 231 | .get('/api/v1/store/products/search') 232 | .expect(400); 233 | 234 | expect(response.body.success).toBe(false); 235 | expect(response.body.message).toBe('Search keyword is required'); 236 | }); 237 | }); 238 | 239 | describe('GET /api/v1/store/products/autocomplete', () => { 240 | it('should get autocomplete suggestions', async () => { 241 | const response = await request(app) 242 | .get('/api/v1/store/products/autocomplete?q=te&limit=5') 243 | .expect(200); 244 | 245 | expect(response.body.success).toBe(true); 246 | expect(response.body.message).toBe( 247 | 'Autocomplete suggestions retrieved successfully' 248 | ); 249 | expect(response.body.data).toHaveProperty('keyword'); 250 | expect(response.body.data).toHaveProperty('suggestions'); 251 | expect(Array.isArray(response.body.data.suggestions)).toBe(true); 252 | }); 253 | 254 | it('should return 400 for short search keyword', async () => { 255 | const response = await request(app) 256 | .get('/api/v1/store/products/autocomplete?q=t') 257 | .expect(400); 258 | 259 | expect(response.body.success).toBe(false); 260 | expect(response.body.message).toBe( 261 | 'Search keyword must be at least 2 characters long' 262 | ); 263 | }); 264 | }); 265 | 266 | describe('GET /api/v1/store/products/random', () => { 267 | it('should get a random product', async () => { 268 | // First create a product to ensure there's at least one in the database 269 | await request(app) 270 | .post('/api/v1/store/products') 271 | .set('Authorization', `Bearer ${adminToken}`) 272 | .send({ 273 | product_name: 'Random Test Product', 274 | description: 'This product is for testing random endpoint.', 275 | initial_price: 25.0, 276 | final_price: 20.0, 277 | currency: 'USD', 278 | main_image: 'https://example.com/random-test-image.jpg', 279 | }); 280 | 281 | const response = await request(app) 282 | .get('/api/v1/store/products/random') 283 | .expect(200); 284 | 285 | expect(response.body.success).toBe(true); 286 | expect(response.body.message).toBe( 287 | 'Random product retrieved successfully' 288 | ); 289 | expect(response.body.data).toHaveProperty('product'); 290 | }); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /src/dao/payment.dao.js: -------------------------------------------------------------------------------- 1 | import Payment from '../models/payment.model.js'; 2 | import AppError from '../utils/appError.js'; 3 | 4 | class PaymentDAO { 5 | // Create a new payment 6 | async create(paymentData) { 7 | try { 8 | const payment = new Payment(paymentData); 9 | return await payment.save(); 10 | } catch (error) { 11 | throw new AppError(`Failed to create payment: ${error.message}`, 500); 12 | } 13 | } 14 | 15 | // Find payment by ID 16 | async findById(paymentId, populate = false) { 17 | try { 18 | let query = Payment.findById(paymentId); 19 | 20 | if (populate) { 21 | query = query 22 | .populate('user', 'name email') 23 | .populate('cartItems.product', 'product_name main_image final_price') 24 | .populate( 25 | 'singleProduct.product', 26 | 'product_name main_image final_price' 27 | ); 28 | } 29 | 30 | return await query; 31 | } catch (error) { 32 | // Handle MongoDB CastError (invalid ObjectId format) 33 | if (error.name === 'CastError' || error.name === 'BSONError') { 34 | throw new AppError('Invalid payment ID format', 400); 35 | } 36 | throw new AppError(`Failed to find payment: ${error.message}`, 500); 37 | } 38 | } 39 | 40 | // Find payment by order ID 41 | async findByOrderId(orderId) { 42 | try { 43 | return await Payment.findOne({ orderId }); 44 | } catch (error) { 45 | throw new AppError( 46 | `Failed to find payment by order ID: ${error.message}`, 47 | 500 48 | ); 49 | } 50 | } 51 | 52 | // Find payment by Razorpay order ID 53 | async findByRazorpayOrderId(razorpayOrderId) { 54 | try { 55 | return await Payment.findOne({ razorpayOrderId }); 56 | } catch (error) { 57 | throw new AppError( 58 | `Failed to find payment by Razorpay order ID: ${error.message}`, 59 | 500 60 | ); 61 | } 62 | } 63 | 64 | // Find payment by Razorpay payment ID 65 | async findByRazorpayPaymentId(razorpayPaymentId) { 66 | try { 67 | return await Payment.findOne({ razorpayPaymentId }); 68 | } catch (error) { 69 | throw new AppError( 70 | `Failed to find payment by Razorpay payment ID: ${error.message}`, 71 | 500 72 | ); 73 | } 74 | } 75 | 76 | // Get user payments with pagination 77 | async getUserPayments(userId, options = {}) { 78 | try { 79 | const { 80 | status, 81 | orderType, 82 | page = 1, 83 | limit = 10, 84 | sort = { createdAt: -1 }, 85 | } = options; 86 | 87 | const skip = (page - 1) * limit; 88 | const query = { user: userId }; 89 | 90 | if (status) query.status = status; 91 | if (orderType) query.orderType = orderType; 92 | 93 | const payments = await Payment.find(query) 94 | .populate('cartItems.product', 'product_name main_image final_price') 95 | .populate( 96 | 'singleProduct.product', 97 | 'product_name main_image final_price' 98 | ) 99 | .sort(sort) 100 | .skip(skip) 101 | .limit(limit); 102 | 103 | const totalCount = await Payment.countDocuments(query); 104 | 105 | return { 106 | payments, 107 | pagination: { 108 | currentPage: page, 109 | totalPages: Math.ceil(totalCount / limit), 110 | totalCount, 111 | hasNextPage: page < Math.ceil(totalCount / limit), 112 | hasPrevPage: page > 1, 113 | }, 114 | }; 115 | } catch (error) { 116 | throw new AppError(`Failed to get user payments: ${error.message}`, 500); 117 | } 118 | } 119 | 120 | // Update payment status 121 | async updateStatus(paymentId, status, additionalData = {}) { 122 | try { 123 | const updateData = { status, ...additionalData }; 124 | 125 | if (status === 'paid') { 126 | updateData.paidAt = new Date(); 127 | } else if (status === 'failed') { 128 | updateData.failedAt = new Date(); 129 | } 130 | 131 | return await Payment.findByIdAndUpdate(paymentId, updateData, { 132 | new: true, 133 | runValidators: true, 134 | }); 135 | } catch (error) { 136 | throw new AppError( 137 | `Failed to update payment status: ${error.message}`, 138 | 500 139 | ); 140 | } 141 | } 142 | 143 | // Update payment with Razorpay payment details 144 | async updateWithPaymentDetails( 145 | paymentId, 146 | razorpayPaymentId, 147 | razorpaySignature, 148 | paymentMethod 149 | ) { 150 | try { 151 | return await Payment.findByIdAndUpdate( 152 | paymentId, 153 | { 154 | razorpayPaymentId, 155 | razorpaySignature, 156 | paymentMethod, 157 | status: 'paid', 158 | paidAt: new Date(), 159 | }, 160 | { new: true, runValidators: true } 161 | ); 162 | } catch (error) { 163 | throw new AppError( 164 | `Failed to update payment details: ${error.message}`, 165 | 500 166 | ); 167 | } 168 | } 169 | 170 | // Add refund information 171 | async addRefundInfo(paymentId, refundData) { 172 | try { 173 | return await Payment.findByIdAndUpdate( 174 | paymentId, 175 | { 176 | status: 'refunded', 177 | refundInfo: { 178 | refundId: refundData.refundId, 179 | refundAmount: refundData.amount, 180 | refundReason: refundData.reason, 181 | refundedAt: new Date(), 182 | }, 183 | }, 184 | { new: true, runValidators: true } 185 | ); 186 | } catch (error) { 187 | throw new AppError(`Failed to add refund info: ${error.message}`, 500); 188 | } 189 | } 190 | 191 | // Get payments by status 192 | async getPaymentsByStatus(status, options = {}) { 193 | try { 194 | const { page = 1, limit = 10, sort = { createdAt: -1 } } = options; 195 | const skip = (page - 1) * limit; 196 | 197 | const payments = await Payment.find({ status }) 198 | .populate('user', 'name email') 199 | .populate('cartItems.product', 'product_name main_image final_price') 200 | .populate( 201 | 'singleProduct.product', 202 | 'product_name main_image final_price' 203 | ) 204 | .sort(sort) 205 | .skip(skip) 206 | .limit(limit); 207 | 208 | const totalCount = await Payment.countDocuments({ status }); 209 | 210 | return { 211 | payments, 212 | pagination: { 213 | currentPage: page, 214 | totalPages: Math.ceil(totalCount / limit), 215 | totalCount, 216 | hasNextPage: page < Math.ceil(totalCount / limit), 217 | hasPrevPage: page > 1, 218 | }, 219 | }; 220 | } catch (error) { 221 | throw new AppError( 222 | `Failed to get payments by status: ${error.message}`, 223 | 500 224 | ); 225 | } 226 | } 227 | 228 | // Get payment statistics 229 | async getPaymentStats(dateFilter = {}) { 230 | try { 231 | const matchStage = {}; 232 | if (Object.keys(dateFilter).length > 0) { 233 | matchStage.createdAt = dateFilter; 234 | } 235 | 236 | const stats = await Payment.aggregate([ 237 | { $match: matchStage }, 238 | { 239 | $group: { 240 | _id: null, 241 | totalPayments: { $sum: 1 }, 242 | totalAmount: { $sum: '$amount' }, 243 | successfulPayments: { 244 | $sum: { $cond: [{ $eq: ['$status', 'paid'] }, 1, 0] }, 245 | }, 246 | failedPayments: { 247 | $sum: { $cond: [{ $eq: ['$status', 'failed'] }, 1, 0] }, 248 | }, 249 | refundedPayments: { 250 | $sum: { $cond: [{ $eq: ['$status', 'refunded'] }, 1, 0] }, 251 | }, 252 | pendingPayments: { 253 | $sum: { 254 | $cond: [{ $in: ['$status', ['created', 'attempted']] }, 1, 0], 255 | }, 256 | }, 257 | averageAmount: { $avg: '$amount' }, 258 | }, 259 | }, 260 | { 261 | $addFields: { 262 | successRate: { 263 | $cond: [ 264 | { $eq: ['$totalPayments', 0] }, 265 | 0, 266 | { 267 | $multiply: [ 268 | { $divide: ['$successfulPayments', '$totalPayments'] }, 269 | 100, 270 | ], 271 | }, 272 | ], 273 | }, 274 | totalAmountFormatted: { $divide: ['$totalAmount', 100] }, 275 | averageAmountFormatted: { $divide: ['$averageAmount', 100] }, 276 | }, 277 | }, 278 | ]); 279 | 280 | return ( 281 | stats[0] || { 282 | totalPayments: 0, 283 | totalAmount: 0, 284 | successfulPayments: 0, 285 | failedPayments: 0, 286 | refundedPayments: 0, 287 | pendingPayments: 0, 288 | averageAmount: 0, 289 | successRate: 0, 290 | totalAmountFormatted: 0, 291 | averageAmountFormatted: 0, 292 | } 293 | ); 294 | } catch (error) { 295 | throw new AppError( 296 | `Failed to get payment statistics: ${error.message}`, 297 | 500 298 | ); 299 | } 300 | } 301 | 302 | // Get payment method breakdown 303 | async getPaymentMethodStats(dateFilter = {}) { 304 | try { 305 | const matchStage = { status: 'paid' }; 306 | if (Object.keys(dateFilter).length > 0) { 307 | matchStage.createdAt = dateFilter; 308 | } 309 | 310 | return await Payment.aggregate([ 311 | { $match: matchStage }, 312 | { 313 | $group: { 314 | _id: '$paymentMethod', 315 | count: { $sum: 1 }, 316 | amount: { $sum: '$amount' }, 317 | }, 318 | }, 319 | { 320 | $addFields: { 321 | amountFormatted: { $divide: ['$amount', 100] }, 322 | }, 323 | }, 324 | { $sort: { count: -1 } }, 325 | ]); 326 | } catch (error) { 327 | throw new AppError( 328 | `Failed to get payment method stats: ${error.message}`, 329 | 500 330 | ); 331 | } 332 | } 333 | 334 | // Get order type breakdown 335 | async getOrderTypeStats(dateFilter = {}) { 336 | try { 337 | const matchStage = {}; 338 | if (Object.keys(dateFilter).length > 0) { 339 | matchStage.createdAt = dateFilter; 340 | } 341 | 342 | return await Payment.aggregate([ 343 | { $match: matchStage }, 344 | { 345 | $group: { 346 | _id: '$orderType', 347 | count: { $sum: 1 }, 348 | amount: { $sum: '$amount' }, 349 | }, 350 | }, 351 | { 352 | $addFields: { 353 | amountFormatted: { $divide: ['$amount', 100] }, 354 | }, 355 | }, 356 | { $sort: { count: -1 } }, 357 | ]); 358 | } catch (error) { 359 | throw new AppError( 360 | `Failed to get order type stats: ${error.message}`, 361 | 500 362 | ); 363 | } 364 | } 365 | 366 | // Delete payment (soft delete by marking as cancelled) 367 | async delete(paymentId) { 368 | try { 369 | return await Payment.findByIdAndUpdate( 370 | paymentId, 371 | { status: 'cancelled' }, 372 | { new: true } 373 | ); 374 | } catch (error) { 375 | throw new AppError(`Failed to delete payment: ${error.message}`, 500); 376 | } 377 | } 378 | 379 | // Hard delete payment (use with caution) 380 | async hardDelete(paymentId) { 381 | try { 382 | return await Payment.findByIdAndDelete(paymentId); 383 | } catch (error) { 384 | throw new AppError( 385 | `Failed to hard delete payment: ${error.message}`, 386 | 500 387 | ); 388 | } 389 | } 390 | } 391 | 392 | export default new PaymentDAO(); 393 | --------------------------------------------------------------------------------