From 05dd2b35fba6e8e792cb7c4951a9b623bf2ab849 Mon Sep 17 00:00:00 2001 From: elijah Date: Thu, 6 Jun 2024 00:32:23 +0200 Subject: [PATCH] Test coverage increse --- .env.example | 37 + .eslintrc.js | 39 + .github/workflows/ci.yml | 50 + .gitignore | 8 + .prettierrc | 21 + README.md | 84 + jest.config.ts | 19 + migrations/1714595134552-UserMigration.ts | 29 + ormconfig.js | 39 + package.json | 100 + src/@types/index.d.ts | 0 src/__test__/cart.test.ts | 616 ++++++ src/__test__/coupon.test.ts | 423 ++++ src/__test__/errorHandler.test.ts | 47 + src/__test__/getProduct.test.ts | 124 ++ src/__test__/isAllowed.test.ts | 92 + src/__test__/logout.test.ts | 76 + src/__test__/oauth.test.ts | 28 + src/__test__/productStatus.test.ts | 243 +++ src/__test__/roleCheck.test.ts | 91 + src/__test__/route.test.ts | 231 +++ src/__test__/test-assets/DatabaseCleanup.ts | 48 + src/__test__/test-assets/photo1.png | Bin 0 -> 42194 bytes src/__test__/test-assets/photo2.webp | Bin 0 -> 27306 bytes src/__test__/userServices.test.ts | 230 +++ src/__test__/userStatus.test.ts | 151 ++ src/__test__/vendorProduct.test.ts | 473 +++++ src/__test__/wishList.test.ts | 204 ++ src/configs/index.ts | 1 + src/configs/swagger.ts | 30 + src/controllers/authController.ts | 69 + src/controllers/cartController.ts | 18 + src/controllers/couponController.ts | 31 + src/controllers/index.ts | 3 + src/controllers/manageStatusController.ts | 0 src/controllers/orderController.ts | 18 + src/controllers/productController.ts | 81 + src/controllers/wishListController.ts | 23 + src/docs/authDocs.yml | 163 ++ src/docs/cartDocs.yml | 103 + src/docs/couponDocs.yml | 217 +++ src/docs/orderDocs.yml | 108 + src/docs/swaggerDark.css | 1729 +++++++++++++++++ src/docs/vendorProduct.yml | 235 +++ src/docs/wishListDocs.yml | 97 + src/entities/Cart.ts | 50 + src/entities/CartItem.ts | 53 + src/entities/Category.ts | 20 + src/entities/Order.ts | 52 + src/entities/OrderItem.ts | 29 + src/entities/Product.ts | 85 + src/entities/User.ts | 118 ++ src/entities/coupon.ts | 68 + src/entities/transaction.ts | 61 + src/entities/wishList.ts | 26 + src/helper/cartItemValidator.ts | 19 + src/helper/couponValidator.ts | 58 + src/helper/emailTemplates.ts | 16 + src/helper/productValidator.ts | 30 + src/helper/verify.ts | 19 + src/index.ts | 45 + src/lib/types.ts | 16 + src/middlewares/errorHandler.ts | 24 + src/middlewares/index.ts | 3 + src/middlewares/isAllowed.ts | 54 + src/middlewares/isValid.ts | 33 + src/middlewares/multer.ts | 14 + src/middlewares/optionalAuthorization.ts | 51 + src/middlewares/roleCheck.ts | 41 + src/middlewares/verifyToken.ts | 50 + src/routes/CartRoutes.ts | 12 + src/routes/ProductRoutes.ts | 40 + src/routes/UserRoutes.ts | 72 + src/routes/couponRoutes.ts | 15 + src/routes/index.ts | 21 + src/routes/wishListRoute.ts | 14 + src/services/cartServices/clearCart.ts | 60 + src/services/cartServices/createCart.ts | 151 ++ src/services/cartServices/readCart.ts | 58 + .../cartServices/removeProductInCart.ts | 109 ++ .../couponServices/accessAllCoupon.ts | 37 + .../couponServices/buyerApplyCoupon.ts | 85 + .../couponServices/createCouponService.ts | 55 + src/services/couponServices/deleteCoupon.ts | 23 + src/services/couponServices/readCoupon.ts | 23 + src/services/couponServices/updateService.ts | 59 + src/services/index.ts | 35 + src/services/orderServices/createOrder.ts | 131 ++ src/services/orderServices/getOrderService.ts | 65 + .../getOrderTransactionHistory.ts | 44 + .../orderServices/updateOrderService.ts | 129 ++ src/services/productServices/createProduct.ts | 104 + src/services/productServices/deleteProduct.ts | 32 + .../getRecommendedProductsService.ts | 62 + .../productServices/listAllProductsService.ts | 42 + src/services/productServices/productStatus.ts | 69 + src/services/productServices/readProduct.ts | 70 + .../productServices/removeProductImage.ts | 54 + src/services/productServices/searchProduct.ts | 45 + src/services/productServices/updateProduct.ts | 117 ++ .../productServices/viewSingleProduct.ts | 38 + .../updateUserStatus/activateUserService.ts | 39 + .../updateUserStatus/deactivateUserService.ts | 39 + src/services/userServices/logoutServices.ts | 18 + .../sendResetPasswordLinkService.ts | 117 ++ .../userServices/userDisableTwoFactorAuth.ts | 29 + .../userServices/userEnableTwoFactorAuth.ts | 29 + src/services/userServices/userIsOTPValid.ts | 26 + src/services/userServices/userLoginService.ts | 77 + .../userServices/userPasswordResetService.ts | 38 + .../userServices/userProfileUpdateServices.ts | 57 + .../userServices/userRegistrationService.ts | 69 + src/services/userServices/userResendOTP.ts | 42 + src/services/userServices/userSendOTPEmail.ts | 27 + .../userServices/userSendOTPMessage.ts | 26 + .../userStartTwoFactorAuthProcess.ts | 19 + src/services/userServices/userValidateOTP.ts | 47 + .../userServices/userValidationService.ts | 25 + src/services/wishListServices/addProduct.ts | 60 + src/services/wishListServices/clearAll.ts | 20 + src/services/wishListServices/getProducts.ts | 38 + .../wishListServices/removeProducts.ts | 23 + src/startups/dbConnection.ts | 12 + src/startups/docs.ts | 16 + src/startups/getSwaggerServer.ts | 13 + src/utils/auth.ts | 72 + src/utils/cloudinary.ts | 13 + src/utils/index.ts | 20 + src/utils/logger.ts | 59 + src/utils/response.utils.ts | 55 + src/utils/roles.ts | 5 + src/utils/sendMail.ts | 90 + src/utils/sendOrderMail.ts | 214 ++ src/utils/sendOrderMailUpdated.ts | 214 ++ src/utils/sendStatusMail.ts | 94 + tsconfig.json | 108 + 136 files changed, 11185 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc.js create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 jest.config.ts create mode 100644 migrations/1714595134552-UserMigration.ts create mode 100644 ormconfig.js create mode 100644 package.json create mode 100644 src/@types/index.d.ts create mode 100644 src/__test__/cart.test.ts create mode 100644 src/__test__/coupon.test.ts create mode 100644 src/__test__/errorHandler.test.ts create mode 100644 src/__test__/getProduct.test.ts create mode 100644 src/__test__/isAllowed.test.ts create mode 100644 src/__test__/logout.test.ts create mode 100644 src/__test__/oauth.test.ts create mode 100644 src/__test__/productStatus.test.ts create mode 100644 src/__test__/roleCheck.test.ts create mode 100644 src/__test__/route.test.ts create mode 100644 src/__test__/test-assets/DatabaseCleanup.ts create mode 100644 src/__test__/test-assets/photo1.png create mode 100644 src/__test__/test-assets/photo2.webp create mode 100644 src/__test__/userServices.test.ts create mode 100644 src/__test__/userStatus.test.ts create mode 100644 src/__test__/vendorProduct.test.ts create mode 100644 src/__test__/wishList.test.ts create mode 100644 src/configs/index.ts create mode 100644 src/configs/swagger.ts create mode 100644 src/controllers/authController.ts create mode 100644 src/controllers/cartController.ts create mode 100644 src/controllers/couponController.ts create mode 100644 src/controllers/index.ts create mode 100644 src/controllers/manageStatusController.ts create mode 100644 src/controllers/orderController.ts create mode 100644 src/controllers/productController.ts create mode 100644 src/controllers/wishListController.ts create mode 100644 src/docs/authDocs.yml create mode 100644 src/docs/cartDocs.yml create mode 100644 src/docs/couponDocs.yml create mode 100644 src/docs/orderDocs.yml create mode 100644 src/docs/swaggerDark.css create mode 100644 src/docs/vendorProduct.yml create mode 100644 src/docs/wishListDocs.yml create mode 100644 src/entities/Cart.ts create mode 100644 src/entities/CartItem.ts create mode 100644 src/entities/Category.ts create mode 100644 src/entities/Order.ts create mode 100644 src/entities/OrderItem.ts create mode 100644 src/entities/Product.ts create mode 100644 src/entities/User.ts create mode 100644 src/entities/coupon.ts create mode 100644 src/entities/transaction.ts create mode 100644 src/entities/wishList.ts create mode 100644 src/helper/cartItemValidator.ts create mode 100644 src/helper/couponValidator.ts create mode 100644 src/helper/emailTemplates.ts create mode 100644 src/helper/productValidator.ts create mode 100644 src/helper/verify.ts create mode 100644 src/index.ts create mode 100644 src/lib/types.ts create mode 100644 src/middlewares/errorHandler.ts create mode 100644 src/middlewares/index.ts create mode 100644 src/middlewares/isAllowed.ts create mode 100644 src/middlewares/isValid.ts create mode 100644 src/middlewares/multer.ts create mode 100644 src/middlewares/optionalAuthorization.ts create mode 100644 src/middlewares/roleCheck.ts create mode 100644 src/middlewares/verifyToken.ts create mode 100644 src/routes/CartRoutes.ts create mode 100644 src/routes/ProductRoutes.ts create mode 100644 src/routes/UserRoutes.ts create mode 100644 src/routes/couponRoutes.ts create mode 100644 src/routes/index.ts create mode 100644 src/routes/wishListRoute.ts create mode 100644 src/services/cartServices/clearCart.ts create mode 100644 src/services/cartServices/createCart.ts create mode 100644 src/services/cartServices/readCart.ts create mode 100644 src/services/cartServices/removeProductInCart.ts create mode 100644 src/services/couponServices/accessAllCoupon.ts create mode 100644 src/services/couponServices/buyerApplyCoupon.ts create mode 100644 src/services/couponServices/createCouponService.ts create mode 100644 src/services/couponServices/deleteCoupon.ts create mode 100644 src/services/couponServices/readCoupon.ts create mode 100644 src/services/couponServices/updateService.ts create mode 100644 src/services/index.ts create mode 100644 src/services/orderServices/createOrder.ts create mode 100644 src/services/orderServices/getOrderService.ts create mode 100644 src/services/orderServices/getOrderTransactionHistory.ts create mode 100644 src/services/orderServices/updateOrderService.ts create mode 100644 src/services/productServices/createProduct.ts create mode 100644 src/services/productServices/deleteProduct.ts create mode 100644 src/services/productServices/getRecommendedProductsService.ts create mode 100644 src/services/productServices/listAllProductsService.ts create mode 100644 src/services/productServices/productStatus.ts create mode 100644 src/services/productServices/readProduct.ts create mode 100644 src/services/productServices/removeProductImage.ts create mode 100644 src/services/productServices/searchProduct.ts create mode 100644 src/services/productServices/updateProduct.ts create mode 100644 src/services/productServices/viewSingleProduct.ts create mode 100644 src/services/updateUserStatus/activateUserService.ts create mode 100644 src/services/updateUserStatus/deactivateUserService.ts create mode 100644 src/services/userServices/logoutServices.ts create mode 100644 src/services/userServices/sendResetPasswordLinkService.ts create mode 100644 src/services/userServices/userDisableTwoFactorAuth.ts create mode 100644 src/services/userServices/userEnableTwoFactorAuth.ts create mode 100644 src/services/userServices/userIsOTPValid.ts create mode 100644 src/services/userServices/userLoginService.ts create mode 100644 src/services/userServices/userPasswordResetService.ts create mode 100644 src/services/userServices/userProfileUpdateServices.ts create mode 100644 src/services/userServices/userRegistrationService.ts create mode 100644 src/services/userServices/userResendOTP.ts create mode 100644 src/services/userServices/userSendOTPEmail.ts create mode 100644 src/services/userServices/userSendOTPMessage.ts create mode 100644 src/services/userServices/userStartTwoFactorAuthProcess.ts create mode 100644 src/services/userServices/userValidateOTP.ts create mode 100644 src/services/userServices/userValidationService.ts create mode 100644 src/services/wishListServices/addProduct.ts create mode 100644 src/services/wishListServices/clearAll.ts create mode 100644 src/services/wishListServices/getProducts.ts create mode 100644 src/services/wishListServices/removeProducts.ts create mode 100644 src/startups/dbConnection.ts create mode 100644 src/startups/docs.ts create mode 100644 src/startups/getSwaggerServer.ts create mode 100644 src/utils/auth.ts create mode 100644 src/utils/cloudinary.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/logger.ts create mode 100644 src/utils/response.utils.ts create mode 100644 src/utils/roles.ts create mode 100644 src/utils/sendMail.ts create mode 100644 src/utils/sendOrderMail.ts create mode 100644 src/utils/sendOrderMailUpdated.ts create mode 100644 src/utils/sendStatusMail.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5e17a15 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +PORT = ******************************** +APP_ENV = ******************************** + +TEST_DB_HOST = ******************************** +TEST_DB_PORT = ******************************** +TEST_DB_USER = ******************************** +TEST_DB_PASS = ******************************** +TEST_DB_NAME = ******************************** + +DEV_DB_HOST = ******************************** +DEV_DB_PORT = ******************************** +DEV_DB_USER = ******************************** +DEV_DB_PASS = ***************************** +DEV_DB_NAME = ******************************* + +PDN_DB_HOST = ******************************** +PDN_DB_PORT = ******************************** +PDN_DB_USER = ******************************** +PDN_DB_PASS = ******************************** +PDN_DB_NAME = ***************************** + + +APP_EMAIL = ******************************** +APP_PASSWORD = ******************************** +PINDO_API_KEY = ******************************** +PINDO_API_URL = ******************************** +PINDO_SENDER = ******************************** +JWT_SECRET = ******************************** +TWO_FA_MINS = ******************************** + +HOST = ******************* +AUTH_EMAIL = ********************* +AUTH_PASSWORD = ****************** + +CLOUDNARY_API_KEY = ************** +CLOUDINARY_CLOUD_NAME = ************** +CLOUDINARY_API_SECRET = ************** \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..2607339 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,39 @@ +// eslint-disable-next-line no-undef +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + 'no-undef': 'off', + 'semi': ['warn', 'always'], + 'no-multi-spaces': 'warn', + 'no-trailing-spaces': 'warn', + 'space-before-function-paren': ['warn', 'always'], + 'func-style': ['warn', 'declaration', { allowArrowFunctions: true }], + 'camelcase': 'warn', + '@typescript-eslint/explicit-function-return-type': [ + 'warn', + { allowExpressions: true }, + ], + '@typescript-eslint/explicit-member-accessibility': [ + 'off', + { accessibility: 'explicit' }, + ], + 'no-unused-vars': 'warn', + 'no-extra-semi': 'warn', + }, +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e0c5f52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: knights-ecomm-be CI + +on: [push, pull_request] + +env: + TEST_DB_HOST: ${{secrets.TEST_DB_HOST}} + TEST_DB_PORT: ${{secrets.TEST_DB_PORT}} + TEST_DB_USER: ${{secrets.TEST_DB_USER}} + TEST_DB_PASS: ${{secrets.TEST_DB_PASS}} + TEST_DB_NAME: ${{secrets.TEST_DB_NAME}} + HOST: ${{secrets.HOST}} + AUTH_EMAIL: ${{secrets.AUTH_EMAIL}} + AUTH_PASSWORD: ${{secrets.AUTH_PASSWORD}} + JWT_SECRET: ${{secrets.JWT_SECRET}} + CLOUDNARY_API_KEY: ${{secrets.CLOUDNARY_API_KEY}} + CLOUDINARY_CLOUD_NAME: ${{secrets.CLOUDINARY_CLOUD_NAME}} + CLOUDINARY_API_SECRET: ${{secrets.CLOUDINARY_API_SECRET}} + GOOGLE_CLIENT_ID: ${{secrets.GOOGLE_CLIENT_ID}} + GOOGLE_CLIENT_SECRET: ${{secrets.GOOGLE_CLIENT_SECRET}} + + +jobs: + build-lint-test-coverage: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: npm install + + - name: Run ESLint and Prettier + run: npm run lint + + - name: Build project + run: npm run build --if-present + + - name: Run tests + run: npm test + + - name: Upload coverage report to Coveralls + uses: coverallsapp/github-action@v2.2.3 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1500c37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +.vscode +.env +package-lock.json +coverage/ +dist +/src/logs +.DS_Store \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..157d2eb --- /dev/null +++ b/.prettierrc @@ -0,0 +1,21 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "printWidth": 120, + "bracketSpacing": true, + "arrowParens": "avoid", + "proseWrap": "always", + "jsxSingleQuote": true, + "quoteProps": "consistent", + "endOfLine": "auto", + "overrides": [ + { + "files": "*.js", + "options": { + "printWidth": 80 + } + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d6c5f0 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# E-commerse Backend API + +[![knights-ecomm-be CI](https://github.com/atlp-rwanda/knights-ecomm-be/actions/workflows/ci.yml/badge.svg)](https://github.com/atlp-rwanda/knights-ecomm-be/actions/workflows/ci.yml) +   +[![Coverage Status](https://coveralls.io/repos/github/atlp-rwanda/knights-ecomm-be/badge.svg?branch=develop)](https://coveralls.io/github/atlp-rwanda/knights-ecomm-be?branch=develop) +   +[![Version](https://img.shields.io/badge/version-1.0.0-blue)](https://github.com/your-username/your-repo-name/releases/tag/v1.0.0) + +## Description + +This repository contains an E-commerce APIs, serving as backend for an E-commerce frontend application, It powers the +functionalities for the frontend, such as storing, retrieving, deleting data and much more. + +## Documentation + +[List of endpoints exposed by the service](https://knights-ecomm-be-lcdh.onrender.com/api/v1/docs/) + +## Setup + +- to use loggers in program use below functions + +```bash +logger.error('This is an error message'); +logger.warn('This is a warning message'); +logger.info('This is an informational message'); +logger.debug('This is a debug message'); + +``` + +### Technologies used + +- Languages: + - TypeScript +- Package manager: + - npm +- Stack to use: + - Node.js + - Express.js + - PostgresSQL +- Testing: + - Jest + - Supertest +- API Documentation + - Swagger Documentation + +### Getting Started + +- Clone this project on your local machine + ``` + git clone https://github.com/atlp-rwanda/knights-ecomm-be.git + ``` +- Navigate to project directory + ``` + cd knights-ecomm-be + ``` +- Install dependencies + ``` + npm install + ``` + +### Run The Service + +- Run the application + ``` + npm run dev + ``` + +## Testing + +- Run tests + ``` + npm test + ``` + +## Authors + +- [Maxime Mizero](https://github.com/maxCastro1) +- [Elie Kuradusenge](https://github.com/elijahladdie) +- [Byishimo Teto Joseph](https://github.com/MC-Knight) +- [Iragena Aime Divin](https://github.com/aimedivin) +- [Gloria Niyonkuru Sinseswa](https://github.com/GSinseswa721) +- [Grace Uwicyeza](https://github.com/UwicyezaG) +- [Jean Paul Elisa Ndevu](https://github.com/Ndevu12) +- [Gisa Mugisha Caleb Pacifique](https://github.com/Calebgisa72) diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..15175c6 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,19 @@ +/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/**/*.test.ts'], + verbose: true, + forceExit: true, + clearMocks: true, + testTimeout: 30000, + resetMocks: true, + restoreMocks: true, + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', // Include all JavaScript/JSX files in the src directory + ], + coveragePathIgnorePatterns: [ + '/node_modules/', // Exclude the node_modules directory + '/__tests__/', // Exclude the tests directory + ], +}; diff --git a/migrations/1714595134552-UserMigration.ts b/migrations/1714595134552-UserMigration.ts new file mode 100644 index 0000000..7eb7953 --- /dev/null +++ b/migrations/1714595134552-UserMigration.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserMigration1614495123940 implements MigrationInterface { + public async up (queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "user" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "firstName" character varying NOT NULL, + "lastName" character varying NOT NULL, + "email" character varying NOT NULL, + "password" character varying NOT NULL, + "gender" character varying NOT NULL, + "phoneNumber" character varying NOT NULL, + "photoUrl" character varying, + "verified" boolean NOT NULL, + "status" character varying NOT NULL CHECK (status IN ('active', 'suspended')), + "userType" character varying NOT NULL DEFAULT 'Buyer' CHECK (userType IN ('Admin', 'Buyer', 'Vendor')), + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), + CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id") + ) + `); + } + + public async down (queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "user"`); + } +} diff --git a/ormconfig.js b/ormconfig.js new file mode 100644 index 0000000..bc7acdf --- /dev/null +++ b/ormconfig.js @@ -0,0 +1,39 @@ +const devConfig = { + type: 'postgres', + host: process.env.DEV_DB_HOST, + port: process.env.DEV_DB_PORT, + username: process.env.DEV_DB_USER, + password: process.env.DEV_DB_PASS, + database: process.env.DEV_DB_NAME, + synchronize: true, + logging: false, + entities: ['src/entities/**/*.ts'], + migrations: ['src/migrations/**/*.ts'], + subscribers: ['src/subscribers/**/*.ts'], + cli: { + entitiesDir: 'src/entities', + migrationsDir: 'src/migrations', + subscribersDir: 'src/subscribers', + }, +}; + +const testConfig = { + type: 'postgres', + host: process.env.TEST_DB_HOST, + port: process.env.TEST_DB_PORT, + username: process.env.TEST_DB_USER, + password: process.env.TEST_DB_PASS, + database: process.env.TEST_DB_NAME, + synchronize: true, + logging: false, + entities: ['src/entities/**/*.ts'], + migrations: ['src/migrations/**/*.ts'], + subscribers: ['src/subscribers/**/*.ts'], + cli: { + entitiesDir: 'src/entities', + migrationsDir: 'src/migrations', + subscribersDir: 'src/subscribers', + }, +}; + +module.exports = process.env.NODE_ENV === 'test' ? testConfig : devConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..06e7e40 --- /dev/null +++ b/package.json @@ -0,0 +1,100 @@ +{ + "name": "knights-ecomm-be", + "version": "1.0.0", + "description": "E-commerce backend", + "main": "index.js", + "scripts": { + "test": "cross-env APP_ENV=test jest --coverage --detectOpenHandles --verbose --runInBand ", + "dev": "cross-env APP_ENV=dev nodemon src/index.ts", + "build": "tsc -p .", + "start": "node dist/index.js", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "format": "prettier --write .", + "typeorm": "typeorm-ts-node-commonjs", + "migration": " npm run typeorm migration:run -- -d ./ormconfig.js" + }, + "keywords": [], + "author": "Scrum master", + "license": "ISC", + "dependencies": { + "@types/express-winston": "^4.0.0", + "@types/jsonwebtoken": "^9.0.6", + "@types/multer": "^1.4.11", + "@types/nodemailer": "^6.4.14", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.6", + "axios": "^1.6.8", + "bcrypt": "^5.1.1", + "class-validator": "^0.14.1", + "cloudinary": "^2.2.0", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "cross-env": "^7.0.3", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "express-session": "^1.18.0", + "express-winston": "^4.2.0", + "highlight.js": "^11.9.0", + "joi": "^17.13.1", + "jsend": "^1.1.0", + "jsonwebtoken": "^9.0.2", + "mailgen": "^2.0.28", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.13", + "nodemon": "^3.1.0", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "pg": "^8.11.5", + "reflect-metadata": "^0.2.2", + "source-map-support": "^0.5.21", + "superagent": "^9.0.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.0", + "ts-log-debug": "^5.5.3", + "ts-node": "^10.9.2", + "typeorm": "^0.3.20", + "typescript": "^5.4.5", + "typescript-jsend": "^0.1.1", + "winston": "^3.13.0" + }, + "devDependencies": { + "@eslint/js": "^9.1.1", + "@types/bcrypt": "^5.0.2", + "@types/body-parser": "^1.19.5", + "@types/cookie-parser": "^1.4.7", + "@types/cors": "^2.8.17", + "@types/dotenv": "^8.2.0", + "@types/eslint": "^8.56.10", + "@types/eslint__js": "^8.42.3", + "@types/express": "^4.17.21", + "@types/express-session": "^1.18.0", + "@types/jest": "^29.5.12", + "@types/jsend": "^1.0.32", + "@types/jsonwebtoken": "^9.0.6", + "@types/morgan": "^1.9.9", + "@types/node": "^20.12.7", + "@types/nodemailer": "^6.4.15", + "@types/passport-google-oauth20": "^2.0.16", + "@types/reflect-metadata": "^0.1.0", + "@types/supertest": "^6.0.2", + "@types/uuid": "^9.0.8", + "@types/winston": "^2.4.4", + "@typescript-eslint/eslint-plugin": "^7.7.1", + "@typescript-eslint/parser": "^7.7.1", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-custom-plugin": "^1.0.0", + "eslint-plugin-eslint-comments": "^3.2.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "^29.7.0", + "jest-mock-extended": "^3.0.6", + "prettier": "^3.2.5", + "supertest": "^7.0.0", + "ts-jest": "^29.1.2", + "typescript": "^5.4.5", + "typescript-eslint": "^7.7.1", + "uuid": "^9.0.1" + } +} diff --git a/src/@types/index.d.ts b/src/@types/index.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/__test__/cart.test.ts b/src/__test__/cart.test.ts new file mode 100644 index 0000000..9f86f73 --- /dev/null +++ b/src/__test__/cart.test.ts @@ -0,0 +1,616 @@ + +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { v4 as uuid } from 'uuid'; +import { User, UserInterface } from '../entities/User'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { Cart } from '../entities/Cart'; +import { CartItem } from '../entities/CartItem'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const buyer1Id = uuid(); +const buyer2Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const catId = uuid(); +const cart1Id = uuid(); +const cartItemId = uuid(); +const sampleCartId = uuid(); +const sampleCartItemId = uuid(); +const samplecartItem3Id = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'vendor1', + lastName: 'user', + email: 'vendo111@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '11126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleBuyer1: UserInterface = { + id: buyer1Id, + firstName: 'buyer1', + lastName: 'user', + email: 'elijahladdiedv@gmail.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '12116380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleBuyer2: UserInterface = { + id: buyer2Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyer1112@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '12116380996348', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +const sampleProduct1 = { + id: product1Id, + name: 'test product', + description: 'amazing product', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +const sampleProduct2 = { + id: product2Id, + name: 'test product2', + description: 'amazing product2', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +const sampleCart1 = { + id: cart1Id, + user: sampleBuyer1, + totalAmount: 200, +}; + +const sampleCart2 = { + id: sampleCartId, + totalAmount: 200, +}; + +const sampleCartItem1 = { + id: cartItemId, + product: sampleProduct1, + cart: sampleCart1, + quantity: 2, + newPrice: 200, + total: 400, +}; + +const sampleCartItem2 = { + id: sampleCartItemId, + product: sampleProduct2, + cart: sampleCart1, + quantity: 2, + newPrice: 200, + total: 400, +}; + +const sampleCartItem3 = { + id: samplecartItem3Id, + product: sampleProduct2, + cart: sampleCart2, + quantity: 2, + newPrice: 200, + total: 400, +}; + +const bodyTosend = { + productId: product1Id, + quantity: 2, +}; + +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleVendor1 }); + await userRepository?.save({ ...sampleBuyer1 }); + await userRepository?.save({ ...sampleBuyer2 }); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct1 }); + await productRepository?.save({ ...sampleProduct2 }); + + const cartRepository = connection?.getRepository(Cart); + await cartRepository?.save({ ...sampleCart1 }); + await cartRepository?.save({ ...sampleCart2 }); + + const cartItemRepository = connection?.getRepository(CartItem); + await cartItemRepository?.save({ ...sampleCartItem1 }); + await cartItemRepository?.save({ ...sampleCartItem2 }); + await cartItemRepository?.save({ ...sampleCartItem3 }); +}); + +afterAll(async () => { + await cleanDatabase() + +}); + +describe('Cart management for guest/buyer', () => { + describe('Creating new product', () => { + it('should create new product', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.product).toBeDefined; + }, 60000); + + it('return an error if the number of product images exceeds 6', async () => { + const response = await request(app) + .post(`/product/`) + .field('name', 'test-product-images') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Product cannot have more than 6 images'); + }); + + it('should not create new product it already exist', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(409); + }); + + it('should not create new product, if there are missing field data', async () => { + const response = await request(app) + .post('/product') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + + it('should not create new product, images are not at least more than 1', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test-product-image') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + }); + + describe('Adding product to cart on guest/buyer', () => { + it('should get cart items of authenticated user', async () => { + const response = await request(app) + .get('/cart') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart retrieved successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get cart items of authenticated user', async () => { + const response = await request(app) + .get('/cart') + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should add product to cart as authenticated buyer', async () => { + const response = await request(app) + .post(`/cart`) + .send(bodyTosend) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should add product to cart as guest', async () => { + const response = await request(app).post(`/cart`).send(bodyTosend); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get cart items of guest user', async () => { + const response = await request(app).get('/cart'); + + expect(response.status).toBe(200); + expect(response.body.data.cart).toBeDefined; + }); + + it('should return 400 if you do not send proper request body', async () => { + const response = await request(app).post(`/cart`); + + expect(response.status).toBe(400); + }); + + it('should not add product to cart if product does not exist', async () => { + const response = await request(app) + .post(`/cart`) + .send({ productId: uuid(), quantity: 2 }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Product not found, try again.'); + }); + + it('should not add product to cart if quantity is less than 1', async () => { + const response = await request(app) + .post(`/cart`) + .send({ productId: product1Id, quantity: 0 }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Quantity must be greater than 0'); + }); + + it('should chnage quantity of product in cart if it is already there', async () => { + const response = await request(app) + .post(`/cart`) + .send({ productId: product1Id, quantity: 3 }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + }); + + describe('Getting cart items', () => { + it('should get cart items of authenticated user', async () => { + const response = await request(app) + .get('/cart') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart retrieved successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get cart items of guest user', async () => { + const response = await request(app).get('/cart'); + + expect(response.status).toBe(200); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get cart items of guest user as empty with wrong cartId', async () => { + const response = await request(app) + .get('/cart') + .set('Cookie', [`cartId=${uuid()}`]); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + }); + + describe('Deleting product from cart', () => { + it('should return 404 if product does not exist in cart', async () => { + const response = await request(app) + .delete(`/cart/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Cart item not found'); + }); + + it('should return 401 if you try to delete item not in your cart', async () => { + const response = await request(app) + .delete(`/cart/${cartItemId}`) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(401); + expect(response.body.message).toBe('You are not authorized to perform this action'); + }); + + it('should delete product from cart', async () => { + const response = await request(app) + .delete(`/cart/${sampleCartItemId}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Product removed from cart successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should delete product from cart', async () => { + const response = await request(app) + .delete(`/cart/${cartItemId}`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('cart removed successfully'); + }); + + it('should add product to cart as authenticated buyer', async () => { + const response = await request(app) + .post(`/cart`) + .send(bodyTosend) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should add product to cart as authenticated buyer', async () => { + const response = await request(app) + .post(`/cart`) + .send({ productId: product2Id, quantity: 2 }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should return 404 if product does not exist in guest cart', async () => { + const response = await request(app).delete(`/cart/${uuid()}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Cart item not found'); + }); + + it('should return 404 if product does not exist in guest cart', async () => { + const response = await request(app).delete(`/cart/${samplecartItem3Id}`); + + expect(response.status).toBe(200); + }); + }); + + describe('Clearing cart', () => { + it('should return 200 as authenticated buyer does not have a cart', async () => { + const response = await request(app) + .delete(`/cart`) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should add product to cart as authenticated buyer', async () => { + const response = await request(app) + .post(`/cart`) + .send(bodyTosend) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.message).toBe('cart updated successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should clear cart as authenticated buyer', async () => { + const response = await request(app) + .delete(`/cart`) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart cleared successfully'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should return 200 as guest does not have a cart', async () => { + const response = await request(app).delete(`/cart`); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should get cart items of guest user as empty with wrong cartId', async () => { + const response = await request(app) + .get('/cart') + .set('Cookie', [`cartId=${uuid()}`]); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + + it('should delete cart items of guest user as empty with wrong cartId', async () => { + const response = await request(app) + .delete('/cart') + .set('Cookie', [`cartId=${sampleCartId}`]); + + expect(response.status).toBe(200); + expect(response.body.data.message).toBe('Cart is empty'); + expect(response.body.data.cart).toBeDefined; + }); + }); +}); + +describe('Order management tests', () => { + let orderId: string | null; + describe('Create order', () => { + it('should return 400 when user ID is not provided', async () => { + const response = await request(app) + .post('/product/orders') + .send({ + address: { + country: 'Test Country', + city: 'Test City', + street: 'Test Street', + }, + }).set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(400); + }); + + it('should create a new order', async () => { + + const response = await request(app) + .post('/product/orders') + .send({ + address: { + country: 'Test Country', + city: 'Test City', + street: 'Test Street', + }, + }) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBeUndefined; + orderId = response.body.data?.orderId; // Assuming orderId is returned in response + }); + + it('should insert a new order', async () => { + + const response = await request(app) + .post('/product/orders') + .send({ + address: { + country: 'Test Country', + city: 'Test City', + street: 'Test Street', + }, + }) + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBeUndefined; + orderId = response.body.data?.orderId; // Assuming orderId is returned in response + }); + }); + + describe('Get orders', () => { + it('should return orders for the buyer', async () => { + const response = await request(app) + .get('/product/client/orders') + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + expect(response.status).toBe(404); + expect(response.body.message).toBeUndefined; + + }); + + it('should return 404 if the buyer has no orders', async () => { + const response = await request(app) + .get('/product/client/orders') + .set('Authorization', `Bearer ${getAccessToken(buyer2Id, sampleBuyer2.email)}`); + expect(response.status).toBe(404); + expect(response.body.message).toBeUndefined; + }); + }); + + describe('Get transaction history', () => { + it('should return transaction history for the buyer', async () => { + + const response = await request(app) + .get('/product/orders/history') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(404); + expect(response.body.message).toBe('No transaction history found'); + + }); + + it('should return 400 when user ID is not provided', async () => { + const response = await request(app) + .get('/product/orders/history') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(404); + }); + }); + + describe('Update order', () => { + it('should update order status successfully', async () => { + + const response = await request(app) + .put(`/product/client/orders/${orderId}`) + .send({ orderStatus: 'delivered' }) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/__test__/coupon.test.ts b/src/__test__/coupon.test.ts new file mode 100644 index 0000000..b3f68b4 --- /dev/null +++ b/src/__test__/coupon.test.ts @@ -0,0 +1,423 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { Coupon } from '../entities/coupon'; +import { CartItem } from '../entities/CartItem'; +import { Cart } from '../entities/Cart'; +import { Product } from '../entities/Product'; +import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const cart1Id = uuid(); +const cartItemId = uuid(); +const buyer1Id = uuid(); +const buyer2Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const couponCode = 'DISCOUNT20'; +const couponCode1 = 'DISCOUNT10'; +const couponCode2 = 'DISCOUNT99'; +const couponCode3 = 'DISCOUNT22' +const expiredCouponCode = 'EXPIRED'; +const finishedCouponCode = 'FINISHED'; +const moneyCouponCode = 'MONEY'; +const invalidCouponCode = 'INVALIDCODE'; + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'Vendor', + lastName: 'User', + email: 'vendor@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '1234567890', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleBuyer1: UserInterface = { + id: buyer1Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyer1@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; +const buyerNoCart: UserInterface = { + id: buyer2Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyr122@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '159380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleProduct1 = new Product(); +sampleProduct1.id = product1Id; +sampleProduct1.name = 'Test Product'; +sampleProduct1.description = 'Amazing product'; +sampleProduct1.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct1.newPrice = 200; +sampleProduct1.quantity = 10; +sampleProduct1.vendor = sampleVendor1 as User; + +const sampleProduct2 = new Product(); +sampleProduct2.id = product2Id; +sampleProduct2.name = 'Test 2 Product'; +sampleProduct2.description = 'Amazing product 2'; +sampleProduct2.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct2.newPrice = 200; +sampleProduct2.quantity = 10; +sampleProduct2.vendor = sampleVendor1 as User; + +const sampleCoupon = new Coupon(); +sampleCoupon.code = couponCode; +sampleCoupon.discountRate = 20; +sampleCoupon.expirationDate = new Date('2025-01-01'); +sampleCoupon.maxUsageLimit = 100; +sampleCoupon.discountType = 'percentage'; +sampleCoupon.product = sampleProduct1; +sampleCoupon.vendor = sampleVendor1 as User; + +const sampleCoupon1 = new Coupon(); +sampleCoupon1.code = couponCode1; +sampleCoupon1.discountRate = 20; +sampleCoupon1.expirationDate = new Date('2025-01-01'); +sampleCoupon1.maxUsageLimit = 100; +sampleCoupon1.discountType = 'percentage'; +sampleCoupon1.product = sampleProduct1; +sampleCoupon1.vendor = sampleVendor1 as User; + +const sampleCoupon2 = new Coupon(); +sampleCoupon2.code = couponCode2; +sampleCoupon2.discountRate = 20; +sampleCoupon2.expirationDate = new Date('2026-01-01'); +sampleCoupon2.maxUsageLimit = 100; +sampleCoupon2.discountType = 'percentage'; +sampleCoupon2.product = sampleProduct1; +sampleCoupon2.vendor = sampleVendor1 as User; + +const sampleCoupon3 = new Coupon(); +sampleCoupon3.code = couponCode3; +sampleCoupon3.discountRate = 20; +sampleCoupon3.expirationDate = new Date('2026-01-01'); +sampleCoupon3.maxUsageLimit = 100; +sampleCoupon3.discountType = 'percentage'; +sampleCoupon3.product = sampleProduct2; +sampleCoupon3.vendor = sampleVendor1 as User; + +const expiredCoupon = new Coupon(); +expiredCoupon.code = expiredCouponCode; +expiredCoupon.discountRate = 20; +expiredCoupon.expirationDate = new Date('2023-01-01'); +expiredCoupon.maxUsageLimit = 100; +expiredCoupon.discountType = 'percentage'; +expiredCoupon.product = sampleProduct1; +expiredCoupon.vendor = sampleVendor1 as User; + +const finishedCoupon = new Coupon(); +finishedCoupon.code = finishedCouponCode; +finishedCoupon.discountRate = 20; +finishedCoupon.expirationDate = new Date('2028-01-01'); +finishedCoupon.maxUsageLimit = 0; +finishedCoupon.discountType = 'percentage'; +finishedCoupon.product = sampleProduct1; +finishedCoupon.vendor = sampleVendor1 as User; + +const moneyCoupon = new Coupon(); +moneyCoupon.code = moneyCouponCode; +moneyCoupon.discountRate = 50; +moneyCoupon.expirationDate = new Date('2028-01-01'); +moneyCoupon.maxUsageLimit = 10; +moneyCoupon.discountType = 'money'; +moneyCoupon.product = sampleProduct1; +moneyCoupon.vendor = sampleVendor1 as User; + +const sampleCart1 = { + id: cart1Id, + user: sampleBuyer1, + totalAmount: 200, +}; + +const sampleCartItem1 = { + id: cartItemId, + product: sampleProduct1, + cart: sampleCart1, + quantity: 2, + newPrice: 200, + total: 400, +}; + +beforeAll(async () => { + const connection = await dbConnection(); + + const userRepository = connection?.getRepository(User); + await userRepository?.save(sampleVendor1); + await userRepository?.save(sampleBuyer1); + await userRepository?.save(buyerNoCart); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save(sampleProduct1); + await productRepository?.save(sampleProduct2); + + const couponRepository = connection?.getRepository(Coupon); + await couponRepository?.save(sampleCoupon); + await couponRepository?.save(sampleCoupon1); + await couponRepository?.save(expiredCoupon); + await couponRepository?.save(sampleCoupon2); + await couponRepository?.save(sampleCoupon3); + await couponRepository?.save(finishedCoupon); + await couponRepository?.save(moneyCoupon); + + const cartRepository = connection?.getRepository(Cart); + await cartRepository?.save({ ...sampleCart1 }); + + const cartItemRepository = connection?.getRepository(CartItem); + await cartItemRepository?.save({ ...sampleCartItem1 }); + +}); + +afterAll(async () => { + await cleanDatabase() + + server.close(); +}); + +describe('Coupon Management System', () => { + describe('Create Coupon', () => { + it('should create a new coupon', async () => { + const response = await request(app) + .post(`/coupons/vendor/${vendor1Id}/`) + .send({ + code: 'NEWCOUPON10', + discountRate: 10, + expirationDate: '2025-12-31', + maxUsageLimit: 50, + discountType: 'PERCENTAGE', + product: product1Id, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.status).toBe('success'); + }, 10000); + + it('should return 400 for invalid coupon data', async () => { + const response = await request(app) + .post(`/coupons/vendor/${vendor1Id}/`) + .send({ + code: '', + discountRate: 'invalid', + expirationDate: 'invalid-date', + maxUsageLimit: 'invalid', + discountType: 'INVALID', + product: 'invalid-product-id', + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }, 10000); + }); + + describe('Get All Coupons', () => { + it('should retrieve all coupons for a vendor', async () => { + const response = await request(app) + .get(`/coupons/vendor/${vendor1Id}/access-coupons`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + expect(response.body.data).toBeInstanceOf(Object); + }, 10000); + + it('should return 404 if no coupons found', async () => { + const newVendorId = uuid(); + const response = await request(app) + .get(`/coupons/vendor/${newVendorId}/access-coupons`) + .set('Authorization', `Bearer ${getAccessToken(newVendorId, 'newvendor@example.com')}`); + + expect(response.status).toBe(401); + }, 10000); + }); + + describe('Read Coupon', () => { + it('should read a single coupon by code', async () => { + const response = await request(app) + .get(`/coupons/vendor/${vendor1Id}/checkout/${couponCode}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }, 10000); + + it('should return 404 for invalid coupon code', async () => { + const response = await request(app) + .get(`/coupons/vendor/${vendor1Id}/checkout/${invalidCouponCode}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.status).toBe('error'); + expect(response.body.message).toBe('Invalid coupon'); + }, 10000); + }); + + describe('Update Coupon', () => { + it('should update an existing coupon', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${couponCode1}`) + .send({ + code: 'KAGAHEBUZO04', + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + }, 10000); + + it('should return 404 for updating a non-existent coupon', async () => { + const response = await request(app) + .put(`/coupons/vendor/${vendor1Id}/update-coupon/${invalidCouponCode}`) + .send({ + discountRate: 25, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Coupon not found'); + }, 10000); + }); + + describe('Delete Coupon', () => { + it('should delete an existing coupon', async () => { + const response = await request(app) + .delete(`/coupons/vendor/${vendor1Id}/checkout/delete`) + .send({ + code: couponCode, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + }, 10000); + + it('should return 404 for deleting a non-existent coupon', async () => { + const response = await request(app) + .delete(`/coupons/vendor/${vendor1Id}/checkout/delete`) + .send({ + code: invalidCouponCode, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.status).toBe('error'); + expect(response.body.message).toBe('Invalid coupon'); + }, 10000); + }); +}); + +describe('Buyer Coupon Application', () => { + describe('Checking Coupon Conditions', () =>{ + it('should return 400 when no coupon submitted', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Coupon Code is required'); + }) + it('should return 404 if coupon code is not found in the database', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: "InvalidCode", + }); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Invalid Coupon Code'); + }) + it('should not allow use of expired tokens', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: expiredCoupon.code, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Coupon is expired'); + }) + it('should not allow use of coupon that reach maximum users', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: finishedCoupon.code, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Coupon Discount Ended'); + }) + it('Should not work when the product is not in cart', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: sampleCoupon3.code, + }); + + expect(response.status).toBe(404); + expect(response.body.message).toBe("No product in Cart with that coupon code"); + }) + }) + + describe("Giving discount according the the product coupon", () => { + it('Should give discont when discount-type is percentage', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: sampleCoupon2.code, + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe(`Coupon Code successfully activated discount on product: ${sampleProduct1.name}`); + }) + it('Should give discont when discount-type is money', async () => { + const response = await request(app) + .post(`/coupons/apply`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`) + .send({ + couponCode: moneyCoupon.code, + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe(`Coupon Code successfully activated discount on product: ${sampleProduct1.name}`); + }) + }) + +}) diff --git a/src/__test__/errorHandler.test.ts b/src/__test__/errorHandler.test.ts new file mode 100644 index 0000000..fb1437c --- /dev/null +++ b/src/__test__/errorHandler.test.ts @@ -0,0 +1,47 @@ +import { Request, Response } from 'express'; +import { CustomError, errorHandler } from '../middlewares/errorHandler' + +describe('CustomError', () => { + it('should create a CustomError object with statusCode and status properties', () => { + const message = 'Test error message'; + const statusCode = 404; + const customError = new CustomError(message, statusCode); + expect(customError.message).toBe(message); + expect(customError.statusCode).toBe(statusCode); + expect(customError.status).toBe('fail'); + }); + }); + + describe('errorHandler', () => { + it('should send correct response with status code and message', () => { + const err = new CustomError('Test error message', 404); + const req = {} as Request; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + const next = jest.fn(); + errorHandler(err, req, res, next); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + status: 404, + message: 'Test error message', + }); + }); + it('should handle errors with status code 500', () => { + const err = new CustomError('something went wrong', 500); + const req = {} as Request; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + const next = jest.fn(); + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + status: 500, + message: 'something went wrong', + }); + }); + }); \ No newline at end of file diff --git a/src/__test__/getProduct.test.ts b/src/__test__/getProduct.test.ts new file mode 100644 index 0000000..88dd415 --- /dev/null +++ b/src/__test__/getProduct.test.ts @@ -0,0 +1,124 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const product1Id = uuid(); +const Invalidproduct = '11278df2-d026-457a-9471-4749f038df68'; +const catId = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'vendor1o', + lastName: 'user', + email: 'vendor10@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '126380996348', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +const sampleProduct1 = { + id: product1Id, + name: 'test product single', + description: 'amazing product', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleVendor1 }); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct1 }); +}); + +afterAll(async () => { + await cleanDatabase() + + server.close(); +}); + +describe('Creating new product', () => { + it('should create new product', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.product).toBeDefined; + }, 20000); +}); +describe('Get single product', () => { + it('should get a single product', async () => { + const response = await request(app) + .get(`/product/${product1Id}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.status).toBe('success'); + expect(response.body.product).toBeDefined; + expect(response.body.product.id).toBe(product1Id); + }, 10000); + + it('should return 400 for invalid product Id', async () => { + const response = await request(app) + .get(`/product/non-existing-id`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.status).toBe('error'); + expect(response.body.message).toBe('Invalid product ID'); + }, 10000); + it('should return 404 for product not found', async () => { + const response = await request(app) + .get(`/product/${Invalidproduct}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Product not found'); + }, 10000); +}); diff --git a/src/__test__/isAllowed.test.ts b/src/__test__/isAllowed.test.ts new file mode 100644 index 0000000..b17b657 --- /dev/null +++ b/src/__test__/isAllowed.test.ts @@ -0,0 +1,92 @@ +import { NextFunction, Request, Response } from 'express'; +import { checkUserStatus } from '../middlewares/isAllowed'; +import { dbConnection } from '../startups/dbConnection'; +import { getConnection } from 'typeorm'; +import { User } from '../entities/User'; +import { responseError } from '../utils/response.utils'; +import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +jest.mock('../utils/response.utils'); + +let reqMock: Partial; +let resMock: Partial; +let nextMock: NextFunction; + +const activeUserId = uuid(); +const suspendedUserId = uuid(); + +beforeAll(async () => { + const connection = await dbConnection(); + + const userRepository = connection?.getRepository(User); + + const activeUser = new User(); + activeUser.id = activeUserId; + activeUser.firstName = 'John2'; + activeUser.lastName = 'Doe'; + activeUser.email = 'active.doe@example.com'; + activeUser.password = 'password'; + activeUser.gender = 'Male'; + activeUser.phoneNumber = '12347'; + activeUser.photoUrl = 'https://example.com/photo.jpg'; + + await userRepository?.save(activeUser); + + const suspendedUser = new User(); + suspendedUser.id = suspendedUserId; + suspendedUser.firstName = 'John2'; + suspendedUser.lastName = 'Doe'; + suspendedUser.email = 'suspended.doe@example.com'; + suspendedUser.password = 'password'; + suspendedUser.gender = 'Male'; + suspendedUser.status = 'suspended'; + suspendedUser.phoneNumber = '12349'; + suspendedUser.photoUrl = 'https://example.com/photo.jpg'; + + await userRepository?.save(suspendedUser); +}); + +afterAll(async () => { + await cleanDatabase() + +}); + +describe('Middleware - checkUserStatus', () => { + beforeEach(() => { + reqMock = {}; + resMock = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + nextMock = jest.fn(); + }); + + it('should return 401 if user is not authenticated', async () => { + await checkUserStatus(reqMock as Request, resMock as Response, nextMock); + expect(responseError).toHaveBeenCalledWith(resMock, 401, 'Authentication required'); + }); + + it('should return 401 if user is not found', async () => { + reqMock = { user: { id: uuid() } }; + + await checkUserStatus(reqMock as Request, resMock as Response, nextMock); + + expect(responseError).toHaveBeenCalledWith(resMock, 401, 'User not found'); + }); + + it('should pass if user status is active', async () => { + reqMock = { user: { id: activeUserId } }; + await checkUserStatus(reqMock as Request, resMock as Response, nextMock); + expect(nextMock).toHaveBeenCalled(); + }); + it('should return 403 if user status is suspended', async () => { + reqMock = { user: { id: suspendedUserId } }; + await checkUserStatus(reqMock as Request, resMock as Response, nextMock); + expect(responseError).toHaveBeenCalledWith( + resMock, + 403, + 'You have been suspended. Please contact our support team.' + ); + }); +}); diff --git a/src/__test__/logout.test.ts b/src/__test__/logout.test.ts new file mode 100644 index 0000000..cd950fd --- /dev/null +++ b/src/__test__/logout.test.ts @@ -0,0 +1,76 @@ +import request from 'supertest'; +import { app, server } from '../index'; +import { createConnection, getConnection, getConnectionOptions, getRepository } from 'typeorm'; +import { User } from '../entities/User'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +beforeAll(async () => { + // Connect to the test database + await createConnection(); +}); + +afterAll(async () => { + await cleanDatabase() + + + server.close(); +}); + +describe('POST /user/logout', () => { + it('should logout a user', async () => { + // sign up a user + const registerUser = { + firstName: 'Ndevu', + lastName: 'Elisa', + email: 'ndevukumurindi@gmail.com', + gender: 'male', + phoneNumber: '078907987443', + photoUrl: 'https://example.com/images/photo.jpg', + userType: 'vender', + verified: true, + status: 'active', + password: process.env.TEST_USER_LOGIN_PASS, + }; + + await request(app).post('/user/register').send(registerUser); + + const loginUser = { + email: registerUser.email, + password: process.env.TEST_USER_LOGIN_PASS, + }; + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { email: registerUser.email } }); + if (user) { + const verifyRes = await request(app).get(`/user/verify/${user.id}`); + + if (!verifyRes) throw new Error(`Test User verification failed for ${user.email}`); + + const loginResponse = await request(app).post('/user/login').send(loginUser); + const setCookie = loginResponse.headers['set-cookie']; + + if (!setCookie) { + throw new Error('No cookies set in login response'); + } + + const resp = await request(app).post('/user/logout').set('Cookie', setCookie); + expect(resp.status).toBe(200); + expect(resp.body).toEqual({ Message: 'Logged out successfully' }); + + // Clean up: delete the test user + await userRepository.remove(user); + } + }); + + it('should not logout a user who is not logged in or with no token', async () => { + const fakeEmail = 'ndevukkkk@gmail.com'; + const loginUser = { + email: fakeEmail, + password: process.env.TEST_USER_LOGIN_PASS, + }; + const token = ''; + const res = await request(app).post('/user/logout').send(loginUser).set('Cookie', token); + expect(res.status).toBe(400); + expect(res.body).toEqual({ Message: 'Access denied. You must be logged in' }); + }); +}); diff --git a/src/__test__/oauth.test.ts b/src/__test__/oauth.test.ts new file mode 100644 index 0000000..2493059 --- /dev/null +++ b/src/__test__/oauth.test.ts @@ -0,0 +1,28 @@ +import request from 'supertest'; +import { app, server } from '../index'; +import { createConnection, getConnection, getConnectionOptions, getRepository } from 'typeorm'; +import { User } from '../entities/User'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +beforeAll(async () => { + + await createConnection(); +}); + +afterAll(async () => { + await cleanDatabase() + server.close(); +}); +describe('authentication routes test',() => { + it('should redirect to the google authentication page',async() => { + const response = await request(app) + .get('/user/google-auth'); + expect(response.statusCode).toBe(302) + }) + it('should redirect after google authentication', async() => { + const response = await request(app) + .get('/user/auth/google/callback'); + expect(response.statusCode).toBe(302) + }) +}); + diff --git a/src/__test__/productStatus.test.ts b/src/__test__/productStatus.test.ts new file mode 100644 index 0000000..6d6df6a --- /dev/null +++ b/src/__test__/productStatus.test.ts @@ -0,0 +1,243 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const vendor2Id = uuid(); +const vendor3Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const product3Id = uuid(); +const product4Id = uuid(); +const product5Id = uuid(); +const catId = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleVendor1 = new User(); +sampleVendor1.id = vendor1Id; +sampleVendor1.firstName = 'vendor1'; +sampleVendor1.lastName = 'user'; +sampleVendor1.email = 'vendora1@example.com'; +sampleVendor1.password = 'password'; +sampleVendor1.userType = 'Vendor'; +sampleVendor1.gender = 'Male'; +sampleVendor1.phoneNumber = '126380996347'; +sampleVendor1.photoUrl = 'https://example.com/photo.jpg'; +sampleVendor1.role = 'VENDOR'; + +const sampleVendor2 = new User(); +sampleVendor2.id = vendor2Id; +sampleVendor2.firstName = 'vendor2'; +sampleVendor2.lastName = 'user'; +sampleVendor2.email = 'vendora2@example.com'; +sampleVendor2.password = 'password'; +sampleVendor2.userType = 'Vendor'; +sampleVendor2.gender = 'Male'; +sampleVendor2.phoneNumber = '1638099634'; +sampleVendor2.photoUrl = 'https://example.com/photo.jpg'; +sampleVendor2.role = 'VENDOR'; + +const sampleVendor3 = new User(); +sampleVendor3.id = vendor3Id; +sampleVendor3.firstName = 'vendor3 ddss'; +sampleVendor3.lastName = 'user'; +sampleVendor3.email = 'vendor2@example.com'; +sampleVendor3.password = 'password'; +sampleVendor3.userType = 'Vendor'; +sampleVendor3.gender = 'Male'; +sampleVendor3.phoneNumber = '1638099634'; +sampleVendor3.photoUrl = 'https://example.com/photo.jpg'; +sampleVendor3.role = 'VENDOR'; + +const sampleCat = new Category(); +sampleCat.id = catId; +sampleCat.name = 'accessories'; + +const sampleProduct1 = new Product(); +sampleProduct1.id = product1Id; +sampleProduct1.name = 'test product'; +sampleProduct1.description = 'amazing product'; +sampleProduct1.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg']; +sampleProduct1.newPrice = 200; +sampleProduct1.quantity = 10; +sampleProduct1.vendor = sampleVendor1; +sampleProduct1.categories = [sampleCat]; + +const sampleProduct2 = new Product(); +sampleProduct2.id = product2Id; +sampleProduct2.name = 'test product2'; +sampleProduct2.description = 'amazing product2'; +sampleProduct2.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg']; +sampleProduct2.newPrice = 200; +sampleProduct2.quantity = 10; +sampleProduct2.vendor = sampleVendor1; +sampleProduct2.categories = [sampleCat]; + +const sampleProduct3 = new Product(); +sampleProduct3.id = product3Id; +sampleProduct3.name = 'testing product3'; +sampleProduct3.description = 'amazing product3'; +sampleProduct3.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg']; +sampleProduct3.newPrice = 200; +sampleProduct3.quantity = 10; +sampleProduct3.vendor = sampleVendor2; +sampleProduct3.categories = [sampleCat]; + +const sampleProduct4 = new Product(); +sampleProduct4.id = product4Id; +sampleProduct4.name = 'testingmkknkkjiproduct4'; +sampleProduct4.description = 'amazing product4'; +sampleProduct4.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg']; +sampleProduct4.newPrice = 200; +sampleProduct4.quantity = 10; +sampleProduct4.vendor = sampleVendor2; +sampleProduct4.categories = [sampleCat]; + +const sampleProduct5 = new Product(); +sampleProduct5.id = product5Id; +sampleProduct5.name = 'Here is testing with product5'; +sampleProduct5.description = 'amazing product5'; +sampleProduct5.images = ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg']; +sampleProduct5.newPrice = 20; +sampleProduct5.quantity = 10; +sampleProduct5.vendor = sampleVendor1; +sampleProduct5.categories = [sampleCat]; +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + const savedCategory = await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + const savedVendor1 = await userRepository?.save({ ...sampleVendor1 }); + const savedVendor2 = await userRepository?.save({ ...sampleVendor2 }); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct1, vendor: savedVendor1, categories: [savedCategory as Category] }); + await productRepository?.save({ ...sampleProduct2, vendor: savedVendor1, categories: [savedCategory as Category] }); + await productRepository?.save({ ...sampleProduct3, vendor: savedVendor2, categories: [savedCategory as Category] }); + await productRepository?.save({ ...sampleProduct5, vendor: savedVendor1, categories: [savedCategory as Category] }); + + sampleProduct2.expirationDate = new Date(2020 - 3 - 24); + productRepository?.save(sampleProduct2); + + sampleProduct5.quantity = 0; + productRepository?.save(sampleProduct5); +}); + +afterAll(async () => { + await cleanDatabase() + + server.close(); +}); + +describe('Vendor product availability status management tests', () => { + it('Should update product availability status', async () => { + const response = await request(app) + .put(`/product/availability/${product1Id}`) + .send({ + isAvailable: false, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.message).toBe('Product status updated successfully'); + }, 10000); + + it('should auto update product status to false if product is expired', async () => { + const response = await request(app) + .put(`/product/availability/${product2Id}`) + .send({ + isAvailable: true, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(201); + expect(response.body.data.message).toBe('Product status is set to false because it is expired.'); + }); + + it('should update product status to false if product is out of stock', async () => { + const response = await request(app) + .put(`/product/availability/${product5Id}`) + .send({ + isAvailable: true, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(202); + expect(response.body.data.message).toBe('Product status is set to false because it is out of stock.'); + }); + + it('should not update product status if it is already updated', async () => { + const response = await request(app) + .put(`/product/availability/${product1Id}`) + .send({ + isAvailable: false, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(400); + }); + + it("should not update product status if it doesn't exists", async () => { + const response = await request(app) + .put(`/product/availability/${product4Id}`) + .send({ + isAvailable: true, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Product not found'); + }); + + it('should not update product which is not in VENDOR s stock', async () => { + const response = await request(app) + .put(`/product/availability/${product3Id}`) + .send({ + isAvailable: true, + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Product not found in your stock'); + }); +}); + + +describe('search product by name availability tests', () => { + it('Should search product by name', async () => { + const response = await request(app) + .get(`/product/search?name=testingmkknkkjiproduct4`) + expect(response.body.data).toBeDefined; + }, 10000); + + it('should return empty array if there is product is not found in the database', async () => { + const response = await request(app) + .put(`/product/search?name=home`) + + + expect(response.statusCode).toBe(401); + expect(response.body.data).toBeUndefined; + }); + + }); + diff --git a/src/__test__/roleCheck.test.ts b/src/__test__/roleCheck.test.ts new file mode 100644 index 0000000..ada2271 --- /dev/null +++ b/src/__test__/roleCheck.test.ts @@ -0,0 +1,91 @@ +import { Response, NextFunction, Request } from 'express'; +import { User } from '../entities/User'; +import { hasRole } from '../middlewares'; +import { responseError } from '../utils/response.utils'; +import { dbConnection } from '../startups/dbConnection'; +import { v4 as uuid } from 'uuid'; +import { getConnection } from 'typeorm'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +let reqMock: Partial; +let resMock: Partial; +let nextMock: NextFunction; + +const userId = uuid(); + +beforeAll(async () => { + // Connect to the test database + const connection = await dbConnection(); + + const userRepository = connection?.getRepository(User); + + const user = new User(); + + user.id = userId; + user.firstName = 'John2'; + user.lastName = 'Doe'; + user.email = 'john2.doe@example.com'; + user.password = 'password'; + user.gender = 'Male'; + user.phoneNumber = '1234'; + user.userType = 'Buyer'; + user.photoUrl = 'https://example.com/photo.jpg'; + + await userRepository?.save(user); +}); + +afterAll(async () => { + await cleanDatabase() +}); + +describe('hasRole MiddleWare Test', () => { + beforeEach(() => { + reqMock = {}; + resMock = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + nextMock = jest.fn(); + }); + + it('should return 401, if user is not authentication', async () => { + await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); + expect(responseError).toHaveBeenCalled; + expect(resMock.status).toHaveBeenCalledWith(401); + }); + + it('should return 401 if user is not found', async () => { + reqMock = { user: { id: uuid() } }; + + await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); + + expect(responseError).toHaveBeenCalled; + expect(resMock.status).toHaveBeenCalledWith(401); + }); + + it('should return 403 if user does not have required role', async () => { + reqMock = { user: { id: userId } }; + + await hasRole('ADMIN')(reqMock as Request, resMock as Response, nextMock); + + expect(responseError).toHaveBeenCalled; + expect(resMock.status).toHaveBeenCalledWith(403); + }); + + it('should call next() if user has required role', async () => { + reqMock = { user: { id: userId } }; + + await hasRole('BUYER')(reqMock as Request, resMock as Response, nextMock); + + expect(nextMock).toHaveBeenCalled(); + }); + + it('should return 400 if user id is of invalid format', async () => { + reqMock = { user: { id: 'sample userId' } }; + + await hasRole('BUYER')(reqMock as Request, resMock as Response, nextMock); + + expect(responseError).toHaveBeenCalled; + expect(resMock.status).toHaveBeenCalledWith(400); + }); +}); diff --git a/src/__test__/route.test.ts b/src/__test__/route.test.ts new file mode 100644 index 0000000..721f763 --- /dev/null +++ b/src/__test__/route.test.ts @@ -0,0 +1,231 @@ +import request from 'supertest'; +import { app, server } from '../index'; +import { createConnection, getConnection, getConnectionOptions, getRepository } from 'typeorm'; +import { User } from '../entities/User'; +import { response } from 'express'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +beforeAll(async () => { + await createConnection(); +}); + +jest.setTimeout(20000); +afterAll(async () => { + await cleanDatabase() + + + server.close(); +}); + +describe('GET /', () => { + it('This is a testing route that returns', done => { + request(app) + .get('/') + .expect(200) + .expect('Content-Type', /json/) + .expect( + { + status: 'success', + data: { + code: 200, + message: 'This is a testing route.', + }, + }, + done + ); + }); +}); +describe('POST /user/register', () => { + it('should register a new user', async () => { + // Arrange + const newUser = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe1@example.com', + password: 'password', + gender: 'Male', + phoneNumber: '0789412421', + userType: 'Buyer', + }; + + // Act + const res = await request(app).post('/user/register').send(newUser); + // Assert + expect(res.body).toEqual({ + status: 'success', + data: { + code: 201, + message: 'User registered successfully', + }, + }); + }); +}); +describe('POST /user/verify/:id', () => { + it('should verify a user', async () => { + // Arrange + const newUser = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe1@example.com', + password: 'password', + gender: 'Male', + phoneNumber: '123456789', + userType: 'Buyer', + photoUrl: 'https://example.com/photo.jpg', + }; + + // Create a new user + const res = await request(app).post('/user/register').send(newUser); + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { email: newUser.email } }); + + if (user) { + const verifyRes = await request(app).get(`/user/verify/${user.id}`); + + // Assert + expect(verifyRes.status).toBe(200); + expect(verifyRes.text).toEqual('

User verified successfully

'); + + // Check that the user's verified field is now true + const verifiedUser = await userRepository.findOne({ where: { email: newUser.email } }); + if (verifiedUser) { + expect(verifiedUser.verified).toBe(true); + } + } + }); +}); + +describe('Send password reset link', () => { + it('Attempt to send email with rate limiting', async () => { + const email = 'elijahladdiedv@gmail.com'; + + const requests = []; + for (let i = 0; i < 5; i++) { + requests.push(await request(app).post(`/user/password/reset/link?email=${email}`)); + } + + const responses = await Promise.all(requests); + const lastResponse = responses[responses.length - 1]; + expect(lastResponse.status).toBe(404); + expect(lastResponse.body.message).toEqual('User not found'); + }, 20000); + + it('Attempt to send email with invalid email template', async () => { + const email = 'elijahladdiedv@gmail.com'; + + const res = await request(app).post(`/user/password/reset/link?email=${email}`); + + expect(res.status).toBe(404); + expect(res.body.message).toEqual('User not found'); + }, 10000); + + it('Send email to a user with special characters in email address', async () => { + const email = 'user+test@example.com'; + + const res = await request(app).post(`/user/password/reset/link?email=${encodeURIComponent(email)}`); + + expect(res.status).toBe(404); + expect(res.body.message).toEqual('User not found'); + }, 10000); +}); +describe('Password Reset Service', () => { + it('Should reset password successfully', async () => { + const data = { + newPassword: 'user', + confirmPassword: 'user', + }; + const email = 'elijahladdiedv@gmail.com'; + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { email: email } }); + if (user) { + const res: any = await request(app).post(`/user/password/reset?userid=${user.id}&email=${email}`).send(data); + // Assert + expect(res.status).toBe(200); + expect(res.data.message).toEqual('Password updated successful'); + } + }); + + it('Should return 404 if user not found', async () => { + const data = { + newPassword: 'user', + confirmPassword: 'user', + }; + const email = 'nonexistentemail@example.com'; + const userId = 'nonexistentuserid'; + const res: any = await request(app).post(`/user/password/reset?userid=${userId}&email=${email}`).send(data); + // Asser + expect(res).toBeTruthy; + }); + + it('Should return 204 if required fields are missing', async () => { + const data = { + // + }; + const email = 'elijahladdiedv@gmail.com'; + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { email: email } }); + if (user) { + const res: any = await request(app).post(`/user/password/reset?userid=${user.id}&email=${email}`).send(data); + expect(res.status).toBe(204); + expect(res.data.error).toEqual('Please provide all required fields'); + } + }); + + it('Should return 204 if newPassword and confirmPassword do not match', async () => { + const data = { + newPassword: 'user123', + confirmPassword: 'user456', + }; + const email = 'elijahladdiedv@gmail.com'; + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { email: email } }); + if (user) { + const res: any = await request(app).post(`/user/password/reset?userid=${user.id}&email=${email}`).send(data); + expect(res.status).toBe(204); + expect(res.data.error).toEqual('New password must match confirm password'); + } + }); +}); +describe('PUT/user/update', () => { + it('should return 401 if user is not authenticated', async () => { + const newUser = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe23@example.com', + password: 'password', + gender: 'Male', + phoneNumber: '12345678900', + userType: 'Buyer', + photoUrl: 'https://example.com/photo.jpg', + }; + + // Create a new user + const res = await request(app).post('/user/register').send(newUser); + const userRepository = getRepository(User); + + const user = await userRepository.findOne({ where: { email: newUser.email } }); + if (user) { + const updateUser = { + id: user.id, + firstName: 'Biguseers2399', + lastName: '1', + email: 'john.doe23@example.com', + gender: 'Male', + phoneNumber: '0790easdas7dsdfd76175', + photoUrl: 'photo', + }; + const res = await request(app).put('/user/update').send(updateUser); + expect(res.status).toBe(201); + expect(res.body).toEqual({ + status: 'success', + data: { + code: 201, + message: 'User Profile has successfully been updated', + }, + }); + } + }); +}); diff --git a/src/__test__/test-assets/DatabaseCleanup.ts b/src/__test__/test-assets/DatabaseCleanup.ts new file mode 100644 index 0000000..ec40ee6 --- /dev/null +++ b/src/__test__/test-assets/DatabaseCleanup.ts @@ -0,0 +1,48 @@ + +import { Transaction } from '../../entities/transaction'; +import { Cart } from "../../entities/Cart"; +import { CartItem } from "../../entities/CartItem"; +import { Order } from "../../entities/Order"; +import { OrderItem } from "../../entities/OrderItem"; +import { wishList } from "../../entities/wishList"; +import { getConnection } from 'typeorm'; +import { Product } from '../../entities/Product'; +import { Category } from '../../entities/Category'; +import { Coupon } from '../../entities/coupon'; +import { User } from '../../entities/User'; +import { server } from '../..'; + +export const cleanDatabase = async () => { + const connection = getConnection(); + + // Delete from child tables first + await connection.getRepository(Transaction).delete({}); + await connection.getRepository(Coupon).delete({}); + await connection.getRepository(OrderItem).delete({}); + await connection.getRepository(Order).delete({}); + await connection.getRepository(CartItem).delete({}); + await connection.getRepository(Cart).delete({}); + await connection.getRepository(wishList).delete({}); + + // Many-to-Many relations + // Clear junction table entries before deleting products and categories + await connection.createQueryRunner().query('DELETE FROM product_categories_category'); + + await connection.getRepository(Product).delete({}); + await connection.getRepository(Category).delete({}); + + // Coupons (if related to Orders or Users) + + // Finally, delete from parent table + await connection.getRepository(User).delete({}); + + await connection.close(); + server.close(); +}; + +// Execute the clean-up function +cleanDatabase().then(() => { + console.log('Database cleaned'); +}).catch(error => { + console.error('Error cleaning database:', error); +}); diff --git a/src/__test__/test-assets/photo1.png b/src/__test__/test-assets/photo1.png new file mode 100644 index 0000000000000000000000000000000000000000..14aacbc37b4dbf9aae0e544691b42b2260cbb896 GIT binary patch literal 42194 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 o33UFjar!{uKkk~5G5?vkYe*5fUODT` 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 { + await createConnection(); +}); + +afterAll(async () => { + await cleanDatabase() + server.close(); +}); + +describe('start2FAProcess', () => { + it('should register a new user', async () => { + // Arrange + const newUser = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe1@example.com', + password: 'password', + gender: 'Male', + phoneNumber: '0789412421', + userType: 'Buyer', + }; + + // Act + const res = await request(app).post('/user/register').send(newUser); + // Assert + expect(res.status).toBe(201); + expect(res.body).toEqual({ + status: 'success', + data: { + code: 201, + message: 'User registered successfully', + }, + }); + }); + + it('should return 400 if not sent email in body on enabling 2fa', async () => { + const data = {}; + + const res = await request(app).post('/user/enable-2fa').send(data); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ status: 'error', message: 'Please provide your email' }); + }); + + it('should return 404 if user not exist on enabling 2fa', async () => { + const data = { + email: 'example@gmail.com', + }; + + const res = await request(app).post('/user/enable-2fa').send(data); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ status: 'error', message: 'User not found' }); + }); + + it('should enable two-factor authentication', async () => { + const data = { + email: 'john.doe1@example.com', + }; + + const res = await request(app).post('/user/enable-2fa').send(data); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'success', message: 'Two factor authentication enabled successfully' }); + }); + + it('should return 400 if not sent email in body on disabling 2fa', async () => { + const data = {}; + + const res = await request(app).post('/user/disable-2fa').send(data); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ status: 'error', message: 'Please provide your email' }); + }); + + it('should return 404 if user not exist on disabling 2fa', async () => { + const data = { + email: 'example@gmail.com', + }; + + const res = await request(app).post('/user/disable-2fa').send(data); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ status: 'error', message: 'User not found' }); + }); + + it('should disable two-factor authentication', async () => { + const data = { + email: 'john.doe1@example.com', + }; + + const res = await request(app).post('/user/disable-2fa').send(data); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'success', message: 'Two factor authentication disabled successfully' }); + }); + + it('should return 400 if not sent email and otp in body on verifying OTP', async () => { + const data = {}; + + const res = await request(app).post('/user/verify-otp').send(data); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ status: 'error', message: 'Please provide an email and OTP code' }); + }); + + it('should return 403 if OTP is invalid', async () => { + const email = 'john.doe1@example.com'; + const user = await getRepository(User).findOneBy({ email }); + if (user) { + user.twoFactorEnabled = true; + user.twoFactorCode = '123456'; + await getRepository(User).save(user); + } + + const data = { + email: 'john.doe1@example.com', + otp: '123457', + }; + + const res = await request(app).post('/user/verify-otp').send(data); + expect(res.status).toBe(403); + expect(res.body).toEqual({ status: 'error', message: 'Invalid authentication code' }); + }); + + it('should return 403 if user not exist on verifying OTP', async () => { + const data = { + email: 'john.doe10@example.com', + otp: '123457', + }; + + const res = await request(app).post('/user/verify-otp').send(data); + expect(res.status).toBe(403); + expect(res.body).toEqual({ status: 'error', message: 'User not found' }); + }); + + it('should return 403 if OTP is expired', async () => { + const email = 'john.doe1@example.com'; + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + if (user) { + user.twoFactorEnabled = true; + user.twoFactorCode = '123456'; + user.twoFactorCodeExpiresAt = new Date(Date.now() - 10 * 60 * 1000); + await getRepository(User).save(user); + } + + const data = { + email: email, + otp: '123456', + }; + + const res = await request(app).post('/user/verify-otp').send(data); + expect(res.status).toBe(403); + expect(res.body).toEqual({ status: 'error', message: 'Authentication code expired' }); + if (user) { + await userRepository.remove(user); + } + }); + + it('should return 400 if not sent email in body on resending OTP', async () => { + const data = {}; + + const res = await request(app).post('/user/resend-otp').send(data); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ status: 'error', message: 'Please provide an email' }); + }); + + it('should return 404 if user not exist on resending OTP', async () => { + const data = { + email: 'john.doe10@example.com', + }; + + const res = await request(app).post('/user/resend-otp').send(data); + expect(res.status).toBe(404); + expect(res.body).toEqual({ status: 'error', message: 'Incorrect email' }); + }); + + it('should resend OTP', async () => { + const newUser = { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe187@example.com', + password: 'password', + gender: 'Male', + phoneNumber: '0785044398', + userType: 'Buyer', + }; + + // Act + const resp = await request(app).post('/user/register').send(newUser); + if (!resp) { + console.log('Error creating user in resend otp test case'); + } + const data = { + email: 'john.doe187@example.com', + }; + + const res = await request(app).post('/user/resend-otp').send(data); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'success', data: { message: 'OTP sent successfully' } }); + }, 20000); + + it('should return 400 if not sent email in body on login', async () => { + const data = {}; + + const res = await request(app).post('/user/login').send(data); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ status: 'error', message: 'Please provide an email and password' }); + }, 1000); + + it('should return 404 if user not exist on login', async () => { + const data = { + email: 'john.doe10@example.com', + password: 'password', + }; + + const res = await request(app).post('/user/login').send(data); + expect(res.status).toBe(404); + expect(res.body).toEqual({ status: 'error', message: 'Incorrect email or password' }); + }, 10000); +}); \ No newline at end of file diff --git a/src/__test__/userStatus.test.ts b/src/__test__/userStatus.test.ts new file mode 100644 index 0000000..132134f --- /dev/null +++ b/src/__test__/userStatus.test.ts @@ -0,0 +1,151 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getRepository } from 'typeorm'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const adminUserId = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +beforeAll(async () => { + const connection = await dbConnection(); + + const userRepository = connection?.getRepository(User); + + const adminUser = new User(); + adminUser.id = adminUserId; + adminUser.firstName = 'remjsa'; + adminUser.lastName = 'djkchd'; + adminUser.email = 'admin.kjaxs@example.com'; + adminUser.password = 'passwordadmin'; + adminUser.userType = 'Admin'; + adminUser.gender = 'Male'; + adminUser.phoneNumber = '126380996347'; + adminUser.photoUrl = 'https://example.com/photo.jpg'; + + await userRepository?.save(adminUser); + + adminUser.role = 'ADMIN'; + adminUser.verified = true; + await userRepository?.save(adminUser); +}); + +afterAll(async () => { + await cleanDatabase() + + server.close(); +}); + +const data = { + id: adminUserId, + email: 'admin.kjaxs@example.com', +}; + +const testUser = { + firstName: 'John', + lastName: 'Doe', + email: 'checki@testing.com', + password: 'password', + gender: 'Male', + phoneNumber: '4223567890', + photoUrl: 'https://example.com/photo.jpg', +}; + +describe('POST /user/deactivate', () => { + it('should deactivate a user', async () => { + await request(app).post('/user/register').send(testUser); + + const token = jwt.sign(data, jwtSecretKey); + + const response = await request(app) + .post(`/user/deactivate`) + .set('Cookie', `token=${token}`) + .send({ email: `${testUser.email}` }); + expect(response.status).toBe(200); + expect(response.body.message).toBe('User deactivated successfully'); + }, 10000); + + it('should return 404 when email is not submitted', async () => { + const token = jwt.sign(data, jwtSecretKey); + const response = await request(app).post(`/user/deactivate`).set('Cookie', `token=${token}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Email is needed'); + }); + it('should return message "User is already suspended" if user is already suspended', async () => { + const token = jwt.sign(data, jwtSecretKey); + const response = await request(app) + .post(`/user/deactivate`) + .set('Cookie', `token=${token}`) + .send({ email: `${testUser.email}` }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('User is already suspended'); + }); + + it('should return 404 if user not found when deactivating', async () => { + const token = jwt.sign(data, jwtSecretKey); + const response = await request(app) + .post(`/user/deactivate`) + .set('Cookie', `token=${token}`) + .send({ email: 'nonexistent@example.com' }); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('User not found'); + }); +}); + +describe('POST /user/activate', () => { + it('should activate a user', async () => { + const token = jwt.sign(data, jwtSecretKey); + + const response = await request(app) + .post(`/user/activate`) + .set('Cookie', `token=${token}`) + .send({ email: `${testUser.email}` }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('User activated successfully'); + }, 10000); + + it('should return 404 when email is not submitted', async () => { + const token = jwt.sign(data, jwtSecretKey); + const response = await request(app).post(`/user/activate`).set('Cookie', `token=${token}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Email is needed'); + }); + + it('should return message "User is already active" if user is already active', async () => { + const token = jwt.sign(data, jwtSecretKey); + const response = await request(app) + .post(`/user/activate`) + .set('Cookie', `token=${token}`) + .send({ email: `${testUser.email}` }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('User is already active'); + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { email: testUser.email } }); + if (user) { + await userRepository.remove(user); + } + }); + + it('should return 404 if user not found when activating', async () => { + const token = jwt.sign(data, jwtSecretKey); + const response = await request(app) + .post('/user/activate') + .set('Cookie', `token=${token}`) + .send({ email: 'nonexistent@example.com' }); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('User not found'); + }); +}); diff --git a/src/__test__/vendorProduct.test.ts b/src/__test__/vendorProduct.test.ts new file mode 100644 index 0000000..f90d80d --- /dev/null +++ b/src/__test__/vendorProduct.test.ts @@ -0,0 +1,473 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { User, UserInterface } from '../entities/User'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const vendor1Id = uuid(); +const vendor2Id = uuid(); +const buyer1Id = uuid(); +const product1Id = uuid(); +const product2Id = uuid(); +const catId = uuid(); + +const jwtSecretKey = process.env.JWT_SECRET || ''; + +const getAccessToken = (id: string, email: string) => { + return jwt.sign( + { + id: id, + email: email, + }, + jwtSecretKey + ); +}; + +const sampleVendor1: UserInterface = { + id: vendor1Id, + firstName: 'vendor1', + lastName: 'user', + email: 'vendor1@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; +const sampleBuyer1: UserInterface = { + id: buyer1Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyer1@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; + +const sampleVendor2: UserInterface = { + id: vendor2Id, + firstName: 'vendor2', + lastName: 'user', + email: 'vendor2@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '1638099634', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +const sampleCat = { + id: catId, + name: 'accessories', +}; + +const sampleProduct1 = { + id: product1Id, + name: 'test product', + description: 'amazing product', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +const sampleProduct2 = { + id: product2Id, + name: 'test product2', + description: 'amazing product2', + images: ['photo1.jpg', 'photo2.jpg', 'photo3.jpg', 'photo4.jpg', 'photo5.jpg'], + newPrice: 200, + quantity: 10, + vendor: sampleVendor1, + categories: [sampleCat], +}; + +beforeAll(async () => { + const connection = await dbConnection(); + + const categoryRepository = connection?.getRepository(Category); + await categoryRepository?.save({ ...sampleCat }); + + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleVendor1 }); + await userRepository?.save({ ...sampleVendor2 }); + await userRepository?.save({ ...sampleBuyer1 }); + + const productRepository = connection?.getRepository(Product); + await productRepository?.save({ ...sampleProduct1 }); + await productRepository?.save({ ...sampleProduct2 }); +}); + +afterAll(async () => { + await cleanDatabase() + + server.close(); +}); + +describe('Vendor product management tests', () => { + describe('Creating new product', () => { + it('should create new product', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(201); + expect(response.body.data.product).toBeDefined; + }, 60000); + + it('return an error if the number of product images exceeds 6', async () => { + const response = await request(app) + .post(`/product/`) + .field('name', 'test-product-images') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Product cannot have more than 6 images'); + }); + + it('should not create new product it already exist', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(409); + }); + + it('should not create new product, if there are missing field data', async () => { + const response = await request(app) + .post('/product') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + + it('should not create new product, images are not at least more than 1', async () => { + const response = await request(app) + .post('/product') + .field('name', 'test-product-image') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('categories', sampleCat.name) + .attach('images', `${__dirname}/test-assets/photo1.png`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + }); + + describe('Updating existing product', () => { + it('return error, if there are missing field data', async () => { + const response = await request(app) + .put(`/product/${sampleProduct2.id}`) + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + + it('return error, if product do not exist', async () => { + const response = await request(app) + .put(`/product/${uuid()}`) + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Product not found'); + }); + + it('return an error if the number of product images exceeds 6', async () => { + const response = await request(app) + .put(`/product/${sampleProduct2.id}`) + .field('name', 'test product3') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('quantity', 0) + .field('expirationDate', '10-2-2023') + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Product cannot have more than 6 images'); + }); + + it('should update the product', async () => { + const response = await request(app) + .put(`/product/${sampleProduct2.id}`) + .field('name', 'test product3 updated') + .field('description', 'amazing product3') + .field('newPrice', 200) + .field('oldPrice', 100) + .field('quantity', 10) + .field('expirationDate', '10-2-2023') + .field('categories', 'tech') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }); + }); + + describe('Retrieving all vendor product', () => { + it('should retrieve all product belong to logged vendor', async () => { + const response = await request(app) + .get('/product/collection') + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.products).toBeDefined; + }); + + it('should not return any product for a vendor with zero product in stock', async () => { + const response = await request(app) + .get(`/product/collection`) + .set('Authorization', `Bearer ${getAccessToken(vendor2Id, sampleVendor2.email)}`); + + expect(response.status).toBe(200); + expect(response.body.products).toBeUndefined; + }); + + it('should not return any product for incorrect syntax of input', async () => { + const response = await request(app) + .get(`/product/collection?page=sdfsd`) + .set('Authorization', `Bearer ${getAccessToken(vendor2Id, sampleVendor2.email)}`); + + expect(response.status).toBe(400); + expect(response.body).toBeUndefined; + }); + }); + + describe('Retrieving single vendor product', () => { + it('should retrieve single product for the user', async () => { + const response = await request(app) + .get(`/product/collection/${product1Id}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.product).toBeDefined; + }); + + it('should not return any product if product1Id do not exist', async () => { + const response = await request(app) + .get(`/product/collection/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.product).toBeUndefined; + }); + + it('should not return any product for incorrect syntax of input', async () => { + const response = await request(app) + .get(`/product/collection/id`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.product).toBeUndefined; + }); + }); + + describe('Removing product image', () => { + it('should remove one image', async () => { + const response = await request(app) + .delete(`/product/images/${sampleProduct1.id}`) + .send({ + image: sampleProduct1.images[2], + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }); + + it('return error, if no image to remove provided', async () => { + const response = await request(app) + .delete(`/product/images/${sampleProduct1.id}`) + + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Please provide an image to remove'); + }); + + it("return error, if product doesn't exist", async () => { + const response = await request(app) + .delete(`/product/images/${uuid()}`) + .send({ + image: sampleProduct1.images[2], + }) + + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Product not found'); + }); + + it('return error, if product has only 2 images', async () => { + const response = await request(app) + .delete(`/product/images/${sampleProduct1.id}`) + .send({ + image: sampleProduct1.images[0], + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('Product must have at least two image'); + }); + + it("return error, if image to remove deosn't exist", async () => { + const response = await request(app) + .delete(`/product/images/${sampleProduct1.id}`) + .send({ + image: 'image', + }) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + expect(response.body.error).toBe('Image not found'); + }); + }); + + describe('Deleting a vendor product', () => { + it('should delete a product for the vendor', async () => { + const response = await request(app) + .delete(`/product/${product2Id}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(200); + }); + + it('should return error for non existing products', async () => { + const response = await request(app) + .delete(`/product/${uuid()}`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(404); + }); + + it('should return error for invalid input syntax', async () => { + const response = await request(app) + .delete(`/product/product2Id`) + .set('Authorization', `Bearer ${getAccessToken(vendor1Id, sampleVendor1.email)}`); + + expect(response.status).toBe(400); + }); + }); + + describe('Retrieving recommended products', () => { + it('should retrieve products', async () => { + const response = await request(app) + .get('/product/recommended') + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + expect(response.status).toBe(200); + expect(response.body.data).toBeDefined; + }); + + it('should not return any product for a vendor with zero product in stock', async () => { + const response = await request(app) + .get(`/product/recommended`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(200); + expect(response.body.products).toBeUndefined; + }); + + it('should not return any product for incorrect syntax of input', async () => { + const response = await request(app) + .get(`/product/recommended?page=sdfsd`) + .set('Authorization', `Bearer ${getAccessToken(buyer1Id, sampleBuyer1.email)}`); + + expect(response.status).toBe(400); + expect(response.body).toBeUndefined; + }); + }); + + describe('List all products service', () => { + it('should return all products for a given category', async () => { + const response = await request(app).get('/product/all'); + + expect(response.status).toBe(200); + expect(response.body.data.products).toBeDefined(); + }); + + it('should return no products for a non-existent category', async () => { + const response = await request(app) + .get('/product/all') + .query({ page: 1, limit: 10, category: 'nonexistentcategory' }); + + expect(response.status).toBe(200); + expect(response.body.data.products).toBeUndefined(); + }); + + it('should return an error for invalid input syntax', async () => { + const response = await request(app) + .get('/product/all') + .query({ page: 'invalid', limit: 'limit', category: 'technology' }); + + expect(response.status).toBe(400); + }); + }); +}); diff --git a/src/__test__/wishList.test.ts b/src/__test__/wishList.test.ts new file mode 100644 index 0000000..aac072d --- /dev/null +++ b/src/__test__/wishList.test.ts @@ -0,0 +1,204 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { app, server } from '../index'; +import { getConnection } from 'typeorm'; +import { dbConnection } from '../startups/dbConnection'; +import { v4 as uuid } from 'uuid'; +import { Product } from '../entities/Product'; +import { Category } from '../entities/Category'; +import { wishList } from '../entities/wishList'; +import { User, UserInterface } from '../entities/User'; +import { cleanDatabase } from './test-assets/DatabaseCleanup'; + +const buyer1Id = uuid(); +const buyer2Id = uuid(); +let product1Id: string; +let product2Id: string; +const catId = uuid(); +const vendor2Id = uuid(); + +const sampleBuyer1: UserInterface = { + id: buyer1Id, + firstName: 'buyer1', + lastName: 'user', + email: 'buyer1@example.com', + password: 'password', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '126380996347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; +const sampleBuyer2: UserInterface = { + id: buyer2Id, + firstName: 'buyer2', + lastName: 'use', + email: 'buyer2@example.com', + password: 'passwo', + userType: 'Buyer', + gender: 'Male', + phoneNumber: '1638099347', + photoUrl: 'https://example.com/photo.jpg', + role: 'BUYER', +}; +const sampleVendor1: UserInterface = { + id: vendor2Id, + firstName: 'vendor1', + lastName: 'user', + email: 'vendor11@example.com', + password: 'password', + userType: 'Vendor', + gender: 'Male', + phoneNumber: '12638090347', + photoUrl: 'https://example.com/photo.jpg', + role: 'VENDOR', +}; + +let productInWishList: number; + +beforeAll(async () => { + const connection = await dbConnection(); + const userRepository = connection?.getRepository(User); + await userRepository?.save({ ...sampleBuyer1 }); + await userRepository?.save({ ...sampleBuyer2 }); + await userRepository?.save({ ...sampleVendor1 }); +}); + +afterAll(async () => { + await cleanDatabase() + server.close(); +}); +const data1 = { + id: buyer1Id, + email: sampleBuyer1.email, +}; +const data2 = { + id: buyer2Id, + email: sampleBuyer2.email, +}; +const vendorData = { + id: vendor2Id, + email: sampleVendor1.email, +}; + +const jwtSecretKey = process.env.JWT_SECRET || ''; +describe('Wish list management tests', () => { + describe('Add product to wish list', () => { + it('should return 404 when product is not found', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).post(`/wish-list/add/${uuid()}`).set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(404); + expect(response.body).toEqual({ message: 'Product not found' }); + }); + + it('should add a new product to wish list', async () => { + const vendorToken = jwt.sign(vendorData, jwtSecretKey); + const prod1Response = await request(app) + .post('/product') + .field('name', 'test product12') + .field('description', 'amazing product3') + .field('newPrice', 2000) + .field('quantity', 10) + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${vendorToken}`); + + product1Id = prod1Response.body.data.product.id; + + const prod2Response = await request(app) + .post('/product') + .field('name', 'more product2') + .field('description', 'food product3') + .field('newPrice', 2000) + .field('quantity', 10) + .field('categories', 'technology') + .field('categories', 'sample') + .attach('images', `${__dirname}/test-assets/photo1.png`) + .attach('images', `${__dirname}/test-assets/photo2.webp`) + .set('Authorization', `Bearer ${vendorToken}`); + + product2Id = prod2Response.body.data.product.id; + + const token = jwt.sign(data1, jwtSecretKey); + const response1 = await request(app).post(`/wish-list/add/${product1Id}`).set('Authorization', `Bearer ${token}`); + expect(response1.status).toBe(201); + expect(response1.body.data.message).toBe('Product Added to wish list'); + productInWishList = response1.body.data.wishlistAdded.id; + + await request(app).post(`/wish-list/add/${product2Id}`).set('Authorization', `Bearer ${token}`); + }); + + it('should tell if there is the product is already in the wish list', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).post(`/wish-list/add/${product1Id}`).set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(401); + expect(response.body.data.message).toBe('Product Already in the wish list'); + }); + it('should return 500 when the ID is not valid', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app) + .post(`/wish-list/add/kjwxq-wbjk2-2bwqs-21`) + .set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(500); + }); + }); + + describe('Get products in wishList', () => { + it('Returns 404 when buyer has no product in wish list', async () => { + const token = jwt.sign(data2, jwtSecretKey); + const response = await request(app).get('/wish-list').set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(404); + expect(response.body.message).toBe('No products in wish list'); + }); + + it('Returns products in the wish list for a buyer ', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).get('/wish-list').set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(200); + expect(response.body.message).toBe('Products retrieved'); + }); + }); + + describe('Remove a product from wish lsit', () => { + it('should return 404 when product is not found in wish list', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).delete(`/wish-list/delete/${28}`).set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(404); + expect(response.body.message).toBe('Product not found in wish list'); + }); + + it('should delete a product from wish list', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app) + .delete(`/wish-list/delete/${productInWishList}`) + .set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(200); + expect(response.body.message).toBe('Product removed from wish list'); + }); + it('should return 500 when the ID is not valid', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app) + .delete(`/wish-list/delete/kjwxq-wbjk2-2bwqs-21`) + .set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(500); + }); + }); + + describe('Clear all products in wish for a user', () => { + it('Returns 404 when buyer has no product in wish list', async () => { + const token = jwt.sign(data2, jwtSecretKey); + const response = await request(app).delete('/wish-list/clearAll').set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(404); + expect(response.body.message).toBe('No products in wish list'); + }); + + it('should delete all products for a nuyer in wish list', async () => { + const token = jwt.sign(data1, jwtSecretKey); + const response = await request(app).delete('/wish-list/clearAll').set('Authorization', `Bearer ${token}`); + expect(response.status).toBe(200); + expect(response.body.message).toBe('All products removed successfully'); + }); + }); +}); diff --git a/src/configs/index.ts b/src/configs/index.ts new file mode 100644 index 0000000..4a6e728 --- /dev/null +++ b/src/configs/index.ts @@ -0,0 +1 @@ +// export all configs diff --git a/src/configs/swagger.ts b/src/configs/swagger.ts new file mode 100644 index 0000000..239d43a --- /dev/null +++ b/src/configs/swagger.ts @@ -0,0 +1,30 @@ +import swaggerJSDoc from 'swagger-jsdoc'; +import { getSwaggerServer } from '../startups/getSwaggerServer'; + +const swaggerServer = getSwaggerServer(); + +const options: swaggerJSDoc.Options = { + definition: { + openapi: '3.0.1', + info: { + title: 'Knights E-commerce API Documentation', + version: '1.0.0', + description: 'knights E-commerce - Backend API', + }, + servers: [{ url: swaggerServer }], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + }, + apis: ['./src/docs/*.ts', './src/docs/*.yml'], +}; + +const swaggerSpec = swaggerJSDoc(options); + +export default swaggerSpec; diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts new file mode 100644 index 0000000..a66cecf --- /dev/null +++ b/src/controllers/authController.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { + userVerificationService, + userRegistrationService, + userLoginService, + userEnableTwoFactorAuth, + userDisableTwoFactorAuth, + userValidateOTP, + userResendOtpService, + logoutService, +} from '../services'; +import { userPasswordResetService } from '../services/userServices/userPasswordResetService'; +import { sendPasswordResetLinkService } from '../services/userServices/sendResetPasswordLinkService'; +import { activateUserService } from '../services/updateUserStatus/activateUserService'; +import { deactivateUserService } from '../services/updateUserStatus/deactivateUserService'; +import { userProfileUpdateServices } from '../services/userServices/userProfileUpdateServices'; + +export const userRegistration = async (req: Request, res: Response) => { + await userRegistrationService(req, res); +}; + +export const userVerification = async (req: Request, res: Response) => { + await userVerificationService(req, res); +}; + +export const login = async (req: Request, res: Response) => { + await userLoginService(req, res); +}; + +export const enable2FA = async (req: Request, res: Response) => { + await userEnableTwoFactorAuth(req, res); +}; + +export const disable2FA = async (req: Request, res: Response) => { + await userDisableTwoFactorAuth(req, res); +}; + +export const verifyOTP = async (req: Request, res: Response) => { + await userValidateOTP(req, res); +}; + +export const resendOTP = async (req: Request, res: Response) => { + await userResendOtpService(req, res); +}; + +export const sampleAPI = async (req: Request, res: Response) => { + res.status(200).json({ message: 'Token is valid' }); +}; +export const userPasswordReset = async (req: Request, res: Response) => { + await userPasswordResetService(req, res); +}; +export const sendPasswordResetLink = async (req: Request, res: Response) => { + await sendPasswordResetLinkService(req, res); +}; + +export async function activateUser (req: Request, res: Response) { + await activateUserService(req, res); +} + +export async function disactivateUser (req: Request, res: Response) { + await deactivateUserService(req, res); +} + +export const logout = async (req: Request, res: Response) => { + await logoutService(req, res); +}; +export const userProfileUpdate = async (req: Request, res: Response) => { + await userProfileUpdateServices(req, res); +}; diff --git a/src/controllers/cartController.ts b/src/controllers/cartController.ts new file mode 100644 index 0000000..3411103 --- /dev/null +++ b/src/controllers/cartController.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express'; +import { createCartService, readCartService, removeProductInCartService, clearCartService } from '../services'; + +export const createCart = async (req: Request, res: Response) => { + await createCartService(req, res); +}; + +export const readCart = async (req: Request, res: Response) => { + await readCartService(req, res); +}; + +export const removeProductInCart = async (req: Request, res: Response) => { + await removeProductInCartService(req, res); +}; + +export const clearCart = async (req: Request, res: Response) => { + await clearCartService(req, res); +}; diff --git a/src/controllers/couponController.ts b/src/controllers/couponController.ts new file mode 100644 index 0000000..dd7e19f --- /dev/null +++ b/src/controllers/couponController.ts @@ -0,0 +1,31 @@ +import { Request, Response } from 'express'; +import { createCouponService } from '../services/couponServices/createCouponService'; +import { updateCouponService } from '../services/couponServices/updateService'; +import { deleteCouponService } from '../services/couponServices/deleteCoupon'; +import { accessAllCouponService } from '../services/couponServices/accessAllCoupon'; +import { readCouponService } from '../services/couponServices/readCoupon'; +import { buyerApplyCouponService } from '../services/couponServices/buyerApplyCoupon' + +export const createCoupon = async (req: Request, res: Response) => { + await createCouponService(req, res); +}; + +export const updateCoupon = async (req: Request, res: Response) => { + await updateCouponService(req, res); +}; + +export const deleteCoupon = async (req: Request, res: Response) => { + await deleteCouponService(req, res); +}; + +export const accessAllCoupon = async (req: Request, res: Response) => { + await accessAllCouponService(req, res); +}; + +export const readCoupon = async (req: Request, res: Response) => { + await readCouponService(req, res); +}; + +export const buyerApplyCoupon = async (req: Request, res: Response) => { + await buyerApplyCouponService(req, res); +}; \ No newline at end of file diff --git a/src/controllers/index.ts b/src/controllers/index.ts new file mode 100644 index 0000000..3cbb7dc --- /dev/null +++ b/src/controllers/index.ts @@ -0,0 +1,3 @@ +export * from './authController'; +export * from './productController'; +export * from './orderController'; \ No newline at end of file diff --git a/src/controllers/manageStatusController.ts b/src/controllers/manageStatusController.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/controllers/orderController.ts b/src/controllers/orderController.ts new file mode 100644 index 0000000..5a5db97 --- /dev/null +++ b/src/controllers/orderController.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express'; +import { createOrderService } from '../services/orderServices/createOrder'; +import { getOrdersService } from '../services/orderServices/getOrderService'; +import { updateOrderService } from '../services/orderServices/updateOrderService'; +import { getTransactionHistoryService } from '../services/orderServices/getOrderTransactionHistory'; + +export const createOrder = async (req: Request, res: Response) => { + await createOrderService(req, res); +}; +export const getOrders = async (req: Request, res: Response) => { + await getOrdersService(req, res); +}; +export const updateOrder = async (req: Request, res: Response) => { + await updateOrderService(req, res); +}; +export const getOrdersHistory = async (req: Request, res: Response) => { + await getTransactionHistoryService(req, res); +}; \ No newline at end of file diff --git a/src/controllers/productController.ts b/src/controllers/productController.ts new file mode 100644 index 0000000..11caddd --- /dev/null +++ b/src/controllers/productController.ts @@ -0,0 +1,81 @@ +import { Request, Response } from 'express'; +import { + + createProductService, + + updateProductService, + + removeProductImageService, + + readProductService, + readProductsService, + + deleteProductService, + + getRecommendedProductsService, + productStatusServices, + viewSingleProduct, + searchProductService + +, + listAllProductsService} +from '../services'; + + +export const readProduct = async (req: Request, res: Response) => { + await readProductService(req, res); +}; + +export const readProducts = async (req: Request, res: Response) => { + await readProductsService(req, res); +}; + +export const createProduct = async (req: Request, res: Response) => { + await createProductService(req, res); +}; + +export const updateProduct = async (req: Request, res: Response) => { + await updateProductService(req, res); +}; + +export const removeProductImage = async (req: Request, res: Response) => { + await removeProductImageService(req, res); +}; + +export const deleteProduct = async (req: Request, res: Response) => { + await deleteProductService(req, res); +}; + +export const getRecommendedProducts = async (req: Request, res: Response) => { + await getRecommendedProductsService(req, res); +}; + + +export const listAllProducts = async (req: Request, res: Response) => { + await listAllProductsService(req, res); +};export const productStatus = async (req: Request, res: Response) => { + await productStatusServices(req, res); +}; +export const singleProduct = async (req: Request, res: Response) => { + await viewSingleProduct(req, res); +}; +export const searchProduct = async (req: Request, res: Response) => { + const { name, sortBy, sortOrder, page, limit } = req.query; + + try { + const searchParams = { + name: name as string, + sortBy: sortBy as string, + sortOrder: sortOrder as 'ASC' | 'DESC', + page: parseInt(page as string, 10) || 1, + limit: parseInt(limit as string, 10) || 10, + }; + + const result = await searchProductService(searchParams); + + res.json(result); + } catch (error) { + console.error('Error searching products:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}; diff --git a/src/controllers/wishListController.ts b/src/controllers/wishListController.ts new file mode 100644 index 0000000..e0cd1bd --- /dev/null +++ b/src/controllers/wishListController.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import{ + addProductService, + getProductsService, + removeProductService, + clearAllProductService +} from '../services' + +export const wishlistAddProduct = async (req: Request, res: Response) => { + await addProductService(req, res); + }; + + export const wishlistRemoveProduct = async (req: Request, res:Response) => { + await removeProductService(req, res); + } + + export const wishlistGetProducts = async (req: Request, res:Response) => { + await getProductsService(req, res); + } + + export const wishlistClearAllProducts = async (req: Request, res:Response) => { + await clearAllProductService(req, res); + } \ No newline at end of file diff --git a/src/docs/authDocs.yml b/src/docs/authDocs.yml new file mode 100644 index 0000000..fa69d1e --- /dev/null +++ b/src/docs/authDocs.yml @@ -0,0 +1,163 @@ +/user/login: + post: + tags: + - Auth + summary: Login a user + description: Login a user with email and password + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + password: + type: string + format: password + required: + - email + - password + responses: + '200': + description: user logged in successfully, complete 2fa process + '400': + description: provide an email and password, Invalid email or password, email not verified, account suspended + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: user not found + '500': + description: Internal server error + +/user/enable-2fa: + post: + tags: + - Auth + summary: Enable 2fa + description: Enable 2fa for a user + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + required: + - email + responses: + '200': + description: 2fa enabled successfully + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: user not found + '500': + description: Internal server error + +/user/disable-2fa: + post: + tags: + - Auth + summary: Disable 2fa + description: Disable 2fa for a user + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + + required: + - email + responses: + '200': + description: 2fa disabled successfully + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: user not found + '500': + description: Internal server error + +/user/verify-otp: + post: + tags: + - Auth + summary: Verify OTP + description: Verify OTP for 2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + otp: + type: string + required: + - email + - otp + responses: + '200': + description: OTP verified successfully + '400': + description: Please provide an email and OTP code + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: user not found + '500': + description: Internal server error + +/user/resend-otp: + post: + tags: + - Auth + summary: Resend OTP + description: Resend OTP for 2fa + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + required: + - email + responses: + '200': + description: OTP resent successfully + '400': + description: Please provide an email + '401': + description: Unauthorized + '403': + description: Forbidden + '404': + description: user not found + '500': + description: Internal server error diff --git a/src/docs/cartDocs.yml b/src/docs/cartDocs.yml new file mode 100644 index 0000000..9962129 --- /dev/null +++ b/src/docs/cartDocs.yml @@ -0,0 +1,103 @@ +/cart: + get: + tags: + - Cart + summary: Get all products in cart + description: Return all products in cart for either guest user or authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Return all products in cart for the user or return empty cart if no product available + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + + post: + tags: + - Cart + summary: Add product to cart or updates its quantity + description: Add product to cart or updates its quantity for either guest user or authenticated user + security: + - bearerAuth: [] + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + productId: + type: string + description: The id of product + quantity: + type: integer + description: The quantity of product + responses: + '200': + description: Product added to cart + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + + delete: + tags: + - Cart + summary: Clear entire cart + description: Clears entire cart for either guest user or authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Cart cleared + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/cart/{id}: + delete: + tags: + - Cart + summary: Remove cart item from cart + description: Remove cart item from cart for either guest user or authenticated user + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of cart item + responses: + '200': + description: Product removed from cart + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error diff --git a/src/docs/couponDocs.yml b/src/docs/couponDocs.yml new file mode 100644 index 0000000..fb0a49a --- /dev/null +++ b/src/docs/couponDocs.yml @@ -0,0 +1,217 @@ +/coupons/vendor/:id/access-coupons: + get: + tags: + - Vendor discount coupon management + summary: List all coupons + description: Return all coupons for the logged user + security: + - bearerAuth: [] + responses: + '200': + description: Return all coupons + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/coupons/vendor/:id/checkout/:code: + get: + tags: + - Vendor discount coupon management + summary: Get a single coupon + description: Return a coupon based on the provided code + security: + - bearerAuth: [] + parameters: + - in: path + name: code + schema: + type: string + required: true + description: The code of the coupon + responses: + '200': + description: Return info for the coupon + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found + '500': + description: Internal server error + +/coupons/coupons/vendor/:id: + post: + tags: + - Vendor discount coupon management + summary: Creates a new coupon + security: + - bearerAuth: [] + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + discountType: + type: string + discountRate: + type: number + maxUsageLimit: + type: number + quantity: + type: number + product: + type: string + expirationDate: + type: string + format: date + required: + - code + - discountType + - maxUsageLimit + - product + responses: + '201': + description: Successfully added the coupon + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found + '500': + description: Internal server error + +/coupons/coupons/vendor/:id/update-coupon/:code: + put: + tags: + - Vendor discount coupon management + summary: Update a coupon + security: + - bearerAuth: [] + parameters: + - in: path + name: code + schema: + type: string + required: true + description: The code of the coupon + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + discountType: + type: string + discountRate: + type: number + maxUsageLimit: + type: number + quantity: + type: number + product: + type: string + expirationDate: + type: string + format: date + responses: + '200': + description: Successfully updated the coupon + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found + '500': + description: Internal server error + +/coupons/vendor/:id/checkout/delete: + delete: + tags: + - Vendor discount coupon management + summary: Delete a coupon + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The ID of the vendor + - in: query + name: code + schema: + type: string + required: true + description: The code of the coupon + responses: + '200': + description: Successfully deleted the coupon + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found + '500': + description: Internal server error + +/coupons/apply: + post: + tags: + - Buyer Coupon Discount Management + summary: Give discount according to coupon code + description: Buyer gets discount on a product when all the checks pass + security: + - bearerAuth: [] + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + couponCode: + type: string + required: + - couponCode + responses: + '200': + description: Successfully Got Discount + '400': + description: Bad Request (Syntax error, No coupon code provide, Coupon is expired, Coupon Discount Ended,etc) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Coupon not found, No cart or product with that coupon is not in cart + '500': + description: Internal server error diff --git a/src/docs/orderDocs.yml b/src/docs/orderDocs.yml new file mode 100644 index 0000000..fcb620e --- /dev/null +++ b/src/docs/orderDocs.yml @@ -0,0 +1,108 @@ +paths: + /product/orders: + post: + tags: + - Order + summary: Make an order + description: Create a new order for the authenticated user + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + address: + type: object + properties: + country: + type: string + description: The country of the shipping address + city: + type: string + description: The city of the shipping address + street: + type: string + description: The street address + required: + - address + responses: + '201': + description: Order created successfully + '400': + description: Bad Request + '401': + description: Unauthorized + '500': + description: Internal Server Error + + /product/client/orders: + get: + tags: + - Order + summary: Get all orders + description: Retrieve all orders for the authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Orders retrieved successfully + '401': + description: Unauthorized + '500': + description: Internal Server Error + + /product/orders/history: + get: + tags: + - Order + summary: Get transaction history + description: Retrieve transaction history for the authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Transaction history retrieved successfully + '401': + description: Unauthorized + '500': + description: Internal Server Error + + /product/client/orders/:orderId: + put: + tags: + - Order + summary: Update order status + description: Update the status of a specific order for the authenticated user + security: + - bearerAuth: [] + parameters: + - in: path + name: orderId + schema: + type: string + required: true + description: The ID of the order + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + orderStatus: + type: string + description: The new status of the order + responses: + '200': + description: Order updated successfully + '400': + description: Bad Request + '401': + description: Unauthorized + '404': + description: Order not found + '500': + description: Internal Server Error diff --git a/src/docs/swaggerDark.css b/src/docs/swaggerDark.css new file mode 100644 index 0000000..0423113 --- /dev/null +++ b/src/docs/swaggerDark.css @@ -0,0 +1,1729 @@ +@media only screen and (prefers-color-scheme: dark) { + a { + color: #8c8cfa; + } + + ::-webkit-scrollbar-track-piece { + background-color: rgba(255, 255, 255, 0.2) !important; + } + + ::-webkit-scrollbar-track { + background-color: rgba(255, 255, 255, 0.3) !important; + } + + ::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.5) !important; + } + + embed[type='application/pdf'] { + filter: invert(90%); + } + + html { + background: #1f1f1f !important; + box-sizing: border-box; + filter: contrast(100%) brightness(100%) saturate(100%); + overflow-y: scroll; + } + + body { + background: #1f1f1f; + background-color: #1f1f1f; + background-image: none !important; + } + + button, + input, + select, + textarea { + background-color: #1f1f1f; + color: #bfbfbf; + } + + font, + html { + color: #bfbfbf; + } + + .swagger-ui, + .swagger-ui section h3 { + color: #b5bac9; + } + + .swagger-ui a { + background-color: transparent; + } + + .swagger-ui mark { + background-color: #664b00; + color: #bfbfbf; + } + + .swagger-ui legend { + color: inherit; + } + + .swagger-ui .debug * { + outline: #e6da99 solid 1px; + } + + .swagger-ui .debug-white * { + outline: #fff solid 1px; + } + + .swagger-ui .debug-black * { + outline: #bfbfbf solid 1px; + } + + .swagger-ui .debug-grid { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) + 0 0; + } + + .swagger-ui .debug-grid-16 { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC) + 0 0; + } + + .swagger-ui .debug-grid-8-solid { + background: url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z) + 0 0 #1c1c21; + } + + .swagger-ui .debug-grid-16-solid { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=) + 0 0 #1c1c21; + } + + .swagger-ui .b--black { + border-color: #000; + } + + .swagger-ui .b--near-black { + border-color: #121212; + } + + .swagger-ui .b--dark-gray { + border-color: #333; + } + + .swagger-ui .b--mid-gray { + border-color: #545454; + } + + .swagger-ui .b--gray { + border-color: #787878; + } + + .swagger-ui .b--silver { + border-color: #999; + } + + .swagger-ui .b--light-silver { + border-color: #6e6e6e; + } + + .swagger-ui .b--moon-gray { + border-color: #4d4d4d; + } + + .swagger-ui .b--light-gray { + border-color: #2b2b2b; + } + + .swagger-ui .b--near-white { + border-color: #242424; + } + + .swagger-ui .b--white { + border-color: #1c1c21; + } + + .swagger-ui .b--white-90 { + border-color: rgba(28, 28, 33, 0.9); + } + + .swagger-ui .b--white-80 { + border-color: rgba(28, 28, 33, 0.8); + } + + .swagger-ui .b--white-70 { + border-color: rgba(28, 28, 33, 0.7); + } + + .swagger-ui .b--white-60 { + border-color: rgba(28, 28, 33, 0.6); + } + + .swagger-ui .b--white-50 { + border-color: rgba(28, 28, 33, 0.5); + } + + .swagger-ui .b--white-40 { + border-color: rgba(28, 28, 33, 0.4); + } + + .swagger-ui .b--white-30 { + border-color: rgba(28, 28, 33, 0.3); + } + + .swagger-ui .b--white-20 { + border-color: rgba(28, 28, 33, 0.2); + } + + .swagger-ui .b--white-10 { + border-color: rgba(28, 28, 33, 0.1); + } + + .swagger-ui .b--white-05 { + border-color: rgba(28, 28, 33, 0.05); + } + + .swagger-ui .b--white-025 { + border-color: rgba(28, 28, 33, 0.024); + } + + .swagger-ui .b--white-0125 { + border-color: rgba(28, 28, 33, 0.01); + } + + .swagger-ui .b--black-90 { + border-color: rgba(0, 0, 0, 0.9); + } + + .swagger-ui .b--black-80 { + border-color: rgba(0, 0, 0, 0.8); + } + + .swagger-ui .b--black-70 { + border-color: rgba(0, 0, 0, 0.7); + } + + .swagger-ui .b--black-60 { + border-color: rgba(0, 0, 0, 0.6); + } + + .swagger-ui .b--black-50 { + border-color: rgba(0, 0, 0, 0.5); + } + + .swagger-ui .b--black-40 { + border-color: rgba(0, 0, 0, 0.4); + } + + .swagger-ui .b--black-30 { + border-color: rgba(0, 0, 0, 0.3); + } + + .swagger-ui .b--black-20 { + border-color: rgba(0, 0, 0, 0.2); + } + + .swagger-ui .b--black-10 { + border-color: rgba(0, 0, 0, 0.1); + } + + .swagger-ui .b--black-05 { + border-color: rgba(0, 0, 0, 0.05); + } + + .swagger-ui .b--black-025 { + border-color: rgba(0, 0, 0, 0.024); + } + + .swagger-ui .b--black-0125 { + border-color: rgba(0, 0, 0, 0.01); + } + + .swagger-ui .b--dark-red { + border-color: #bc2f36; + } + + .swagger-ui .b--red { + border-color: #c83932; + } + + .swagger-ui .b--light-red { + border-color: #ab3c2b; + } + + .swagger-ui .b--orange { + border-color: #cc6e33; + } + + .swagger-ui .b--purple { + border-color: #5e2ca5; + } + + .swagger-ui .b--light-purple { + border-color: #672caf; + } + + .swagger-ui .b--dark-pink { + border-color: #ab2b81; + } + + .swagger-ui .b--hot-pink { + border-color: #c03086; + } + + .swagger-ui .b--pink { + border-color: #8f2464; + } + + .swagger-ui .b--light-pink { + border-color: #721d4d; + } + + .swagger-ui .b--dark-green { + border-color: #1c6e50; + } + + .swagger-ui .b--green { + border-color: #279b70; + } + + .swagger-ui .b--light-green { + border-color: #228762; + } + + .swagger-ui .b--navy { + border-color: #0d1d35; + } + + .swagger-ui .b--dark-blue { + border-color: #20497e; + } + + .swagger-ui .b--blue { + border-color: #4380d0; + } + + .swagger-ui .b--light-blue { + border-color: #20517e; + } + + .swagger-ui .b--lightest-blue { + border-color: #143a52; + } + + .swagger-ui .b--washed-blue { + border-color: #0c312d; + } + + .swagger-ui .b--washed-green { + border-color: #0f3d2c; + } + + .swagger-ui .b--washed-red { + border-color: #411010; + } + + .swagger-ui .b--transparent { + border-color: transparent; + } + + .swagger-ui .b--gold, + .swagger-ui .b--light-yellow, + .swagger-ui .b--washed-yellow, + .swagger-ui .b--yellow { + border-color: #664b00; + } + + .swagger-ui .shadow-1 { + box-shadow: rgba(0, 0, 0, 0.2) 0 0 4px 2px; + } + + .swagger-ui .shadow-2 { + box-shadow: rgba(0, 0, 0, 0.2) 0 0 8px 2px; + } + + .swagger-ui .shadow-3 { + box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 4px 2px; + } + + .swagger-ui .shadow-4 { + box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 8px 0; + } + + .swagger-ui .shadow-5 { + box-shadow: rgba(0, 0, 0, 0.2) 4px 4px 8px 0; + } + + @media screen and (min-width: 30em) { + .swagger-ui .shadow-1-ns { + box-shadow: rgba(0, 0, 0, 0.2) 0 0 4px 2px; + } + + .swagger-ui .shadow-2-ns { + box-shadow: rgba(0, 0, 0, 0.2) 0 0 8px 2px; + } + + .swagger-ui .shadow-3-ns { + box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 4px 2px; + } + + .swagger-ui .shadow-4-ns { + box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 8px 0; + } + + .swagger-ui .shadow-5-ns { + box-shadow: rgba(0, 0, 0, 0.2) 4px 4px 8px 0; + } + } + + @media screen and (max-width: 60em) and (min-width: 30em) { + .swagger-ui .shadow-1-m { + box-shadow: rgba(0, 0, 0, 0.2) 0 0 4px 2px; + } + + .swagger-ui .shadow-2-m { + box-shadow: rgba(0, 0, 0, 0.2) 0 0 8px 2px; + } + + .swagger-ui .shadow-3-m { + box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 4px 2px; + } + + .swagger-ui .shadow-4-m { + box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 8px 0; + } + + .swagger-ui .shadow-5-m { + box-shadow: rgba(0, 0, 0, 0.2) 4px 4px 8px 0; + } + } + + @media screen and (min-width: 60em) { + .swagger-ui .shadow-1-l { + box-shadow: rgba(0, 0, 0, 0.2) 0 0 4px 2px; + } + + .swagger-ui .shadow-2-l { + box-shadow: rgba(0, 0, 0, 0.2) 0 0 8px 2px; + } + + .swagger-ui .shadow-3-l { + box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 4px 2px; + } + + .swagger-ui .shadow-4-l { + box-shadow: rgba(0, 0, 0, 0.2) 2px 2px 8px 0; + } + + .swagger-ui .shadow-5-l { + box-shadow: rgba(0, 0, 0, 0.2) 4px 4px 8px 0; + } + } + + .swagger-ui .black-05 { + color: rgba(191, 191, 191, 0.05); + } + + .swagger-ui .bg-black-05 { + background-color: rgba(0, 0, 0, 0.05); + } + + .swagger-ui .black-90, + .swagger-ui .hover-black-90:focus, + .swagger-ui .hover-black-90:hover { + color: rgba(191, 191, 191, 0.9); + } + + .swagger-ui .black-80, + .swagger-ui .hover-black-80:focus, + .swagger-ui .hover-black-80:hover { + color: rgba(191, 191, 191, 0.8); + } + + .swagger-ui .black-70, + .swagger-ui .hover-black-70:focus, + .swagger-ui .hover-black-70:hover { + color: rgba(191, 191, 191, 0.7); + } + + .swagger-ui .black-60, + .swagger-ui .hover-black-60:focus, + .swagger-ui .hover-black-60:hover { + color: rgba(191, 191, 191, 0.6); + } + + .swagger-ui .black-50, + .swagger-ui .hover-black-50:focus, + .swagger-ui .hover-black-50:hover { + color: rgba(191, 191, 191, 0.5); + } + + .swagger-ui .black-40, + .swagger-ui .hover-black-40:focus, + .swagger-ui .hover-black-40:hover { + color: rgba(191, 191, 191, 0.4); + } + + .swagger-ui .black-30, + .swagger-ui .hover-black-30:focus, + .swagger-ui .hover-black-30:hover { + color: rgba(191, 191, 191, 0.3); + } + + .swagger-ui .black-20, + .swagger-ui .hover-black-20:focus, + .swagger-ui .hover-black-20:hover { + color: rgba(191, 191, 191, 0.2); + } + + .swagger-ui .black-10, + .swagger-ui .hover-black-10:focus, + .swagger-ui .hover-black-10:hover { + color: rgba(191, 191, 191, 0.1); + } + + .swagger-ui .hover-white-90:focus, + .swagger-ui .hover-white-90:hover, + .swagger-ui .white-90 { + color: rgba(255, 255, 255, 0.9); + } + + .swagger-ui .hover-white-80:focus, + .swagger-ui .hover-white-80:hover, + .swagger-ui .white-80 { + color: rgba(255, 255, 255, 0.8); + } + + .swagger-ui .hover-white-70:focus, + .swagger-ui .hover-white-70:hover, + .swagger-ui .white-70 { + color: rgba(255, 255, 255, 0.7); + } + + .swagger-ui .hover-white-60:focus, + .swagger-ui .hover-white-60:hover, + .swagger-ui .white-60 { + color: rgba(255, 255, 255, 0.6); + } + + .swagger-ui .hover-white-50:focus, + .swagger-ui .hover-white-50:hover, + .swagger-ui .white-50 { + color: rgba(255, 255, 255, 0.5); + } + + .swagger-ui .hover-white-40:focus, + .swagger-ui .hover-white-40:hover, + .swagger-ui .white-40 { + color: rgba(255, 255, 255, 0.4); + } + + .swagger-ui .hover-white-30:focus, + .swagger-ui .hover-white-30:hover, + .swagger-ui .white-30 { + color: rgba(255, 255, 255, 0.3); + } + + .swagger-ui .hover-white-20:focus, + .swagger-ui .hover-white-20:hover, + .swagger-ui .white-20 { + color: rgba(255, 255, 255, 0.2); + } + + .swagger-ui .hover-white-10:focus, + .swagger-ui .hover-white-10:hover, + .swagger-ui .white-10 { + color: rgba(255, 255, 255, 0.1); + } + + .swagger-ui .hover-moon-gray:focus, + .swagger-ui .hover-moon-gray:hover, + .swagger-ui .moon-gray { + color: #ccc; + } + + .swagger-ui .hover-light-gray:focus, + .swagger-ui .hover-light-gray:hover, + .swagger-ui .light-gray { + color: #ededed; + } + + .swagger-ui .hover-near-white:focus, + .swagger-ui .hover-near-white:hover, + .swagger-ui .near-white { + color: #f5f5f5; + } + + .swagger-ui .dark-red, + .swagger-ui .hover-dark-red:focus, + .swagger-ui .hover-dark-red:hover { + color: #e6999d; + } + + .swagger-ui .hover-red:focus, + .swagger-ui .hover-red:hover, + .swagger-ui .red { + color: #e69d99; + } + + .swagger-ui .hover-light-red:focus, + .swagger-ui .hover-light-red:hover, + .swagger-ui .light-red { + color: #e6a399; + } + + .swagger-ui .hover-orange:focus, + .swagger-ui .hover-orange:hover, + .swagger-ui .orange { + color: #e6b699; + } + + .swagger-ui .gold, + .swagger-ui .hover-gold:focus, + .swagger-ui .hover-gold:hover { + color: #e6d099; + } + + .swagger-ui .hover-yellow:focus, + .swagger-ui .hover-yellow:hover, + .swagger-ui .yellow { + color: #e6da99; + } + + .swagger-ui .hover-light-yellow:focus, + .swagger-ui .hover-light-yellow:hover, + .swagger-ui .light-yellow { + color: #ede6b6; + } + + .swagger-ui .hover-purple:focus, + .swagger-ui .hover-purple:hover, + .swagger-ui .purple { + color: #b99ae4; + } + + .swagger-ui .hover-light-purple:focus, + .swagger-ui .hover-light-purple:hover, + .swagger-ui .light-purple { + color: #bb99e6; + } + + .swagger-ui .dark-pink, + .swagger-ui .hover-dark-pink:focus, + .swagger-ui .hover-dark-pink:hover { + color: #e699cc; + } + + .swagger-ui .hot-pink, + .swagger-ui .hover-hot-pink:focus, + .swagger-ui .hover-hot-pink:hover, + .swagger-ui .hover-pink:focus, + .swagger-ui .hover-pink:hover, + .swagger-ui .pink { + color: #e699c7; + } + + .swagger-ui .hover-light-pink:focus, + .swagger-ui .hover-light-pink:hover, + .swagger-ui .light-pink { + color: #edb6d5; + } + + .swagger-ui .dark-green, + .swagger-ui .green, + .swagger-ui .hover-dark-green:focus, + .swagger-ui .hover-dark-green:hover, + .swagger-ui .hover-green:focus, + .swagger-ui .hover-green:hover { + color: #99e6c9; + } + + .swagger-ui .hover-light-green:focus, + .swagger-ui .hover-light-green:hover, + .swagger-ui .light-green { + color: #a1e8ce; + } + + .swagger-ui .hover-navy:focus, + .swagger-ui .hover-navy:hover, + .swagger-ui .navy { + color: #99b8e6; + } + + .swagger-ui .blue, + .swagger-ui .dark-blue, + .swagger-ui .hover-blue:focus, + .swagger-ui .hover-blue:hover, + .swagger-ui .hover-dark-blue:focus, + .swagger-ui .hover-dark-blue:hover { + color: #99bae6; + } + + .swagger-ui .hover-light-blue:focus, + .swagger-ui .hover-light-blue:hover, + .swagger-ui .light-blue { + color: #a9cbea; + } + + .swagger-ui .hover-lightest-blue:focus, + .swagger-ui .hover-lightest-blue:hover, + .swagger-ui .lightest-blue { + color: #d6e9f5; + } + + .swagger-ui .hover-washed-blue:focus, + .swagger-ui .hover-washed-blue:hover, + .swagger-ui .washed-blue { + color: #f7fdfc; + } + + .swagger-ui .hover-washed-green:focus, + .swagger-ui .hover-washed-green:hover, + .swagger-ui .washed-green { + color: #ebfaf4; + } + + .swagger-ui .hover-washed-yellow:focus, + .swagger-ui .hover-washed-yellow:hover, + .swagger-ui .washed-yellow { + color: #fbf9ef; + } + + .swagger-ui .hover-washed-red:focus, + .swagger-ui .hover-washed-red:hover, + .swagger-ui .washed-red { + color: #f9e7e7; + } + + .swagger-ui .color-inherit, + .swagger-ui .hover-inherit:focus, + .swagger-ui .hover-inherit:hover { + color: inherit; + } + + .swagger-ui .bg-black-90, + .swagger-ui .hover-bg-black-90:focus, + .swagger-ui .hover-bg-black-90:hover { + background-color: rgba(0, 0, 0, 0.9); + } + + .swagger-ui .bg-black-80, + .swagger-ui .hover-bg-black-80:focus, + .swagger-ui .hover-bg-black-80:hover { + background-color: rgba(0, 0, 0, 0.8); + } + + .swagger-ui .bg-black-70, + .swagger-ui .hover-bg-black-70:focus, + .swagger-ui .hover-bg-black-70:hover { + background-color: rgba(0, 0, 0, 0.7); + } + + .swagger-ui .bg-black-60, + .swagger-ui .hover-bg-black-60:focus, + .swagger-ui .hover-bg-black-60:hover { + background-color: rgba(0, 0, 0, 0.6); + } + + .swagger-ui .bg-black-50, + .swagger-ui .hover-bg-black-50:focus, + .swagger-ui .hover-bg-black-50:hover { + background-color: rgba(0, 0, 0, 0.5); + } + + .swagger-ui .bg-black-40, + .swagger-ui .hover-bg-black-40:focus, + .swagger-ui .hover-bg-black-40:hover { + background-color: rgba(0, 0, 0, 0.4); + } + + .swagger-ui .bg-black-30, + .swagger-ui .hover-bg-black-30:focus, + .swagger-ui .hover-bg-black-30:hover { + background-color: rgba(0, 0, 0, 0.3); + } + + .swagger-ui .bg-black-20, + .swagger-ui .hover-bg-black-20:focus, + .swagger-ui .hover-bg-black-20:hover { + background-color: rgba(0, 0, 0, 0.2); + } + + .swagger-ui .bg-white-90, + .swagger-ui .hover-bg-white-90:focus, + .swagger-ui .hover-bg-white-90:hover { + background-color: rgba(28, 28, 33, 0.9); + } + + .swagger-ui .bg-white-80, + .swagger-ui .hover-bg-white-80:focus, + .swagger-ui .hover-bg-white-80:hover { + background-color: rgba(28, 28, 33, 0.8); + } + + .swagger-ui .bg-white-70, + .swagger-ui .hover-bg-white-70:focus, + .swagger-ui .hover-bg-white-70:hover { + background-color: rgba(28, 28, 33, 0.7); + } + + .swagger-ui .bg-white-60, + .swagger-ui .hover-bg-white-60:focus, + .swagger-ui .hover-bg-white-60:hover { + background-color: rgba(28, 28, 33, 0.6); + } + + .swagger-ui .bg-white-50, + .swagger-ui .hover-bg-white-50:focus, + .swagger-ui .hover-bg-white-50:hover { + background-color: rgba(28, 28, 33, 0.5); + } + + .swagger-ui .bg-white-40, + .swagger-ui .hover-bg-white-40:focus, + .swagger-ui .hover-bg-white-40:hover { + background-color: rgba(28, 28, 33, 0.4); + } + + .swagger-ui .bg-white-30, + .swagger-ui .hover-bg-white-30:focus, + .swagger-ui .hover-bg-white-30:hover { + background-color: rgba(28, 28, 33, 0.3); + } + + .swagger-ui .bg-white-20, + .swagger-ui .hover-bg-white-20:focus, + .swagger-ui .hover-bg-white-20:hover { + background-color: rgba(28, 28, 33, 0.2); + } + + .swagger-ui .bg-black, + .swagger-ui .hover-bg-black:focus, + .swagger-ui .hover-bg-black:hover { + background-color: #000; + } + + .swagger-ui .bg-near-black, + .swagger-ui .hover-bg-near-black:focus, + .swagger-ui .hover-bg-near-black:hover { + background-color: #121212; + } + + .swagger-ui .bg-dark-gray, + .swagger-ui .hover-bg-dark-gray:focus, + .swagger-ui .hover-bg-dark-gray:hover { + background-color: #333; + } + + .swagger-ui .bg-mid-gray, + .swagger-ui .hover-bg-mid-gray:focus, + .swagger-ui .hover-bg-mid-gray:hover { + background-color: #545454; + } + + .swagger-ui .bg-gray, + .swagger-ui .hover-bg-gray:focus, + .swagger-ui .hover-bg-gray:hover { + background-color: #787878; + } + + .swagger-ui .bg-silver, + .swagger-ui .hover-bg-silver:focus, + .swagger-ui .hover-bg-silver:hover { + background-color: #999; + } + + .swagger-ui .bg-white, + .swagger-ui .hover-bg-white:focus, + .swagger-ui .hover-bg-white:hover { + background-color: #1c1c21; + } + + .swagger-ui .bg-transparent, + .swagger-ui .hover-bg-transparent:focus, + .swagger-ui .hover-bg-transparent:hover { + background-color: transparent; + } + + .swagger-ui .bg-dark-red, + .swagger-ui .hover-bg-dark-red:focus, + .swagger-ui .hover-bg-dark-red:hover { + background-color: #bc2f36; + } + + .swagger-ui .bg-red, + .swagger-ui .hover-bg-red:focus, + .swagger-ui .hover-bg-red:hover { + background-color: #c83932; + } + + .swagger-ui .bg-light-red, + .swagger-ui .hover-bg-light-red:focus, + .swagger-ui .hover-bg-light-red:hover { + background-color: #ab3c2b; + } + + .swagger-ui .bg-orange, + .swagger-ui .hover-bg-orange:focus, + .swagger-ui .hover-bg-orange:hover { + background-color: #cc6e33; + } + + .swagger-ui .bg-gold, + .swagger-ui .bg-light-yellow, + .swagger-ui .bg-washed-yellow, + .swagger-ui .bg-yellow, + .swagger-ui .hover-bg-gold:focus, + .swagger-ui .hover-bg-gold:hover, + .swagger-ui .hover-bg-light-yellow:focus, + .swagger-ui .hover-bg-light-yellow:hover, + .swagger-ui .hover-bg-washed-yellow:focus, + .swagger-ui .hover-bg-washed-yellow:hover, + .swagger-ui .hover-bg-yellow:focus, + .swagger-ui .hover-bg-yellow:hover { + background-color: #664b00; + } + + .swagger-ui .bg-purple, + .swagger-ui .hover-bg-purple:focus, + .swagger-ui .hover-bg-purple:hover { + background-color: #5e2ca5; + } + + .swagger-ui .bg-light-purple, + .swagger-ui .hover-bg-light-purple:focus, + .swagger-ui .hover-bg-light-purple:hover { + background-color: #672caf; + } + + .swagger-ui .bg-dark-pink, + .swagger-ui .hover-bg-dark-pink:focus, + .swagger-ui .hover-bg-dark-pink:hover { + background-color: #ab2b81; + } + + .swagger-ui .bg-hot-pink, + .swagger-ui .hover-bg-hot-pink:focus, + .swagger-ui .hover-bg-hot-pink:hover { + background-color: #c03086; + } + + .swagger-ui .bg-pink, + .swagger-ui .hover-bg-pink:focus, + .swagger-ui .hover-bg-pink:hover { + background-color: #8f2464; + } + + .swagger-ui .bg-light-pink, + .swagger-ui .hover-bg-light-pink:focus, + .swagger-ui .hover-bg-light-pink:hover { + background-color: #721d4d; + } + + .swagger-ui .bg-dark-green, + .swagger-ui .hover-bg-dark-green:focus, + .swagger-ui .hover-bg-dark-green:hover { + background-color: #1c6e50; + } + + .swagger-ui .bg-green, + .swagger-ui .hover-bg-green:focus, + .swagger-ui .hover-bg-green:hover { + background-color: #279b70; + } + + .swagger-ui .bg-light-green, + .swagger-ui .hover-bg-light-green:focus, + .swagger-ui .hover-bg-light-green:hover { + background-color: #228762; + } + + .swagger-ui .bg-navy, + .swagger-ui .hover-bg-navy:focus, + .swagger-ui .hover-bg-navy:hover { + background-color: #0d1d35; + } + + .swagger-ui .bg-dark-blue, + .swagger-ui .hover-bg-dark-blue:focus, + .swagger-ui .hover-bg-dark-blue:hover { + background-color: #20497e; + } + + .swagger-ui .bg-blue, + .swagger-ui .hover-bg-blue:focus, + .swagger-ui .hover-bg-blue:hover { + background-color: #4380d0; + } + + .swagger-ui .bg-light-blue, + .swagger-ui .hover-bg-light-blue:focus, + .swagger-ui .hover-bg-light-blue:hover { + background-color: #20517e; + } + + .swagger-ui .bg-lightest-blue, + .swagger-ui .hover-bg-lightest-blue:focus, + .swagger-ui .hover-bg-lightest-blue:hover { + background-color: #143a52; + } + + .swagger-ui .bg-washed-blue, + .swagger-ui .hover-bg-washed-blue:focus, + .swagger-ui .hover-bg-washed-blue:hover { + background-color: #0c312d; + } + + .swagger-ui .bg-washed-green, + .swagger-ui .hover-bg-washed-green:focus, + .swagger-ui .hover-bg-washed-green:hover { + background-color: #0f3d2c; + } + + .swagger-ui .bg-washed-red, + .swagger-ui .hover-bg-washed-red:focus, + .swagger-ui .hover-bg-washed-red:hover { + background-color: #411010; + } + + .swagger-ui .bg-inherit, + .swagger-ui .hover-bg-inherit:focus, + .swagger-ui .hover-bg-inherit:hover { + background-color: inherit; + } + + .swagger-ui .shadow-hover { + transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) 0s; + } + + .swagger-ui .shadow-hover::after { + border-radius: inherit; + box-shadow: rgba(0, 0, 0, 0.2) 0 0 16px 2px; + content: ''; + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + transition: opacity 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) 0s; + width: 100%; + z-index: -1; + } + + .swagger-ui .bg-animate, + .swagger-ui .bg-animate:focus, + .swagger-ui .bg-animate:hover { + transition: background-color 0.15s ease-in-out 0s; + } + + .swagger-ui .nested-links a { + color: #99bae6; + transition: color 0.15s ease-in 0s; + } + + .swagger-ui .nested-links a:focus, + .swagger-ui .nested-links a:hover { + color: #a9cbea; + transition: color 0.15s ease-in 0s; + } + + .swagger-ui .opblock-tag { + border-bottom: 1px solid rgba(58, 64, 80, 0.3); + color: #b5bac9; + transition: all 0.2s ease 0s; + } + + .swagger-ui .opblock-tag svg, + .swagger-ui section.models h4 svg { + transition: all 0.4s ease 0s; + } + + .swagger-ui .opblock { + border: 1px solid #000; + border-radius: 4px; + box-shadow: rgba(0, 0, 0, 0.19) 0 0 3px; + margin: 0 0 15px; + } + + .swagger-ui .opblock .tab-header .tab-item.active h4 span::after { + background: gray; + } + + .swagger-ui .opblock.is-open .opblock-summary { + border-bottom: 1px solid #000; + } + + .swagger-ui .opblock .opblock-section-header { + background: rgba(28, 28, 33, 0.8); + box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px; + } + + .swagger-ui .opblock .opblock-section-header > label > span { + padding: 0 10px 0 0; + } + + .swagger-ui .opblock .opblock-summary-method { + background: #000; + color: #fff; + text-shadow: rgba(0, 0, 0, 0.1) 0 1px 0; + } + + .swagger-ui .opblock.opblock-post { + background: rgba(72, 203, 144, 0.1); + border-color: #48cb90; + } + + .swagger-ui .opblock.opblock-post .opblock-summary-method, + .swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span::after { + background: #48cb90; + } + + .swagger-ui .opblock.opblock-post .opblock-summary { + border-color: #48cb90; + } + + .swagger-ui .opblock.opblock-put { + background: rgba(213, 157, 88, 0.1); + border-color: #d59d58; + } + + .swagger-ui .opblock.opblock-put .opblock-summary-method, + .swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span::after { + background: #d59d58; + } + + .swagger-ui .opblock.opblock-put .opblock-summary { + border-color: #d59d58; + } + + .swagger-ui .opblock.opblock-delete { + background: rgba(200, 50, 50, 0.1); + border-color: #c83232; + } + + .swagger-ui .opblock.opblock-delete .opblock-summary-method, + .swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span::after { + background: #c83232; + } + + .swagger-ui .opblock.opblock-delete .opblock-summary { + border-color: #c83232; + } + + .swagger-ui .opblock.opblock-get { + background: rgba(42, 105, 167, 0.1); + border-color: #2a69a7; + } + + .swagger-ui .opblock.opblock-get .opblock-summary-method, + .swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span::after { + background: #2a69a7; + } + + .swagger-ui .opblock.opblock-get .opblock-summary { + border-color: #2a69a7; + } + + .swagger-ui .opblock.opblock-patch { + background: rgba(92, 214, 188, 0.1); + border-color: #5cd6bc; + } + + .swagger-ui .opblock.opblock-patch .opblock-summary-method, + .swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span::after { + background: #5cd6bc; + } + + .swagger-ui .opblock.opblock-patch .opblock-summary { + border-color: #5cd6bc; + } + + .swagger-ui .opblock.opblock-head { + background: rgba(140, 63, 207, 0.1); + border-color: #8c3fcf; + } + + .swagger-ui .opblock.opblock-head .opblock-summary-method, + .swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span::after { + background: #8c3fcf; + } + + .swagger-ui .opblock.opblock-head .opblock-summary { + border-color: #8c3fcf; + } + + .swagger-ui .opblock.opblock-options { + background: rgba(36, 89, 143, 0.1); + border-color: #24598f; + } + + .swagger-ui .opblock.opblock-options .opblock-summary-method, + .swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span::after { + background: #24598f; + } + + .swagger-ui .opblock.opblock-options .opblock-summary { + border-color: #24598f; + } + + .swagger-ui .opblock.opblock-deprecated { + background: rgba(46, 46, 46, 0.1); + border-color: #2e2e2e; + opacity: 0.6; + } + + .swagger-ui .opblock.opblock-deprecated .opblock-summary-method, + .swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span::after { + background: #2e2e2e; + } + + .swagger-ui .opblock.opblock-deprecated .opblock-summary { + border-color: #2e2e2e; + } + + .swagger-ui .filter .operation-filter-input { + border: 2px solid #2b3446; + } + + .swagger-ui .tab li:first-of-type::after { + background: rgba(0, 0, 0, 0.2); + } + + .swagger-ui .download-contents { + background: #7c8192; + color: #fff; + } + + .swagger-ui .scheme-container { + background: #1c1c21; + box-shadow: rgba(0, 0, 0, 0.15) 0 1px 2px 0; + } + + .swagger-ui .loading-container .loading::before { + animation: + 1s linear 0s infinite normal none running rotation, + 0.5s ease 0s 1 normal none running opacity; + border-color: rgba(0, 0, 0, 0.6) rgba(84, 84, 84, 0.1) rgba(84, 84, 84, 0.1); + } + + .swagger-ui .response-control-media-type--accept-controller select { + border-color: #196619; + } + + .swagger-ui .response-control-media-type__accept-message { + color: #99e699; + } + + .swagger-ui .version-pragma__message code { + background-color: #3b3b3b; + } + + .swagger-ui .btn { + background: 0 0; + border: 2px solid gray; + box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px; + color: #b5bac9; + } + + .swagger-ui .btn:hover { + box-shadow: rgba(0, 0, 0, 0.3) 0 0 5px; + } + + .swagger-ui .btn.authorize, + .swagger-ui .btn.cancel { + background-color: transparent; + border-color: #a72a2a; + color: #e69999; + } + + .swagger-ui .btn.cancel:hover { + background-color: #a72a2a; + color: #fff; + } + + .swagger-ui .btn.authorize { + border-color: #48cb90; + color: #9ce3c3; + } + + .swagger-ui .btn.authorize svg { + fill: #9ce3c3; + } + + .btn.authorize.unlocked:hover { + background-color: #48cb90; + color: #fff; + } + + .btn.authorize.unlocked:hover svg { + fill: #fbfbfb; + } + + .swagger-ui .btn.execute { + background-color: #5892d5; + border-color: #5892d5; + color: #fff; + } + + .swagger-ui .copy-to-clipboard { + background: #7c8192; + } + + .swagger-ui .copy-to-clipboard button { + background: url('data:image/svg+xml;charset=utf-8,') + 50% center no-repeat; + } + + .swagger-ui select { + background: url('data:image/svg+xml;charset=utf-8,') + right 10px center/20px no-repeat #212121; + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQuc3ZnIgogICBpZD0ic3ZnNCIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMjAgMjAiPgogIDxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTEwIj4KICAgIDxyZGY6UkRGPgogICAgICA8Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+CiAgICAgICAgPGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+CiAgICAgICAgPGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPgogICAgICA8L2NjOldvcms+CiAgICA8L3JkZjpSREY+CiAgPC9tZXRhZGF0YT4KICA8ZGVmcwogICAgIGlkPSJkZWZzOCIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnNCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOndpbmRvdy15PSItOSIKICAgICBpbmtzY2FwZTp3aW5kb3cteD0iLTkiCiAgICAgaW5rc2NhcGU6Y3k9IjEwIgogICAgIGlua3NjYXBlOmN4PSIxMCIKICAgICBpbmtzY2FwZTp6b29tPSI0MS41IgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEzLjQxOCA3Ljg1OWEuNjk1LjY5NSAwIDAxLjk3OCAwIC42OC42OCAwIDAxMCAuOTY5bC0zLjkwOCAzLjgzYS42OTcuNjk3IDAgMDEtLjk3OSAwbC0zLjkwOC0zLjgzYS42OC42OCAwIDAxMC0uOTY5LjY5NS42OTUgMCAwMS45NzggMEwxMCAxMWwzLjQxOC0zLjE0MXoiIC8+Cjwvc3ZnPgo=) + right 10px center/20px no-repeat #1c1c21; + border: 2px solid #41444e; + } + + .swagger-ui select[multiple] { + background: #212121; + } + + .swagger-ui button.invalid, + .swagger-ui input[type='email'].invalid, + .swagger-ui input[type='file'].invalid, + .swagger-ui input[type='password'].invalid, + .swagger-ui input[type='search'].invalid, + .swagger-ui input[type='text'].invalid, + .swagger-ui select.invalid, + .swagger-ui textarea.invalid { + background: #390e0e; + border-color: #c83232; + } + + .swagger-ui input[type='email'], + .swagger-ui input[type='file'], + .swagger-ui input[type='password'], + .swagger-ui input[type='search'], + .swagger-ui input[type='text'], + .swagger-ui textarea { + background: #1c1c21; + border: 1px solid #404040; + } + + .swagger-ui textarea { + background: rgba(28, 28, 33, 0.8); + color: #b5bac9; + } + + .swagger-ui input[disabled], + .swagger-ui select[disabled] { + background-color: #1f1f1f; + color: #bfbfbf; + } + + .swagger-ui textarea[disabled] { + background-color: #41444e; + color: #fff; + } + + .swagger-ui select[disabled] { + border-color: #878787; + } + + .swagger-ui textarea:focus { + border: 2px solid #2a69a7; + } + + .swagger-ui .checkbox input[type='checkbox'] + label > .item { + background: #303030; + box-shadow: #303030 0 0 0 2px; + } + + .swagger-ui .checkbox input[type='checkbox']:checked + label > .item { + background: url('data:image/svg+xml;charset=utf-8,') + 50% center no-repeat #303030; + } + + .swagger-ui .dialog-ux .backdrop-ux { + background: rgba(0, 0, 0, 0.8); + } + + .swagger-ui .dialog-ux .modal-ux { + background: #1c1c21; + border: 1px solid #2e2e2e; + box-shadow: rgba(0, 0, 0, 0.2) 0 10px 30px 0; + } + + .swagger-ui .dialog-ux .modal-ux-header .close-modal { + background: 0 0; + } + + .swagger-ui .model .deprecated span, + .swagger-ui .model .deprecated td { + color: #bfbfbf !important; + } + + .swagger-ui .model-toggle::after { + background: url('data:image/svg+xml;charset=utf-8,') + 50% center/100% no-repeat; + } + + .swagger-ui .model-hint { + background: rgba(0, 0, 0, 0.7); + color: #ebebeb; + } + + .swagger-ui section.models { + border: 1px solid rgba(58, 64, 80, 0.3); + } + + .swagger-ui section.models.is-open h4 { + border-bottom: 1px solid rgba(58, 64, 80, 0.3); + } + + .swagger-ui section.models .model-container { + background: rgba(0, 0, 0, 0.05); + } + + .swagger-ui section.models .model-container:hover { + background: rgba(0, 0, 0, 0.07); + } + + .swagger-ui .model-box { + background: rgba(0, 0, 0, 0.1); + } + + .swagger-ui .prop-type { + color: #aaaad4; + } + + .swagger-ui table thead tr td, + .swagger-ui table thead tr th { + border-bottom: 1px solid rgba(58, 64, 80, 0.2); + color: #b5bac9; + } + + .swagger-ui .parameter__name.required::after { + color: rgba(230, 153, 153, 0.6); + } + + .swagger-ui .topbar .download-url-wrapper .select-label { + color: #f0f0f0; + } + + .swagger-ui .topbar .download-url-wrapper .download-url-button { + background: #63a040; + color: #fff; + } + + .swagger-ui .info .title small { + background: #7c8492; + } + + .swagger-ui .info .title small.version-stamp { + background-color: #7a9b27; + } + + .swagger-ui .auth-container .errors { + background-color: #350d0d; + color: #b5bac9; + } + + .swagger-ui .errors-wrapper { + background: rgba(200, 50, 50, 0.1); + border: 2px solid #c83232; + } + + .swagger-ui .markdown code, + .swagger-ui .renderedmarkdown code { + background: rgba(0, 0, 0, 0.05); + color: #c299e6; + } + + .swagger-ui .model-toggle:after { + background: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgaW5rc2NhcGU6dmVyc2lvbj0iMS4wICg0MDM1YTRmYjQ5LCAyMDIwLTA1LTAxKSIKICAgc29kaXBvZGk6ZG9jbmFtZT0iZG93bmxvYWQyLnN2ZyIKICAgaWQ9InN2ZzQiCiAgIHZlcnNpb249IjEuMSIKICAgaGVpZ2h0PSIyNCIKICAgd2lkdGg9IjI0Ij4KICA8bWV0YWRhdGEKICAgICBpZD0ibWV0YWRhdGExMCI+CiAgICA8cmRmOlJERj4KICAgICAgPGNjOldvcmsKICAgICAgICAgcmRmOmFib3V0PSIiPgogICAgICAgIDxkYzpmb3JtYXQ+aW1hZ2Uvc3ZnK3htbDwvZGM6Zm9ybWF0PgogICAgICAgIDxkYzp0eXBlCiAgICAgICAgICAgcmRmOnJlc291cmNlPSJodHRwOi8vcHVybC5vcmcvZGMvZGNtaXR5cGUvU3RpbGxJbWFnZSIgLz4KICAgICAgPC9jYzpXb3JrPgogICAgPC9yZGY6UkRGPgogIDwvbWV0YWRhdGE+CiAgPGRlZnMKICAgICBpZD0iZGVmczgiIC8+CiAgPHNvZGlwb2RpOm5hbWVkdmlldwogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzQiCiAgICAgaW5rc2NhcGU6d2luZG93LW1heGltaXplZD0iMSIKICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTkiCiAgICAgaW5rc2NhcGU6d2luZG93LXg9Ii05IgogICAgIGlua3NjYXBlOmN5PSIxMiIKICAgICBpbmtzY2FwZTpjeD0iMTIiCiAgICAgaW5rc2NhcGU6em9vbT0iMzQuNTgzMzMzIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpZD0ibmFtZWR2aWV3NiIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMDAxIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIKICAgICBpbmtzY2FwZTpwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGd1aWRldG9sZXJhbmNlPSIxMCIKICAgICBncmlkdG9sZXJhbmNlPSIxMCIKICAgICBvYmplY3R0b2xlcmFuY2U9IjEwIgogICAgIGJvcmRlcm9wYWNpdHk9IjEiCiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiCiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAvPgogIDxwYXRoCiAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZiIKICAgICBpZD0icGF0aDIiCiAgICAgZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiIgLz4KPC9zdmc+Cg==) + 50% no-repeat; + } + + /* arrows for each operation and request are now white */ + .arrow, + #large-arrow-up { + fill: #fff; + } + + #unlocked { + fill: #fff; + } + + ::-webkit-scrollbar-track { + background-color: #646464 !important; + } + + ::-webkit-scrollbar-thumb { + background-color: #242424 !important; + border: 2px solid #3e4346 !important; + } + + ::-webkit-scrollbar-button:vertical:start:decrement { + background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), + linear-gradient(230deg, #696969 40%, transparent 41%), linear-gradient(0deg, #696969 40%, transparent 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:vertical:end:increment { + background: linear-gradient(310deg, #696969 40%, transparent 41%), + linear-gradient(50deg, #696969 40%, transparent 41%), linear-gradient(180deg, #696969 40%, transparent 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:horizontal:end:increment { + background: linear-gradient(210deg, #696969 40%, transparent 41%), + linear-gradient(330deg, #696969 40%, transparent 41%), linear-gradient(90deg, #696969 30%, transparent 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:horizontal:start:decrement { + background: linear-gradient(30deg, #696969 40%, transparent 41%), + linear-gradient(150deg, #696969 40%, transparent 41%), linear-gradient(270deg, #696969 30%, transparent 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button, + ::-webkit-scrollbar-track-piece { + background-color: #3e4346 !important; + } + + .swagger-ui .black, + .swagger-ui .checkbox, + .swagger-ui .dark-gray, + .swagger-ui .download-url-wrapper .loading, + .swagger-ui .errors-wrapper .errors small, + .swagger-ui .fallback, + .swagger-ui .filter .loading, + .swagger-ui .gray, + .swagger-ui .hover-black:focus, + .swagger-ui .hover-black:hover, + .swagger-ui .hover-dark-gray:focus, + .swagger-ui .hover-dark-gray:hover, + .swagger-ui .hover-gray:focus, + .swagger-ui .hover-gray:hover, + .swagger-ui .hover-light-silver:focus, + .swagger-ui .hover-light-silver:hover, + .swagger-ui .hover-mid-gray:focus, + .swagger-ui .hover-mid-gray:hover, + .swagger-ui .hover-near-black:focus, + .swagger-ui .hover-near-black:hover, + .swagger-ui .hover-silver:focus, + .swagger-ui .hover-silver:hover, + .swagger-ui .light-silver, + .swagger-ui .markdown pre, + .swagger-ui .mid-gray, + .swagger-ui .model .property, + .swagger-ui .model .property.primitive, + .swagger-ui .model-title, + .swagger-ui .near-black, + .swagger-ui .parameter__extension, + .swagger-ui .parameter__in, + .swagger-ui .prop-format, + .swagger-ui .renderedmarkdown pre, + .swagger-ui .response-col_links .response-undocumented, + .swagger-ui .response-col_status .response-undocumented, + .swagger-ui .silver, + .swagger-ui section.models h4, + .swagger-ui section.models h5, + .swagger-ui span.token-not-formatted, + .swagger-ui span.token-string, + .swagger-ui table.headers .header-example, + .swagger-ui table.model tr.description, + .swagger-ui table.model tr.extension { + color: #bfbfbf; + } + + .swagger-ui .hover-white:focus, + .swagger-ui .hover-white:hover, + .swagger-ui .info .title small pre, + .swagger-ui .topbar a, + .swagger-ui .white { + color: #fff; + } + + .swagger-ui .bg-black-10, + .swagger-ui .hover-bg-black-10:focus, + .swagger-ui .hover-bg-black-10:hover, + .swagger-ui .stripe-dark:nth-child(2n + 1) { + background-color: rgba(0, 0, 0, 0.1); + } + + .swagger-ui .bg-white-10, + .swagger-ui .hover-bg-white-10:focus, + .swagger-ui .hover-bg-white-10:hover, + .swagger-ui .stripe-light:nth-child(2n + 1) { + background-color: rgba(28, 28, 33, 0.1); + } + + .swagger-ui .bg-light-silver, + .swagger-ui .hover-bg-light-silver:focus, + .swagger-ui .hover-bg-light-silver:hover, + .swagger-ui .striped--light-silver:nth-child(2n + 1) { + background-color: #6e6e6e; + } + + .swagger-ui .bg-moon-gray, + .swagger-ui .hover-bg-moon-gray:focus, + .swagger-ui .hover-bg-moon-gray:hover, + .swagger-ui .striped--moon-gray:nth-child(2n + 1) { + background-color: #4d4d4d; + } + + .swagger-ui .bg-light-gray, + .swagger-ui .hover-bg-light-gray:focus, + .swagger-ui .hover-bg-light-gray:hover, + .swagger-ui .striped--light-gray:nth-child(2n + 1) { + background-color: #2b2b2b; + } + + .swagger-ui .bg-near-white, + .swagger-ui .hover-bg-near-white:focus, + .swagger-ui .hover-bg-near-white:hover, + .swagger-ui .striped--near-white:nth-child(2n + 1) { + background-color: #242424; + } + + .swagger-ui .opblock-tag:hover, + .swagger-ui section.models h4:hover { + background: rgba(0, 0, 0, 0.02); + } + + .swagger-ui .checkbox p, + .swagger-ui .dialog-ux .modal-ux-content h4, + .swagger-ui .dialog-ux .modal-ux-content p, + .swagger-ui .dialog-ux .modal-ux-header h3, + .swagger-ui .errors-wrapper .errors h4, + .swagger-ui .errors-wrapper hgroup h4, + .swagger-ui .info .base-url, + .swagger-ui .info .title, + .swagger-ui .info h1, + .swagger-ui .info h2, + .swagger-ui .info h3, + .swagger-ui .info h4, + .swagger-ui .info h5, + .swagger-ui .info li, + .swagger-ui .info p, + .swagger-ui .info table, + .swagger-ui .loading-container .loading::after, + .swagger-ui .model, + .swagger-ui .opblock .opblock-section-header h4, + .swagger-ui .opblock .opblock-section-header > label, + .swagger-ui .opblock .opblock-summary-description, + .swagger-ui .opblock .opblock-summary-operation-id, + .swagger-ui .opblock .opblock-summary-path, + .swagger-ui .opblock .opblock-summary-path__deprecated, + .swagger-ui .opblock-description-wrapper, + .swagger-ui .opblock-description-wrapper h4, + .swagger-ui .opblock-description-wrapper p, + .swagger-ui .opblock-external-docs-wrapper, + .swagger-ui .opblock-external-docs-wrapper h4, + .swagger-ui .opblock-external-docs-wrapper p, + .swagger-ui .opblock-tag small, + .swagger-ui .opblock-title_normal, + .swagger-ui .opblock-title_normal h4, + .swagger-ui .opblock-title_normal p, + .swagger-ui .parameter__name, + .swagger-ui .parameter__type, + .swagger-ui .response-col_links, + .swagger-ui .response-col_status, + .swagger-ui .responses-inner h4, + .swagger-ui .responses-inner h5, + .swagger-ui .scheme-container .schemes > label, + .swagger-ui .scopes h2, + .swagger-ui .servers > label, + .swagger-ui .tab li, + .swagger-ui label, + .swagger-ui select, + .swagger-ui table.headers td { + color: #b5bac9; + } + + .swagger-ui .download-url-wrapper .failed, + .swagger-ui .filter .failed, + .swagger-ui .model-deprecated-warning, + .swagger-ui .parameter__deprecated, + .swagger-ui .parameter__name.required span, + .swagger-ui table.model tr.property-row .star { + color: #e69999; + } + + .swagger-ui .opblock-body pre.microlight, + .swagger-ui textarea.curl { + background: #41444e; + border-radius: 4px; + color: #fff; + } + + .swagger-ui .expand-methods svg, + .swagger-ui .expand-methods:hover svg { + fill: #bfbfbf; + } + + .swagger-ui .auth-container, + .swagger-ui .dialog-ux .modal-ux-header { + border-bottom: 1px solid #2e2e2e; + } + + .swagger-ui .topbar .download-url-wrapper .select-label select, + .swagger-ui .topbar .download-url-wrapper input[type='text'] { + border: 2px solid #63a040; + } + + .swagger-ui .info a, + .swagger-ui .info a:hover, + .swagger-ui .scopes h2 a { + color: #99bde6; + } + + /* Dark Scrollbar */ + ::-webkit-scrollbar { + width: 14px; + height: 14px; + } + + ::-webkit-scrollbar-button { + background-color: #3e4346 !important; + } + + ::-webkit-scrollbar-track { + background-color: #646464 !important; + } + + ::-webkit-scrollbar-track-piece { + background-color: #3e4346 !important; + } + + ::-webkit-scrollbar-thumb { + height: 50px; + background-color: #242424 !important; + border: 2px solid #3e4346 !important; + } + + ::-webkit-scrollbar-button:vertical:start:decrement { + background: linear-gradient(130deg, #696969 40%, rgba(255, 0, 0, 0) 41%), + linear-gradient(230deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(0deg, #696969 40%, rgba(0, 0, 0, 0) 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:vertical:end:increment { + background: linear-gradient(310deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(50deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(180deg, #696969 40%, rgba(0, 0, 0, 0) 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:horizontal:end:increment { + background: linear-gradient(210deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(330deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(90deg, #696969 30%, rgba(0, 0, 0, 0) 31%); + background-color: #b6b6b6; + } + + ::-webkit-scrollbar-button:horizontal:start:decrement { + background: linear-gradient(30deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(150deg, #696969 40%, rgba(0, 0, 0, 0) 41%), + linear-gradient(270deg, #696969 30%, rgba(0, 0, 0, 0) 31%); + background-color: #b6b6b6; + } +} diff --git a/src/docs/vendorProduct.yml b/src/docs/vendorProduct.yml new file mode 100644 index 0000000..937b097 --- /dev/null +++ b/src/docs/vendorProduct.yml @@ -0,0 +1,235 @@ +/product/collection: + get: + tags: + - Vendor product management + summary: Get all products + description: Return all product for logged user + security: + - bearerAuth: [] + responses: + '200': + description: Return all products for the user or return nothing if no product available + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/product/collection/{id}: + get: + tags: + - Vendor product management + summary: Get single product + description: return a product basing on id provided + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of product + responses: + '200': + description: Return info for the product + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + +/product: + post: + tags: + - Vendor product management + summary: Creates new product + security: + - bearerAuth: [] + consumes: + - application/json + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + newPrice: + type: number + quantity: + type: number + images: + type: file + categories: + oneOf: + - type: string + - type: array + items: + type: string + example: "'category' or ['category1', 'category2', ...]" + expirationDate: + type: string + format: date + required: + - name + - description + - quantity + - newPrice + - categories + optional: + - expirationDate + responses: + '201': + description: Successfully added the product + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + +/product/{id}: + put: + tags: + - Vendor product management + summary: Update a product + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of product + responses: + '200': + description: Successfully updated product + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + + delete: + tags: + - Vendor product management + summary: Delete a product + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of product + responses: + '200': + description: Successfully deleted product + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + +/product/images/{id}: + delete: + tags: + - Vendor product management + summary: Delete an image of product + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: The id of product + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: string + required: + - id + responses: + '200': + description: Successfully deleted product image + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found + '500': + description: Internal server error + +/product/recommended: + get: + tags: + - Products Recommended + summary: Gets recommended products + security: + - bearerAuth: [] + parameters: + - in: query + name: categories + required: false + schema: + type: string + pattern: '^{"categories":\s*\[[^\]]*\]\s*}$' + description: JSON string representing an array of category IDs + - in: query + name: vendor + required: false + schema: + type: string + description: Vendor ID + responses: + '201': + description: Successfully data retrieved + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Products not found + '500': + description: Internal server error diff --git a/src/docs/wishListDocs.yml b/src/docs/wishListDocs.yml new file mode 100644 index 0000000..df3c72c --- /dev/null +++ b/src/docs/wishListDocs.yml @@ -0,0 +1,97 @@ +/wish-list: + get: + tags: + - Wish list + summary: Get all products in wishlist + description: Return all products in wish list for authenticated buyer + security: + - bearerAuth: [] + responses: + '200': + description: Return all products in wish list for a buyer + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error + +/wish-list/add/{id}: + post: + tags: + - Wish list + summary: Add product to wish list + description: Adds selected product (product id) to the wish list + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: Product id + responses: + '201': + description: Product Added to wish list + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found in wish list + '500': + description: Internal server error + +/wish-list/delete/{id}: + delete: + tags: + - Wish list + summary: Remove product from wish list + description: Remove product from wish list for an authenticated buyer + security: + - bearerAuth: [] + parameters: + - in: path + name: id + schema: + type: string + required: true + description: Product id + responses: + '200': + description: Product removed from wish list + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '404': + description: Product not found in wish list + '500': + description: Internal server error + +/wish-list/clearAll: + delete: + tags: + - Wish list + summary: Clear entire wish list + description: Clears entire wish list for authenticated buyer + security: + - bearerAuth: [] + responses: + '200': + description: All products removed successfully + '400': + description: Bad Request (syntax error, incorrect input format, etc..) + '401': + description: Unauthorized + '403': + description: Forbidden (Unauthorized action) + '500': + description: Internal server error \ No newline at end of file diff --git a/src/entities/Cart.ts b/src/entities/Cart.ts new file mode 100644 index 0000000..0ba44a6 --- /dev/null +++ b/src/entities/Cart.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsNotEmpty, IsBoolean } from 'class-validator'; +import { User } from './User'; +import { CartItem } from './CartItem'; + +@Entity() +export class Cart { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User) + user!: User; + + @OneToMany(() => CartItem, (cartItem: any) => cartItem.cart) + items!: CartItem[]; + + @Column('decimal') + totalAmount: number = 0; + + @Column({ default: false }) + @IsBoolean() + isCheckedOut: boolean = false; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + updateTotal(): void { + if (this.items) { + let total: number = 0; + for (let i = 0; i < this.items.length; i++) { + total += Number(this.items[i].total); + } + this.totalAmount = total; + } else { + this.totalAmount = 0; + } + } +} diff --git a/src/entities/CartItem.ts b/src/entities/CartItem.ts new file mode 100644 index 0000000..107170c --- /dev/null +++ b/src/entities/CartItem.ts @@ -0,0 +1,53 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, + BeforeInsert, + BeforeUpdate, +} from 'typeorm'; +import { IsNotEmpty, IsNumber } from 'class-validator'; +import { Product } from './Product'; +import { Cart } from './Cart'; + +@Entity() +export class CartItem { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => Cart, cart => cart.items, { onDelete: 'CASCADE' }) + @IsNotEmpty() + cart!: Cart; + + @ManyToOne(() => Product) + @IsNotEmpty() + product!: Product; + + @Column('decimal') + @IsNotEmpty() + @IsNumber() + newPrice!: number; + + @Column('int') + @IsNotEmpty() + @IsNumber() + quantity!: number; + + @Column('decimal') + total!: number; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @BeforeInsert() + @BeforeUpdate() + updateTotal(): void { + this.total = this.newPrice * this.quantity; + } +} diff --git a/src/entities/Category.ts b/src/entities/Category.ts new file mode 100644 index 0000000..9152553 --- /dev/null +++ b/src/entities/Category.ts @@ -0,0 +1,20 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { IsNotEmpty, IsString } from 'class-validator'; + +@Entity() +export class Category { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @Column() + @IsNotEmpty() + @IsString() + name!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/Order.ts b/src/entities/Order.ts new file mode 100644 index 0000000..49965a0 --- /dev/null +++ b/src/entities/Order.ts @@ -0,0 +1,52 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { IsNotEmpty, IsNumber, IsDate, IsIn } from 'class-validator'; +import { User } from './User'; +import { OrderItem } from './OrderItem'; +import { Transaction } from './transaction'; + + +@Entity() +export class Order { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User, user => user.orders) + @IsNotEmpty() + buyer!: User; + + @OneToMany(() => OrderItem, orderItem => orderItem.order, { cascade: true }) + @IsNotEmpty() + orderItems!: OrderItem[]; + + @Column('decimal') + @IsNotEmpty() + @IsNumber() + totalPrice!: number; + + @OneToMany(() => Transaction, (transaction) => transaction.order) + transactions!: Transaction[]; + @Column({ default: 'order placed' }) + @IsNotEmpty() + @IsIn(['order placed', 'cancelled', 'awaiting shipment', 'in transit', 'delivered', 'received', 'returned']) + orderStatus!: string; + + @Column('int') + @IsNotEmpty() + @IsNumber() + quantity!: number; + + @Column({ default: 'City, Country street address' }) + address!: string; + + @Column() + @IsDate() + @IsNotEmpty() + orderDate!: Date; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/OrderItem.ts b/src/entities/OrderItem.ts new file mode 100644 index 0000000..130b330 --- /dev/null +++ b/src/entities/OrderItem.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; +import { IsNotEmpty, IsNumber } from 'class-validator'; +import { Order } from './Order'; +import { Product } from './Product'; + +@Entity() +export class OrderItem { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => Order, order => order.orderItems) + @IsNotEmpty() + order!: Order; + + @ManyToOne(() => Product, product => product.orderItems) + @IsNotEmpty() + product!: Product; + + @Column('decimal') + @IsNotEmpty() + @IsNumber() + price!: number; + + @Column('int') + @IsNotEmpty() + @IsNumber() + quantity!: number; +} diff --git a/src/entities/Product.ts b/src/entities/Product.ts new file mode 100644 index 0000000..2b39493 --- /dev/null +++ b/src/entities/Product.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Unique, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, + OneToMany, + JoinTable, + OneToOne, + JoinColumn, +} from 'typeorm'; +import { IsNotEmpty, IsString, IsBoolean, ArrayNotEmpty, IsArray, MaxLength } from 'class-validator'; +import { User } from './User'; +import { Category } from './Category'; +import { Order } from './Order'; +import { Coupon } from './coupon'; +import { OrderItem } from './OrderItem'; + +@Entity() +@Unique(['id']) +export class Product { + static query() { + throw new Error('Method not implemented.'); + } + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User) + @IsNotEmpty() + vendor!: User; + + @OneToMany(() => OrderItem, orderItem => orderItem.product) + orderItems!: OrderItem[]; + + @OneToOne(() => Coupon, (coupons: any) => coupons.product) + @JoinColumn() + coupons?: Coupon; + + @Column() + @IsNotEmpty() + @IsString() + name!: string; + + @Column() + @IsNotEmpty() + description!: string; + + @Column('simple-array') + @IsArray() + @ArrayNotEmpty() + @MaxLength(10) + images!: string[]; + + @Column('decimal') + @IsNotEmpty() + newPrice!: number; + + @Column('decimal', { nullable: true }) + oldPrice?: number; + + @Column('timestamp', { nullable: true }) + expirationDate?: Date; + + @Column('int') + @IsNotEmpty() + quantity!: number; + + @Column({ default: true }) + @IsBoolean() + isAvailable!: boolean; + + @ManyToMany(() => Category) + @JoinTable() + categories!: Category[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/User.ts b/src/entities/User.ts new file mode 100644 index 0000000..fb45fe9 --- /dev/null +++ b/src/entities/User.ts @@ -0,0 +1,118 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Unique, + CreateDateColumn, + UpdateDateColumn, + BeforeInsert, + OneToMany, +} from 'typeorm'; +import { IsEmail, IsNotEmpty, IsString, IsBoolean, IsIn } from 'class-validator'; +import { roles } from '../utils/roles'; +import { Order } from './Order'; +import { Transaction } from './transaction'; + +export interface UserInterface { + id?: string; + firstName: string; + lastName: string; + email: string; + password: string; + gender: string; + phoneNumber: string; + photoUrl?: string; + verified?: boolean; + status?: 'active' | 'suspended'; + userType: 'Admin' | 'Buyer' | 'Vendor'; + role?: string; + createdAt?: Date; + updatedAt?: Date; +} + +@Entity() +@Unique(['email']) +export class User { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @Column() + @IsNotEmpty() + @IsString() + firstName!: string; + + @Column() + @IsNotEmpty() + @IsString() + lastName!: string; + + @Column() + @IsNotEmpty() + @IsEmail() + email!: string; + + @Column() + @IsNotEmpty() + password!: string; + + @Column() + @IsNotEmpty() + @IsString() + gender!: string; + + @Column() + @IsNotEmpty() + phoneNumber!: string; + + @Column({ nullable: true }) + photoUrl?: string; + + @Column({ default: false }) + @IsNotEmpty() + @IsBoolean() + verified!: boolean; + + @Column({ default: 'active' }) + @IsNotEmpty() + @IsIn(['active', 'suspended']) + status!: 'active' | 'suspended'; + + @Column({ default: 'Buyer' }) + @IsNotEmpty() + @IsIn(['Admin', 'Buyer', 'Vendor']) + userType!: 'Admin' | 'Buyer' | 'Vendor'; + + @Column({ default: false }) + @IsBoolean() + twoFactorEnabled!: boolean; + + @Column({ nullable: true }) + twoFactorCode?: string; + + @Column({ type: 'timestamp', nullable: true }) + twoFactorCodeExpiresAt?: Date; + + @Column() + role!: string; + + @OneToMany(() => Order, (order: any) => order.buyer) + orders!: Order[]; + + @OneToMany(() => Transaction, (transaction) => transaction.user) + transactions!: Transaction[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @Column({ type: 'numeric', precision: 24, scale: 2, default: 0 }) + accountBalance!: number; + + @BeforeInsert() + setRole(): void { + this.role = this.userType === 'Vendor' ? roles.vendor : roles.buyer; + } +} diff --git a/src/entities/coupon.ts b/src/entities/coupon.ts new file mode 100644 index 0000000..39631c3 --- /dev/null +++ b/src/entities/coupon.ts @@ -0,0 +1,68 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Unique, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { IsDate, IsNotEmpty, IsArray, IsIn } from 'class-validator'; +import { User } from './User'; +import { Product } from './Product'; + +@Entity() +@Unique(['id']) +@Unique(['code']) // Ensure only 'code' is unique +export class Coupon { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User) + @IsNotEmpty() + @JoinColumn() + vendor!: User; + + @ManyToOne(() => Product, product => product.coupons) + @IsNotEmpty() + @JoinColumn() + product!: Product; + + @Column() + @IsNotEmpty() + code!: string; + + @Column() + @IsNotEmpty() + @IsIn(['percentage', 'money']) + discountType!: 'percentage' | 'money'; + + @Column('float') + @IsNotEmpty() + discountRate!: number; + + @Column('timestamp', { nullable: false }) + @IsNotEmpty() + @IsDate() + expirationDate?: Date; + + @Column('int', { default: 0 }) + @IsNotEmpty() + usageTimes!: number; + + @Column('int') + @IsNotEmpty() + maxUsageLimit!: number; + + @Column('simple-array', { nullable: true, default: '' }) + @IsArray() + usedBy!: string[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/entities/transaction.ts b/src/entities/transaction.ts new file mode 100644 index 0000000..d475812 --- /dev/null +++ b/src/entities/transaction.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { IsNotEmpty, IsString, IsNumber } from 'class-validator'; +import { User } from './User'; +import { Order } from './Order'; +import { Product } from './Product'; // Assuming Product entity exists + +@Entity() +export class Transaction { + @PrimaryGeneratedColumn('uuid') + @IsNotEmpty() + id!: string; + + @ManyToOne(() => User, { nullable: false }) + @JoinColumn({ name: 'userId' }) + user!: User; + + @ManyToOne(() => Order, { nullable: true }) + @JoinColumn({ name: 'orderId' }) + order?: Order; + + @ManyToOne(() => Product, { nullable: true }) + @JoinColumn({ name: 'productId' }) + product?: Product; + + @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 }) + @IsNotEmpty() + @IsNumber() + amount!: number; + + @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 }) + @IsNotEmpty() + @IsNumber() + previousBalance!: number; + + @Column({ type: 'numeric', precision: 15, scale: 2, default: 0 }) + @IsNotEmpty() + @IsNumber() + currentBalance!: number; + + @Column({ type: 'enum', enum: ['debit', 'credit'] }) + @IsNotEmpty() + @IsString() + type!: 'debit' | 'credit'; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/entities/wishList.ts b/src/entities/wishList.ts new file mode 100644 index 0000000..69dbebd --- /dev/null +++ b/src/entities/wishList.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryGeneratedColumn, BaseEntity,Column, Unique, ManyToOne, CreateDateColumn, UpdateDateColumn,} from "typeorm"; +import { IsNotEmpty, IsString } from 'class-validator'; +import { User } from './User'; + +@Entity("wishlist") +@Unique(['id']) +export class wishList extends BaseEntity{ + @PrimaryGeneratedColumn() + @IsNotEmpty() + id!: number; + + @Column() + @IsNotEmpty() + @IsString() + productId!: string; + + @ManyToOne(() => User) + @IsNotEmpty() + buyer!: User; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/src/helper/cartItemValidator.ts b/src/helper/cartItemValidator.ts new file mode 100644 index 0000000..c8de0e8 --- /dev/null +++ b/src/helper/cartItemValidator.ts @@ -0,0 +1,19 @@ +import Joi from 'joi'; + +interface CartItem { + productId: string; + quantity: number; +} + +export const validateCartItem = (product: CartItem): Joi.ValidationResult => { + const schema = Joi.object({ + productId: Joi.string().min(3).required().messages({ + 'any.required': 'id is required.', + }), + quantity: Joi.number().required().messages({ + 'any.required': 'quantity is required.', + }), + }); + + return schema.validate(product); +}; diff --git a/src/helper/couponValidator.ts b/src/helper/couponValidator.ts new file mode 100644 index 0000000..9736aa8 --- /dev/null +++ b/src/helper/couponValidator.ts @@ -0,0 +1,58 @@ +import Joi from 'joi'; +import { Coupon } from '../entities/coupon'; + +export const validateCoupon = ( + coupon: Pick +): Joi.ValidationResult => { + const schema = Joi.object({ + code: Joi.string().min(5).required().messages({ + 'any.required': 'code is required.', + 'string.min': 'code must be at least 5 characters long.', + }), + discountRate: Joi.number().required().messages({ + 'any.required': 'discountRate is required.', + }), + expirationDate: Joi.date().required().messages({ + 'any.required': 'expirationDate is required.', + }), + maxUsageLimit: Joi.number().required().messages({ + 'any.required': 'maxUsageLimit is required.', + }), + discountType: Joi.string().required().messages({ + 'any.required': 'discountType is required.', + }), + product: Joi.string().required().messages({ + 'any.required': 'product is required.', + }), + }); + + return schema.validate(coupon); +}; + +export const validateCouponUpdate = ( + coupon: Partial> +): Joi.ValidationResult => { + const schema = Joi.object({ + code: Joi.string().min(5).messages({ + 'string.min': 'code must be at least 5 characters long.', + }), + discountRate: Joi.number().messages({ + 'number.base': 'discountRate must be a number.', + }), + expirationDate: Joi.date().messages({ + 'date.base': 'expirationDate must be a valid date.', + }), + maxUsageLimit: Joi.number().messages({ + 'number.base': 'maxUsageLimit must be a number.', + }), + discountType: Joi.string().messages({ + 'string.base': 'discountType must be a string.', + }), + }) + .min(1) + .messages({ + 'object.min': 'At least one field must be updated.', + }); + + return schema.validate(coupon); +}; diff --git a/src/helper/emailTemplates.ts b/src/helper/emailTemplates.ts new file mode 100644 index 0000000..5578446 --- /dev/null +++ b/src/helper/emailTemplates.ts @@ -0,0 +1,16 @@ +export const otpTemplate = (name: string, otpCode: string): string => { + return ` +
+

Login OTP Code

+

Hi ${name},

+

+ It looks like you are trying to log in to knight e-commerce using your username and password. + As an additional security measure you are requested to enter the OTP code (one-time password) provided in this email. +

+

If you did not intend to log in to your acount, please ignore this email.

+

The OTP code is: ${otpCode}

+ +

Cheers,

+

Knights e-commerce Team

+
`; +}; diff --git a/src/helper/productValidator.ts b/src/helper/productValidator.ts new file mode 100644 index 0000000..4d93847 --- /dev/null +++ b/src/helper/productValidator.ts @@ -0,0 +1,30 @@ +import Joi from 'joi'; +import { Product } from '../lib/types'; + +export const validateProduct = ( + product: Pick +): Joi.ValidationResult => { + const schema = Joi.object({ + name: Joi.string().min(3).required().messages({ + 'any.required': 'name is required.', + }), + description: Joi.string().min(3).required().messages({ + 'any.required': 'description is required.', + }), + newPrice: Joi.number().required().messages({ + 'any.required': 'newPrice is required.', + }), + quantity: Joi.number().required().messages({ + 'any.required': 'quantity is required.', + }), + categories: Joi.alternatives() + .try(Joi.array().items(Joi.string()).min(1).required(), Joi.string().required()) + .messages({ + 'any.required': 'at least one category is required.', + }), + expirationDate: Joi.date(), + oldPrice: Joi.number(), + }); + + return schema.validate(product); +}; diff --git a/src/helper/verify.ts b/src/helper/verify.ts new file mode 100644 index 0000000..bda6a00 --- /dev/null +++ b/src/helper/verify.ts @@ -0,0 +1,19 @@ +import jwt from 'jsonwebtoken'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const jwtSecretKey = process.env.JWT_SECRET; + +if (!jwtSecretKey) { + throw new Error('JWT_SECRET is not defined in the environment variables.'); +} + +export const verifiedToken = (token: string): any => { + try { + return jwt.verify(token, jwtSecretKey); + } catch (err) { + console.error(err); + return null; + } +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..07efd39 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,45 @@ +import express, { Request, Response } from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import router from './routes'; +import { addDocumentation } from './startups/docs'; +import 'reflect-metadata'; +import cookieParser from 'cookie-parser'; +import session from "express-session"; +import passport from 'passport'; + +import { CustomError, errorHandler } from './middlewares/errorHandler'; +import morgan from 'morgan'; +import { dbConnection } from './startups/dbConnection'; +dotenv.config(); + +export const app = express(); +const port = process.env.PORT || 8000; +app.use(session({ + secret: 'keyboard cat' +})) +app.use(passport.initialize()) +app.use(passport.session()) +app.use(express.json()); +app.use(cookieParser()); +app.use(cors({ origin: '*' })); +app.use(router); +addDocumentation(app); +app.all('*', (req: Request, res: Response, next) => { + const error = new CustomError(`Can't find ${req.originalUrl} on the server!`, 404); + error.status = 'fail'; + next(error); +}); +app.use(errorHandler); + +// Start database connection + +dbConnection(); + +//morgan +const morganFormat = ':method :url :status :response-time ms - :res[content-length]'; +app.use(morgan(morganFormat)); + +export const server = app.listen(port, () => { + console.log(`[server]: Server is running at http://localhost:${port}`); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..b8598e8 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,16 @@ +import { User } from '../entities/User'; + +export type Product = { + id: string; + vendor: User; + name: string; + description: string; + images: string[]; + newPrice: number; + oldPrice?: number; + expirationDate?: Date; + quantity: number; + isAvailable: boolean; + createdAt: Date; + updatedAt: Date; +}; diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts new file mode 100644 index 0000000..d028c83 --- /dev/null +++ b/src/middlewares/errorHandler.ts @@ -0,0 +1,24 @@ +import { NextFunction, Request, Response } from 'express'; + +class CustomError extends Error { + statusCode: number; + status: string; + + constructor (message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error'; + Error.captureStackTrace(this, this.constructor); + } +} + +const errorHandler = (err: CustomError, req: Request, res: Response, next: NextFunction) => { + err.statusCode = err.statusCode || 500; + err.status = err.status || 'error'; + res.status(err.statusCode).json({ + status: err.statusCode, + message: err.message, + }); +}; + +export { CustomError, errorHandler }; diff --git a/src/middlewares/index.ts b/src/middlewares/index.ts new file mode 100644 index 0000000..8fd48b8 --- /dev/null +++ b/src/middlewares/index.ts @@ -0,0 +1,3 @@ +export * from './errorHandler'; +export * from './roleCheck'; +export * from './isValid'; diff --git a/src/middlewares/isAllowed.ts b/src/middlewares/isAllowed.ts new file mode 100644 index 0000000..77c115b --- /dev/null +++ b/src/middlewares/isAllowed.ts @@ -0,0 +1,54 @@ +import { NextFunction, Request, Response } from 'express'; +import { User } from '../entities/User'; +import { getRepository } from 'typeorm'; +import { responseError } from '../utils/response.utils'; + +export interface UserInterface { + id: string; + firstName: string; + lastName: string; + email: string; + password: string; + gender: string; + phoneNumber: string; + photoUrl?: string; + verified: boolean; + status: 'active' | 'suspended'; + userType: 'Admin' | 'Buyer' | 'Vendor'; + role: string; + createdAt: Date; + updatedAt: Date; +} + +declare module 'express' { + interface Request { + user?: Partial; + } +} + +export const checkUserStatus = async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.user) { + return responseError(res, 401, 'Authentication required'); + } + + const userId = req.user.id; + + const userRepository = getRepository(User); + + const user = await userRepository.findOne({ where: { id: userId } }); + if (!user) { + return responseError(res, 401, 'User not found'); + } + + if (user.status === 'active') { + next(); + } else if (user.status === 'suspended') { + return responseError(res, 403, 'You have been suspended. Please contact our support team.'); + } else { + return responseError(res, 403, 'Unauthorized action'); + } + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/middlewares/isValid.ts b/src/middlewares/isValid.ts new file mode 100644 index 0000000..bd3a004 --- /dev/null +++ b/src/middlewares/isValid.ts @@ -0,0 +1,33 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; +import { verifiedToken } from '../helper/verify'; +import { getRepository } from 'typeorm'; +import { User } from '../entities/User'; + +export interface DecodedUser { + userType: string; + id: string; + email: string; +} + +export const isTokenValide: RequestHandler = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const token = req.cookies.token; + const userPaylod = verifiedToken(token); + if (!userPaylod) { + res.status(401).json({ Message: 'Sorry, You are not authorized' }); + return; + } + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { id: userPaylod.id } }); + if (!user) { + res.status(404).json({ Message: 'User not found' }); + return; + } + req.user = user; + return next(); + } catch (error) { + console.error('Error in token Validation middleware:\n', error); + res.status(401).json({ Message: 'Sorry, Something went wrong' }); + return; + } +}; diff --git a/src/middlewares/multer.ts b/src/middlewares/multer.ts new file mode 100644 index 0000000..0c1eba5 --- /dev/null +++ b/src/middlewares/multer.ts @@ -0,0 +1,14 @@ +import multer from 'multer'; +import path from 'path'; + +export default multer({ + storage: multer.diskStorage({}), + fileFilter: (req, file, next) => { + const ext = path.extname(file.originalname); + const supported = ['.png', '.jpg', '.jpeg', '.webp']; + if (!supported.includes(ext)) { + next(new Error(`file type not supported\ntry ${supported} are supported`)); + } + next(null, true); + }, +}); diff --git a/src/middlewares/optionalAuthorization.ts b/src/middlewares/optionalAuthorization.ts new file mode 100644 index 0000000..0024ee1 --- /dev/null +++ b/src/middlewares/optionalAuthorization.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import { User, UserInterface } from '../entities/User'; +import { getRepository } from 'typeorm'; +import jwt, { type JwtPayload, type Secret } from 'jsonwebtoken'; +import { responseError } from '../utils/response.utils'; +import dotenv from 'dotenv'; + +dotenv.config(); + +interface AuthRequest extends Request { + user?: UserInterface; +} + +export const optinalAuthMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (authHeader !== undefined) { + const [bearer, token] = authHeader.split(' '); + + if (bearer !== 'Bearer' || token === undefined) { + responseError(res, 401, 'Please login'); + } + + if (token !== undefined) { + try { + jwt.verify(token, process.env.JWT_SECRET as Secret, async (err, decodedToken) => { + if (err !== null) { + responseError(res, 403, 'Access denied'); + } + + if (decodedToken !== undefined) { + const { email } = decodedToken as JwtPayload; + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + responseError(res, 401, 'You are not Authorized'); + } + + req.user = user as UserInterface; + next(); + } + }); + } catch (error) { + responseError(res, 401, 'Invalid token'); + } + } + } else { + next(); + } +}; diff --git a/src/middlewares/roleCheck.ts b/src/middlewares/roleCheck.ts new file mode 100644 index 0000000..e56c1b0 --- /dev/null +++ b/src/middlewares/roleCheck.ts @@ -0,0 +1,41 @@ +import { NextFunction, Request, Response } from 'express'; +import { User, UserInterface } from '../entities/User'; +import { getRepository } from 'typeorm'; +import { responseError } from '../utils/response.utils'; + +/** + * Middleware to check user role before granting access to protectered routes. + * @param {("ADMIN" | "VENDOR" | "BUYER")} role - The role required to access the route. + * @returns {function} Helper function for making responses. + */ + +declare module 'express' { + interface Request { + user?: Partial; + } +} + +export const hasRole = + (role: 'ADMIN' | 'VENDOR' | 'BUYER') => async (req: Request, res: Response, next: NextFunction) => { + try { + if (!req.user) { + return responseError(res, 401, 'Authentication required'); + } + + const userId = req.user.id; + + const userRepository = getRepository(User); + + const user = await userRepository.findOne({ where: { id: userId } }); + if (!user) { + return responseError(res, 401, 'User not found'); + } + if (user.role !== role) { + return responseError(res, 403, 'Unauthorized action'); + } + + next(); + } catch (error) { + responseError(res, 400, (error as Error).message); + } + }; diff --git a/src/middlewares/verifyToken.ts b/src/middlewares/verifyToken.ts new file mode 100644 index 0000000..3fe4f1a --- /dev/null +++ b/src/middlewares/verifyToken.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import { User, UserInterface } from '../entities/User'; +import { getRepository } from 'typeorm'; +import jwt, { type JwtPayload, type Secret } from 'jsonwebtoken'; +import dotenv from 'dotenv'; + +dotenv.config(); + +interface AuthRequest extends Request { + user?: UserInterface; +} + +export const authMiddleware = async (req: AuthRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (authHeader === undefined) { + return res.status(401).json({ error: 'Access denied. No token provided.' }); + } + + const [bearer, token] = authHeader.split(' '); + + if (bearer !== 'Bearer' || token === undefined) { + return res.status(401).json({ error: 'Please login' }); + } + + if (token !== undefined) { + try { + jwt.verify(token, process.env.JWT_SECRET as Secret, async (err, decodedToken) => { + if (err !== null) { + return res.status(403).json({ status: 'error', error: 'Access denied' }); + } + + if (decodedToken !== undefined) { + const { email } = decodedToken as JwtPayload; + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + return res.status(401).json({ status: 'error', error: 'You are not Authorized' }); + } + + req.user = user as UserInterface; + next(); + } + }); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } + } +}; diff --git a/src/routes/CartRoutes.ts b/src/routes/CartRoutes.ts new file mode 100644 index 0000000..ca74292 --- /dev/null +++ b/src/routes/CartRoutes.ts @@ -0,0 +1,12 @@ +import { RequestHandler, Router } from 'express'; +import { createCart, readCart, removeProductInCart, clearCart } from '../controllers/cartController'; +import { optinalAuthMiddleware } from '../middlewares/optionalAuthorization'; + +const router = Router(); + +router.post('/', optinalAuthMiddleware as RequestHandler, createCart); +router.get('/', optinalAuthMiddleware as RequestHandler, readCart); +router.delete('/:id', optinalAuthMiddleware as RequestHandler, removeProductInCart); +router.delete('/', optinalAuthMiddleware as RequestHandler, clearCart); + +export default router; diff --git a/src/routes/ProductRoutes.ts b/src/routes/ProductRoutes.ts new file mode 100644 index 0000000..ce146ec --- /dev/null +++ b/src/routes/ProductRoutes.ts @@ -0,0 +1,40 @@ +import { RequestHandler, Router } from 'express'; + +import { productStatus, searchProduct } from '../controllers/index'; +import { hasRole } from '../middlewares/roleCheck'; +import upload from '../middlewares/multer'; +import { authMiddleware } from '../middlewares/verifyToken'; + +import { + createProduct, + updateProduct, + removeProductImage, + readProducts, + readProduct, + deleteProduct, + getRecommendedProducts, + listAllProducts, + singleProduct, + createOrder, + getOrders, + updateOrder, + getOrdersHistory +} from '../controllers'; +const router = Router(); +router.get('/all', listAllProducts); +router.get('/recommended', authMiddleware as RequestHandler, hasRole('BUYER'), getRecommendedProducts); +router.get('/collection', authMiddleware as RequestHandler, hasRole('VENDOR'), readProducts); +router.get('/', authMiddleware as RequestHandler, hasRole('BUYER'), readProducts); +router.get('/:id', singleProduct); +router.get('/collection/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), readProduct); +router.post('/', authMiddleware as RequestHandler, hasRole('VENDOR'), upload.array('images', 10), createProduct); +router.put('/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), upload.array('images', 10), updateProduct); +router.delete('/images/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), removeProductImage); +router.delete('/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), deleteProduct); +router.put('/availability/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), productStatus); +router.post('/orders', authMiddleware as RequestHandler, hasRole('BUYER'), createOrder); +router.get('/client/orders', authMiddleware as RequestHandler, hasRole('BUYER'), getOrders); +router.put('/client/orders/:orderId', authMiddleware as RequestHandler, hasRole('BUYER'), updateOrder); +router.get('/orders/history', authMiddleware as RequestHandler, hasRole('BUYER'), getOrdersHistory); + +export default router; diff --git a/src/routes/UserRoutes.ts b/src/routes/UserRoutes.ts new file mode 100644 index 0000000..50bb4ca --- /dev/null +++ b/src/routes/UserRoutes.ts @@ -0,0 +1,72 @@ +import { Router } from 'express'; +import { responseError } from '../utils/response.utils'; +import { UserInterface } from '../entities/User'; +import jwt from 'jsonwebtoken' +import { + disable2FA, + enable2FA, + login, + resendOTP, + sendPasswordResetLink, + userPasswordReset, + userRegistration, + userVerification, + verifyOTP, + logout, +} from '../controllers'; + +import { activateUser, disactivateUser, userProfileUpdate } from '../controllers/index'; +import { hasRole } from '../middlewares/roleCheck'; +import { isTokenValide } from '../middlewares/isValid'; +import passport from 'passport'; +import "../utils/auth"; +const router = Router(); + +router.post('/register', userRegistration); +router.get('/verify/:id', userVerification); +router.post('/login', login); +router.post('/logout', logout); +router.post('/enable-2fa', enable2FA); +router.post('/disable-2fa', disable2FA); +router.post('/verify-otp', verifyOTP); +router.post('/resend-otp', resendOTP); +router.post('/activate', isTokenValide, hasRole('ADMIN'), activateUser); +router.post('/deactivate', isTokenValide, hasRole('ADMIN'), disactivateUser); +router.post('/password/reset', userPasswordReset); +router.post('/password/reset/link', sendPasswordResetLink); +router.put('/update', userProfileUpdate); + +router.get('/google-auth', passport.authenticate('google', { scope: ['profile', 'email'] })); +router.get("/auth/google/callback", + passport.authenticate("google", { + successRedirect: "/user/login/success", + failureRedirect: "/user/login/failed" + }) +); +router.get("/login/success", async (req, res) => { + const user = req.user as UserInterface; + if(!user){ + responseError(res, 404, 'user not found') + } + const payload = { + id: user?.id, + email: user?.email, + role: user?.role + } + const token = jwt.sign(payload, process.env.JWT_SECRET as string,{expiresIn: '24h'}) + res.status(200).json({ + status: 'success', + data:{ + token: token, + message: "Login success" + } + }) +}); +router.get("/login/failed", async (req, res) => { + res.status(401).json({ + status: false, + message: "Login failed" + }); +}); + +export default router; diff --git a/src/routes/couponRoutes.ts b/src/routes/couponRoutes.ts new file mode 100644 index 0000000..c315ab8 --- /dev/null +++ b/src/routes/couponRoutes.ts @@ -0,0 +1,15 @@ +import { RequestHandler, Router } from 'express'; +import { createCoupon, updateCoupon, accessAllCoupon, readCoupon, deleteCoupon, buyerApplyCoupon } from '../controllers/couponController'; +import { hasRole } from '../middlewares/roleCheck'; +import { authMiddleware } from '../middlewares/verifyToken'; + +const router = Router(); + +router.post('/vendor/:id', authMiddleware as RequestHandler, hasRole('VENDOR'), createCoupon); +router.put('/vendor/:id/update-coupon/:code', authMiddleware as RequestHandler, hasRole('VENDOR'), updateCoupon); +router.get('/vendor/:id/checkout/:code', authMiddleware as RequestHandler, hasRole('VENDOR'), readCoupon); +router.get('/vendor/:id/access-coupons', authMiddleware as RequestHandler, hasRole('VENDOR'), accessAllCoupon); +router.delete('/vendor/:id/checkout/delete', authMiddleware as RequestHandler, hasRole('VENDOR'), deleteCoupon); +router.post('/apply', authMiddleware as RequestHandler, hasRole('BUYER'),buyerApplyCoupon); + +export default router; \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..cddc08a --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,21 @@ +import { Request, Response, Router } from 'express'; +import { responseSuccess } from '../utils/response.utils'; +import userRoutes from './UserRoutes'; +import productRoutes from './ProductRoutes'; +import wishListRoutes from './wishListRoute'; +import couponRoute from './couponRoutes';; +import cartRoutes from './CartRoutes'; + +const router = Router(); + +router.get('/', (req: Request, res: Response) => { + return responseSuccess(res, 200, 'This is a testing route.'); +}); + +router.use('/user', userRoutes); +router.use('/product', productRoutes); +router.use('/wish-list', wishListRoutes); +router.use('/cart', cartRoutes); +router.use('/coupons', couponRoute); + +export default router; diff --git a/src/routes/wishListRoute.ts b/src/routes/wishListRoute.ts new file mode 100644 index 0000000..d5ac6fb --- /dev/null +++ b/src/routes/wishListRoute.ts @@ -0,0 +1,14 @@ +import { RequestHandler, Router } from 'express'; +import { authMiddleware } from '../middlewares/verifyToken'; +import { hasRole } from '../middlewares'; +import { checkUserStatus } from '../middlewares/isAllowed'; +import { wishlistAddProduct,wishlistRemoveProduct,wishlistGetProducts,wishlistClearAllProducts } from '../controllers/wishListController'; + +const router = Router(); + +router.post('/add/:id', authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'), wishlistAddProduct); +router.get('/',authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'),wishlistGetProducts); +router.delete('/delete/:id',authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'),wishlistRemoveProduct); +router.delete('/clearAll',authMiddleware as RequestHandler, checkUserStatus, hasRole('BUYER'),wishlistClearAllProducts); + +export default router; \ No newline at end of file diff --git a/src/services/cartServices/clearCart.ts b/src/services/cartServices/clearCart.ts new file mode 100644 index 0000000..4806e01 --- /dev/null +++ b/src/services/cartServices/clearCart.ts @@ -0,0 +1,60 @@ +import { Request, Response } from 'express'; +import { Cart } from '../../entities/Cart'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; + +export const clearCartService = async (req: Request, res: Response) => { + try { + const cartRepository = getRepository(Cart); + + if (req.user) { + const cart = await cartRepository.findOne({ + where: { + user: { + id: req.user.id, + }, + isCheckedOut: false, + }, + relations: ['items', 'items.product', 'user'], + }); + + if (!cart) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + await cartRepository.remove(cart as Cart); + + responseSuccess(res, 200, 'Cart cleared successfully', { cart: [] }); + return; + } + + if (!req.user) { + if (!req.cookies.cartId) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + const cart = await cartRepository.findOne({ + where: { + id: req.cookies.cartId, + isCheckedOut: false, + }, + relations: ['items', 'items.product'], + }); + + if (!cart) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + await cartRepository.remove(cart as Cart); + + responseSuccess(res, 200, 'Cart cleared successfully', { cart: [] }); + return; + } + } catch (error) { + responseError(res, 400, (error as Error).message); + return; + } +}; diff --git a/src/services/cartServices/createCart.ts b/src/services/cartServices/createCart.ts new file mode 100644 index 0000000..36232a3 --- /dev/null +++ b/src/services/cartServices/createCart.ts @@ -0,0 +1,151 @@ +import { Request, Response } from 'express'; +import { CartItem } from '../../entities/CartItem'; +import { Cart } from '../../entities/Cart'; +import { Product } from '../../entities/Product'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { validateCartItem } from '../../helper/cartItemValidator'; +import { responseSuccess, responseError } from '../../utils/response.utils'; + +export const createCartService = async (req: Request, res: Response) => { + try { + const { error } = validateCartItem(req.body); + if (error) { + return responseError(res, 400, error.details[0].message); + } + + if (req.body.quantity < 1) { + responseError(res, 400, 'Quantity must be greater than 0'); + return; + } + + const product = await getRepository(Product).findOne({ + where: { id: req.body.productId }, + }); + + if (!product) { + responseError(res, 404, 'Product not found, try again.'); + return; + } + + if (req.user) { + const cartRepository = getRepository(Cart); + const cartItemRepository = getRepository(CartItem); + + let cart = await cartRepository.findOne({ + where: { + user: { id: req.user.id }, + isCheckedOut: false, + }, + relations: ['items', 'items.product'], + }); + + if (!cart) { + cart = new Cart(); + cart.user = req.user as User; + await cartRepository.save(cart); + } + + let cartItem = await cartItemRepository.findOne({ + where: { + cart: { id: cart.id }, + product: { id: req.body.productId }, + }, + }); + + if (cartItem) { + cartItem.quantity = req.body.quantity; + cartItem.newPrice = product.newPrice; + await cartItemRepository.save(cartItem); + } else { + cartItem = new CartItem(); + cartItem.cart = cart; + cartItem.product = product; + cartItem.newPrice = product.newPrice; + cartItem.quantity = req.body.quantity; + await cartItemRepository.save(cartItem); + } + + // Fetch the updated cart with items and user + cart = await cartRepository.findOne({ + where: { id: cart.id }, + relations: ['items', 'items.product', 'user'], + }); + + if (cart) { + // Update the total amount in the cart + cart.updateTotal(); + await cartRepository.save(cart); + + const responseCart = { + ...cart, + user: cart?.user.id, + }; + + responseSuccess(res, 201, 'cart updated successfully', { cart: responseCart }); + return; + } + } + + if (!req.user) { + // guest user + const cartRepository = getRepository(Cart); + const cartItemRepository = getRepository(CartItem); + + let cart; + if (req.cookies.cartId) { + cart = await cartRepository.findOne({ + where: { + id: req.cookies?.cartId, + isCheckedOut: false, + }, + relations: ['items', 'items.product'], + }); + } + + if (!cart) { + cart = new Cart(); + await cartRepository.save(cart); + } + + let cartItem = await cartItemRepository.findOne({ + where: { + cart: { id: cart.id }, + product: { id: req.body.productId }, + }, + }); + + if (cartItem) { + cartItem.quantity = req.body.quantity; + cartItem.newPrice = product.newPrice; + await cartItemRepository.save(cartItem); + } else { + cartItem = new CartItem(); + cartItem.cart = cart; + cartItem.product = product; + cartItem.newPrice = product.newPrice; + cartItem.quantity = req.body.quantity; + await cartItemRepository.save(cartItem); + } + + // Fetch the updated cart with items and user + cart = await cartRepository.findOne({ + where: { id: cart.id }, + relations: ['items', 'items.product', 'user'], + }); + + if (cart) { + // Update the total amount in the cart + cart.updateTotal(); + await cartRepository.save(cart); + + res.cookie('cartId', cart.id); + responseSuccess(res, 201, 'cart updated successfully', { cart }); + return; + } + } + } catch (error) { + responseError(res, 400, (error as Error).message); + return; + } +}; diff --git a/src/services/cartServices/readCart.ts b/src/services/cartServices/readCart.ts new file mode 100644 index 0000000..71d7a4d --- /dev/null +++ b/src/services/cartServices/readCart.ts @@ -0,0 +1,58 @@ +import { Request, Response } from 'express'; +import { Cart } from '../../entities/Cart'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; + +export const readCartService = async (req: Request, res: Response) => { + try { + const cartRepository = getRepository(Cart); + + if (req.user) { + const cart = await cartRepository.findOne({ + where: { + user: { + id: req.user.id, + }, + isCheckedOut: false, + }, + relations: ['items', 'items.product', 'user'], + }); + + if (!cart) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + cart.user = cart.user.id as unknown as User; + responseSuccess(res, 200, 'Cart retrieved successfully', { cart }); + return; + } + + if (!req.user) { + if (!req.cookies.cartId) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + const cart = await cartRepository.findOne({ + where: { + id: req.cookies.cartId, + isCheckedOut: false, + }, + relations: ['items', 'items.product'], + }); + + if (!cart) { + responseSuccess(res, 200, 'Cart is empty', { cart: [] }); + return; + } + + responseSuccess(res, 200, 'Cart retrieved successfully', { cart }); + return; + } + } catch (error) { + responseError(res, 400, (error as Error).message); + return; + } +}; diff --git a/src/services/cartServices/removeProductInCart.ts b/src/services/cartServices/removeProductInCart.ts new file mode 100644 index 0000000..25bfe13 --- /dev/null +++ b/src/services/cartServices/removeProductInCart.ts @@ -0,0 +1,109 @@ +import { Request, Response } from 'express'; +import { CartItem } from '../../entities/CartItem'; +import { Cart } from '../../entities/Cart'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; + +export const removeProductInCartService = async (req: Request, res: Response) => { + try { + const cartItemRepository = getRepository(CartItem); + const cartRepository = getRepository(Cart); + + if (!req.params.id) { + responseError(res, 400, 'Cart item id is required'); + return; + } + + const cartItem = await cartItemRepository.findOne({ + where: { + id: req.params.id, + }, + relations: ['cart', 'cart.user'], + }); + + if (!cartItem) { + responseError(res, 404, 'Cart item not found'); + return; + } + + if (req.user) { + if (cartItem?.cart.user.id !== req.user.id) { + responseError(res, 401, 'You are not authorized to perform this action'); + return; + } + + await cartItemRepository.remove(cartItem as CartItem); + + const cart = await cartRepository.findOne({ + where: { + id: cartItem?.cart.id, + }, + relations: ['items', 'items.product', 'user'], + }); + + if (cart) { + if (cart.items.length === 0) { + await cartRepository.remove(cart as Cart); + + responseSuccess(res, 200, 'cart removed successfully', { cart: [] }); + return; + } + + cart.updateTotal(); + await cartRepository.save(cart as Cart); + + cart.user = cart?.user.id as unknown as User; + + responseSuccess(res, 200, 'Product removed from cart successfully', { cart }); + return; + } + } + + if (!req.user) { + if (!req.params.id) { + responseError(res, 400, 'Cart item id is required'); + return; + } + + const cartItem = await cartItemRepository.findOne({ + where: { + id: req.params.id, + }, + relations: ['cart'], + }); + + if (!cartItem) { + responseError(res, 404, 'Cart item not found'); + return; + } + + await cartItemRepository.remove(cartItem); + + const cart = await cartRepository.findOne({ + where: { + id: cartItem.cart.id, + }, + relations: ['items', 'items.product'], + }); + + if (cart) { + if (cart.items.length === 0) { + await cartRepository.remove(cart); + + responseSuccess(res, 200, 'cart removed successfully', { cart: [] }); + return; + } + + cart.updateTotal(); + await cartRepository.save(cart); + + responseSuccess(res, 200, 'Product removed from cart successfully', { cart }); + return; + } + } + } catch (error) { + responseError(res, 400, (error as Error).message); + return; + } +}; diff --git a/src/services/couponServices/accessAllCoupon.ts b/src/services/couponServices/accessAllCoupon.ts new file mode 100644 index 0000000..9266a44 --- /dev/null +++ b/src/services/couponServices/accessAllCoupon.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; +import { User } from '../../entities/User'; + +export const accessAllCouponService = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Retrieve the user by id + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { id } }); + + if (!user) { + console.log('User not found with id:', id); + return responseError(res, 404, 'User not found'); + } + + // Retrieve all coupons for the user + const couponRepository = getRepository(Coupon); + const coupons = await couponRepository.find({ + where: { vendor: { id: user.id } }, + relations: ['product'], + }); + + if (!coupons.length) { + console.log('No coupons found for user with id:', id); + return responseError(res, 404, 'No coupons found'); + } + + return responseSuccess(res, 200, 'Coupons retrieved successfully', coupons); + } catch (error: any) { + console.log('Error retrieving all coupons:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/couponServices/buyerApplyCoupon.ts b/src/services/couponServices/buyerApplyCoupon.ts new file mode 100644 index 0000000..12da4e1 --- /dev/null +++ b/src/services/couponServices/buyerApplyCoupon.ts @@ -0,0 +1,85 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; +import { Cart } from '../../entities/Cart'; +import { CartItem } from '../../entities/CartItem'; + +export const buyerApplyCouponService = async (req: Request, res: Response) => { + try { + const {couponCode} = req.body + + if (!couponCode) return res.status(400).json({ message: 'Coupon Code is required' }); + + const couponRepository = getRepository(Coupon); + const coupon = await couponRepository.findOne({ + where: { code: couponCode }, + relations: ['product'], + }); + + if(!coupon) return res.status(404).json({message: 'Invalid Coupon Code'}); + + if(coupon){ + if(coupon.expirationDate && coupon.expirationDate < new Date()){ + return res.status(400).json({message: 'Coupon is expired'}); + } + + if(coupon.usageTimes == coupon.maxUsageLimit){ + return res.status(400).json({message: 'Coupon Discount Ended'}); + } + } + const couponProductId = coupon.product.id; + + const cartRepository = getRepository(Cart) + let cart = await cartRepository.findOne({where: { user: { id: req.user?.id },isCheckedOut: false }, + relations: ['items', 'items.product'], + }); + + if(!cart) return res.status(400).json({message: "You don't have a product in cart"}); + + const cartItemRepository = getRepository(CartItem); + const couponCartItem = await cartItemRepository.findOne({ + where: { + cart: { id: cart.id }, + product: { id: couponProductId }, + }, + relations: ['product'], + }); + + if(!couponCartItem) return res.status(404).json({message: 'No product in Cart with that coupon code'}); + + let amountReducted; + if(coupon.discountType === 'percentage'){ + const reduction = (couponCartItem.product.newPrice * coupon.discountRate)/ 100; + amountReducted = reduction; + couponCartItem.newPrice = couponCartItem.product.newPrice - reduction; + + await cartItemRepository.save(couponCartItem) + } + else { + amountReducted = coupon.discountRate; + couponCartItem.newPrice = couponCartItem.product.newPrice - amountReducted; + await cartItemRepository.save(couponCartItem) + } + + cart = await cartRepository.findOne({where: { id: cart.id}, + relations: ['items', 'items.product'], + }); + if(cart){ + cart.updateTotal(); + await cartRepository.save(cart); + } + + coupon.usageTimes +=1; + + if(req.user?.id){ + coupon.usedBy.push(req.user?.id); + } + + await couponRepository.save(coupon); + + return (res.status(200).json({message: `Coupon Code successfully activated discount on product: ${couponCartItem.product.name}`, amountDiscounted: amountReducted })); + + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } + }; \ No newline at end of file diff --git a/src/services/couponServices/createCouponService.ts b/src/services/couponServices/createCouponService.ts new file mode 100644 index 0000000..a824ddf --- /dev/null +++ b/src/services/couponServices/createCouponService.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; +import { validateCoupon } from '../../helper/couponValidator'; +import { User } from '../../entities/User'; +import { Product } from '../../entities/Product'; + +export const createCouponService = async (req: Request, res: Response) => { + try { + const { error } = validateCoupon(req.body); + if (error) { + console.log('Validation Error creating coupon:\n', error); + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const { code, discountRate, expirationDate, maxUsageLimit, discountType, product: productId } = req.body; + const { id: vendorId } = req.params; + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { id: vendorId } }); + if (!user) { + console.log('Error creating coupon: User not found', user); + return responseError(res, 404, 'User not found'); + } + + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ where: { id: productId } }); + if (!product) { + console.log('Error creating coupon: Product not found', product); + return responseError(res, 403, 'Product not found'); + } + + const couponRepository = getRepository(Coupon); + const existingCoupon = await couponRepository.findOne({ where: { code } }); + if (existingCoupon) { + return responseError(res, 402, 'Coupon code already exists'); + } + + const newCoupon = new Coupon(); + newCoupon.code = code; + newCoupon.discountRate = discountRate; + newCoupon.expirationDate = expirationDate; + newCoupon.maxUsageLimit = maxUsageLimit; + newCoupon.discountType = discountType; + newCoupon.product = product; + newCoupon.vendor = user; + + await couponRepository.save(newCoupon); + responseSuccess(res, 201, 'Coupon created successfully'); + } catch (error: any) { + console.log('Error creating coupon:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/couponServices/deleteCoupon.ts b/src/services/couponServices/deleteCoupon.ts new file mode 100644 index 0000000..c984d9e --- /dev/null +++ b/src/services/couponServices/deleteCoupon.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; + +export const deleteCouponService = async (req: Request, res: Response) => { + try { + const couponRepository = getRepository(Coupon); + const coupon = await couponRepository.findOne({ where: { code: req.body.code } }); + + if (!coupon) { + console.log('Invalid coupon.'); + return responseError(res, 404, 'Invalid coupon'); + } + + await couponRepository.remove(coupon); + + return responseSuccess(res, 200, 'Coupon deleted successfully'); + } catch (error: any) { + console.log('Error deleting coupon:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/couponServices/readCoupon.ts b/src/services/couponServices/readCoupon.ts new file mode 100644 index 0000000..47e12ea --- /dev/null +++ b/src/services/couponServices/readCoupon.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { Coupon } from '../../entities/coupon'; + +export const readCouponService = async (req: Request, res: Response) => { + try { + const { code } = req.params; + if (!code) return responseError(res, 400, 'coupon code is required'); + + const couponRepository = getRepository(Coupon); + const coupon = await couponRepository.findOne({ where: { code: code } }); + + if (!coupon) { + return responseError(res, 404, 'Invalid coupon'); + } + + return responseSuccess(res, 200, 'Coupon retrieved successfully', coupon); + } catch (error: any) { + console.log('Error retrieving coupon:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/couponServices/updateService.ts b/src/services/couponServices/updateService.ts new file mode 100644 index 0000000..26aeef6 --- /dev/null +++ b/src/services/couponServices/updateService.ts @@ -0,0 +1,59 @@ +import { Coupon } from '../../entities/coupon'; +import { Request, Response } from 'express'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { validateCouponUpdate } from '../../helper/couponValidator'; +import { Product } from '../../entities/Product'; + +export const updateCouponService = async (req: Request, res: Response) => { + try { + const { code } = req.params; + const { error } = validateCouponUpdate(req.body); + if (error) { + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const couponRepository = getRepository(Coupon); + const coupon = await couponRepository.findOne({ where: { code } }); + if (coupon) { + if (req.body.code !== undefined) { + const existtCoupon = await couponRepository.findOne({ where: { code: req.body.code } }); + if (existtCoupon) return responseError(res, 400, 'Coupon code already exists'); + if (req.body.code === coupon.code) return responseError(res, 400, 'Coupon code already up to date'); + coupon.code = req.body.code; + } + if (req.body.discountRate !== undefined) { + coupon.discountRate = req.body.discountRate; + } + if (req.body.expirationDate !== undefined) { + coupon.expirationDate = req.body.expirationDate; + } + if (req.body.maxUsageLimit !== undefined) { + coupon.maxUsageLimit = req.body.maxUsageLimit; + } + if (req.body.discountType !== undefined) { + coupon.discountType = req.body.discountType; + } + if (req.body.product !== undefined) { + const { id } = req.body.product; + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ where: { id } }); + if (!product) { + console.log('Error updating coupon: Product not found', product); + return responseError(res, 404, 'Product not found'); + } + + coupon.product = product; + } + + await couponRepository.save(coupon); + return responseSuccess(res, 200, 'Coupon updated successfully', coupon); + } else { + console.log('Error updating coupon: Coupon not found', coupon); + return responseError(res, 404, 'Coupon not found'); + } + } catch (error: any) { + console.log('Error while updating coupon:\n', error); + return responseServerError(res, error); + } +}; diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..8f560c3 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,35 @@ +export * from './userServices/sendResetPasswordLinkService'; +export * from './userServices/userPasswordResetService'; +export * from './userServices/userRegistrationService'; +export * from './userServices/userValidationService'; +export * from './userServices/userEnableTwoFactorAuth'; +export * from './userServices/userDisableTwoFactorAuth'; +export * from './userServices/userValidateOTP'; +export * from './userServices/userLoginService'; +export * from './userServices/userResendOTP'; +export * from './userServices/logoutServices'; +export * from './userServices/userProfileUpdateServices'; + +// Vendor product services +export * from './productServices/createProduct'; +export * from './productServices/updateProduct'; +export * from './productServices/removeProductImage'; +export * from './productServices/readProduct'; +export * from './productServices/deleteProduct'; +export * from './productServices/getRecommendedProductsService'; +export * from './productServices/listAllProductsService'; +export * from './productServices/productStatus'; +export * from './productServices/viewSingleProduct'; +export * from './productServices/searchProduct'; + +// Buyer wishlist services +export * from './wishListServices/addProduct'; +export * from './wishListServices/getProducts'; +export * from './wishListServices/removeProducts'; +export * from './wishListServices/clearAll'; + +// cart managment +export * from './cartServices/createCart'; +export * from './cartServices/readCart'; +export * from './cartServices/removeProductInCart'; +export * from './cartServices/clearCart'; diff --git a/src/services/orderServices/createOrder.ts b/src/services/orderServices/createOrder.ts new file mode 100644 index 0000000..038d796 --- /dev/null +++ b/src/services/orderServices/createOrder.ts @@ -0,0 +1,131 @@ +import { Request, Response } from 'express'; +import { getRepository, getManager } from 'typeorm'; +import { Order } from '../../entities/Order'; +import { OrderItem } from '../../entities/OrderItem'; +import { Product } from '../../entities/Product'; +import { User } from '../../entities/User'; +import { Cart } from '../../entities/Cart'; +import { Transaction } from '../../entities/transaction'; +import { responseError, sendErrorResponse, sendSuccessResponse } from '../../utils/response.utils'; +import sendMail from '../../utils/sendOrderMail'; + +export const createOrderService = async (req: Request, res: Response) => { + const { cartId, address } = req.body; + const buyerId = req.user?.id; + + try { + const userRepository = getRepository(User); + const productRepository = getRepository(Product); + const cartRepository = getRepository(Cart); + + const buyer = await userRepository.findOne({ where: { id: buyerId } }); + if (!buyer) { + return responseError(res, 404, 'Buyer not found'); + } + + const cart = await cartRepository.findOne({ + where: { + user: { + id: buyerId, + }, + isCheckedOut: false, + }, + relations: ['items', 'items.product', 'user'], + }); + + if (!cart || cart.items.length === 0) { + return sendErrorResponse(res, 400, 'Cart is empty or already checked out'); + } + + let totalPrice = 0; + const orderItems: OrderItem[] = []; + + for (const item of cart.items) { + const product = item.product; + + if (product.quantity < item.quantity) { + return sendErrorResponse(res, 400, `Not enough ${product.name} in stock`); + } + + totalPrice += product.newPrice * item.quantity; + product.quantity -= item.quantity; + + const orderItem = new OrderItem(); + orderItem.product = product; + orderItem.price = product.newPrice; + orderItem.quantity = item.quantity; + orderItems.push(orderItem); + } + + if (!buyer.accountBalance || buyer.accountBalance < totalPrice) { + return sendErrorResponse(res, 400, 'Not enough funds to perform this transaction'); + } + + const previousBalance = buyer.accountBalance; + buyer.accountBalance -= totalPrice; + const currentBalance = buyer.accountBalance; + + const newOrder = new Order(); + newOrder.buyer = buyer; + newOrder.totalPrice = totalPrice; + newOrder.orderItems = orderItems; + newOrder.quantity = cart.items.reduce((acc, item) => acc + item.quantity, 0); + newOrder.orderDate = new Date(); + newOrder.address = `${address.country}, ${address.city}, ${address.street}`; + + await getManager().transaction(async transactionalEntityManager => { + for (const item of cart.items) { + const product = item.product; + product.quantity -= item.quantity; + await transactionalEntityManager.save(Product, product); + } + + await transactionalEntityManager.save(User, buyer); + + await transactionalEntityManager.save(Order, newOrder); + for (const orderItem of orderItems) { + orderItem.order = newOrder; + await transactionalEntityManager.save(OrderItem, orderItem); + } + + const orderTransaction = new Transaction(); + orderTransaction.user = buyer; + orderTransaction.order = newOrder; + orderTransaction.amount = totalPrice; + orderTransaction.previousBalance = previousBalance; + orderTransaction.currentBalance = currentBalance; + orderTransaction.type = 'debit'; + orderTransaction.description = 'Purchase of products'; + await transactionalEntityManager.save(Transaction, orderTransaction); + + cart.isCheckedOut = true; + await transactionalEntityManager.save(Cart, cart); + }); + + const orderResponse = { + fullName: `${newOrder.buyer.firstName} ${newOrder.buyer.lastName}`, + email: newOrder.buyer.email, + products: orderItems.map(item => ({ + name: item.product.name, + newPrice: item.price, + quantity: item.quantity, + })), + totalAmount: newOrder.totalPrice, + quantity: newOrder.quantity, + orderDate: newOrder.orderDate, + address: newOrder.address, + }; + + const message = { + subject: 'Order created successfully', + ...orderResponse + }; + + await sendMail(message); + + return sendSuccessResponse(res, 201, 'Order created successfully', orderResponse); + } catch (error) { + console.error('Error creating order:', error); + return sendErrorResponse(res, 500, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/orderServices/getOrderService.ts b/src/services/orderServices/getOrderService.ts new file mode 100644 index 0000000..18e0664 --- /dev/null +++ b/src/services/orderServices/getOrderService.ts @@ -0,0 +1,65 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError } from '../../utils/response.utils'; +import { Order } from '../../entities/Order'; +import { OrderItem } from '../../entities/OrderItem'; + + +// Example usage: + + +export const getOrdersService = async (req: Request, res: Response) => { + try { + const orderRepository = getRepository(Order); + const buyerId = req.user?.id; + + const orders = await orderRepository.find({ + where: { + buyer: { + id: buyerId, + } + }, + relations: ['buyer', 'orderItems', 'orderItems.product'], + order: { + createdAt: 'DESC', // Order by creation date, most recent first + }, + }); + + if (!orders || orders.length === 0) { + return responseSuccess(res, 404, `You haven't made any orders yet`, { orders: [] }); + } + + const sanitezedResponse = orders.map(order => ({ + id: order.id, + totalPrice: order.totalPrice, + orderStatus: order.orderStatus, + quantity: order.quantity, + address: order.address, + orderDate: order.orderDate, + createdAt: order.createdAt, + updatedAt: order.updatedAt, + buyer: { + id: order.buyer.id, + firstName: order.buyer.firstName, + lastName: order.buyer.lastName, + accountBalance: order.buyer.accountBalance + }, + orderItems: order.orderItems.map((item: OrderItem) => ({ + id: item.id, + price: item.price, + quantity: item.quantity, + product: { + id: item.product.id, + name: item.product.name, + description: item.product.description, + images: item.product.images, + price: item.product.newPrice, + expirationDate: item.product.expirationDate, + } + })) + })); + responseSuccess(res, 200, 'Orders retrieved successfully', { orders: sanitezedResponse }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/orderServices/getOrderTransactionHistory.ts b/src/services/orderServices/getOrderTransactionHistory.ts new file mode 100644 index 0000000..74ae473 --- /dev/null +++ b/src/services/orderServices/getOrderTransactionHistory.ts @@ -0,0 +1,44 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { Transaction } from '../../entities/transaction'; +import { sendErrorResponse, sendSuccessResponse } from '../../utils/response.utils'; +import { OrderItem } from '../../entities/OrderItem'; + +export const getTransactionHistoryService = async (req: Request, res: Response) => { + const userId = req.user?.id; + + try { + const transactionRepository = getRepository(Transaction); + const transactions = await transactionRepository.find({ + where: { user: { id: userId } }, + order: { createdAt: 'DESC' }, + relations: ['order'], + }); + + if (!transactions || transactions.length === 0) { + return sendErrorResponse(res, 404, 'No transaction history found'); + } + + const transactionHistory = transactions.map(transaction => ({ + id: transaction.id, + amount: transaction.amount, + type: transaction.type, + previousBalance: transaction.previousBalance, + currentBalance: transaction.currentBalance, + description: transaction.description, + createdAt: transaction.createdAt, + order: transaction.order + ? { + id: transaction.order.id, + totalPrice: transaction.order.totalPrice, + orderDate: transaction.order.orderDate, + address: transaction.order.address, + } + : null, + })); + + return sendSuccessResponse(res, 200, 'Transaction history retrieved successfully', transactionHistory); + } catch (error) { + return sendErrorResponse(res, 500, (error as Error).message); + } +}; diff --git a/src/services/orderServices/updateOrderService.ts b/src/services/orderServices/updateOrderService.ts new file mode 100644 index 0000000..f29b47c --- /dev/null +++ b/src/services/orderServices/updateOrderService.ts @@ -0,0 +1,129 @@ +import { Request, Response } from 'express'; +import { getManager, EntityManager, Repository } from 'typeorm'; +import { Order } from '../../entities/Order'; +import { Product } from '../../entities/Product'; +import { User } from '../../entities/User'; +import { OrderItem } from '../../entities/OrderItem'; +import { Transaction } from '../../entities/transaction'; +import { responseError, sendErrorResponse, sendSuccessResponse } from '../../utils/response.utils'; +import sendMail from '../../utils/sendOrderMail'; +interface OrderStatusType { + orderStatus: 'order placed' | 'cancelled' | 'awaiting shipment' | 'in transit' | 'delivered' | 'received' | 'returned'; +} +export const updateOrderService = async (req: Request, res: Response) => { + const { orderId } = req.params; + const { orderStatus } = req.body; + + try { + await getManager().transaction(async (transactionalEntityManager: EntityManager) => { + const orderRepository: Repository = transactionalEntityManager.getRepository(Order); + const productRepository: Repository = transactionalEntityManager.getRepository(Product); + const userRepository: Repository = transactionalEntityManager.getRepository(User); + const orderItemRepository: Repository = transactionalEntityManager.getRepository(OrderItem); + const transactionRepository: Repository = transactionalEntityManager.getRepository(Transaction); + + const buyerId = req.user?.id; + if (!buyerId) { + throw new Error('Unauthorized'); + } + + // Fetch order and related entities + const order: Order | null = await orderRepository.findOne({ + where: { id: orderId, buyer: { id: buyerId } }, + relations: ['orderItems', 'orderItems.product', 'buyer'], + }); + + if (!order) { + return sendErrorResponse(res, 404, "Order not found"); + } + // Check if order can be updated + if (isOrderFinalStatus(order.orderStatus)) { + return sendErrorResponse(res, 401, `Order cannot be updated once it is ${order.orderStatus}`); + } + + // Handle order status transitions + if (orderStatus !== undefined && order.orderStatus !== orderStatus) { + switch (orderStatus) { + case 'cancelled': + case 'returned': + if (order.orderStatus !== 'delivered') { + await processRefund(order, transactionalEntityManager); + } + break; + default: + break; + } + + order.orderStatus = orderStatus; + } + + // Save updated order status + await orderRepository.save(order); + + // Prepare response data + const orderResponse = { + fullName: `${order.buyer.firstName} ${order.buyer.lastName}`, + email: order.buyer.email, + products: order.orderItems.map((item: OrderItem) => ({ + name: item.product.name, + newPrice: item.price, + quantity: item.quantity, + })), + totalAmount: order.totalPrice, + quantity: order.quantity, + orderDate: order.orderDate, + address: order.address, + }; + + // Send email notification + const message = { + subject: 'Order updated successfully', + ...orderResponse + }; + await sendMail(message); + + // Respond with success + return sendSuccessResponse(res, 200, 'Order updated successfully', orderResponse); + }); + } catch (error) { + console.error('Error updating order:', error); + return sendErrorResponse(res, 500, (error as Error).message); + } +}; + +async function processRefund(order: Order, entityManager: EntityManager) { + const buyer = order.buyer; + + // Refund buyer + const previousBalance = buyer.accountBalance; + buyer.accountBalance += order.totalPrice; + const currentBalance = buyer.accountBalance; + await entityManager.save(buyer); + + // Record refund transaction + const refundTransaction = new Transaction(); + refundTransaction.user = buyer; + refundTransaction.order = order; + refundTransaction.amount = order.totalPrice; + refundTransaction.previousBalance = previousBalance; + refundTransaction.currentBalance = currentBalance; + refundTransaction.type = 'credit'; + refundTransaction.description = 'Refund for cancelled or returned order'; + await entityManager.save(refundTransaction); + + // Return products to store + for (const orderItem of order.orderItems) { + const product = orderItem.product; + product.quantity += orderItem.quantity; + await entityManager.save(product); + } + + // Clear order details + order.orderItems = []; + order.totalPrice = 0; + order.quantity = 0; +} + +function isOrderFinalStatus(status: string): boolean { + return ['cancelled', 'delivered', 'returned'].includes(status); +} \ No newline at end of file diff --git a/src/services/productServices/createProduct.ts b/src/services/productServices/createProduct.ts new file mode 100644 index 0000000..668ddd2 --- /dev/null +++ b/src/services/productServices/createProduct.ts @@ -0,0 +1,104 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { validateProduct } from '../../helper/productValidator'; +import cloudinary from '../../utils/cloudinary'; +import { User } from '../../entities/User'; +import { Category } from '../../entities/Category'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const createProductService = async (req: Request, res: Response) => { + try { + const { error } = validateProduct(req.body); + if (error !== undefined) { + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const existingProduct = await getRepository(Product).findOne({ + where: { + name: req.body.name, + vendor: { + id: req.user?.id, + }, + }, + }); + + if (existingProduct) { + return res.status(409).json({ status: 'error', error: 'Its looks like Product already exists' }); + } + + const files: any = req.files; + + if (files.length < 2) { + return res.status(400).json({ status: 'error', error: 'Please upload more than one image' }); + } + if (files.length > 6) { + return res.status(400).json({ status: 'error', error: 'Product cannot have more than 6 images' }); + } + + const imageUrls: string[] = []; + for (const file of files) { + const image = file.path; + const link = await cloudinary.uploader.upload(image); + imageUrls.push(link.secure_url); + } + + const product = new Product(); + product.name = req.body.name; + product.description = req.body.description; + product.newPrice = req.body.newPrice; + product.quantity = req.body.quantity; + product.images = imageUrls; + + if (req.body.expirationDate) { + product.expirationDate = req.body.expirationDate; + } + product.vendor = req.user as User; + + const categoryRepository = getRepository(Category); + let categories = []; + if (typeof req.body.categories === 'string') { + let category = await categoryRepository.findOne({ + where: { name: req.body.categories.toLowerCase() }, + }); + if (!category) { + category = new Category(); + category.name = req.body.categories.toLowerCase(); + category = await categoryRepository.save(category); + } + categories.push(category); + } else { + categories = await Promise.all( + req.body.categories.map(async (categoryName: string) => { + let category = await categoryRepository.findOne({ where: { name: categoryName.toLowerCase() } }); + if (!category) { + category = new Category(); + category.name = categoryName.toLowerCase(); + await categoryRepository.save(category); + } + return category; + }) + ); + } + product.categories = categories; + + const productRepository = getRepository(Product); + const savedProduct = await productRepository.save(product); + + product.vendor = product.vendor.id as unknown as User; + return res.status(201).json({ + status: 'success', + data: { + message: 'Product created successfully', + product: { ...savedProduct }, + }, + }); + } catch (error) { + res.status(400).json({ message: (error as Error).message }); + } +}; diff --git a/src/services/productServices/deleteProduct.ts b/src/services/productServices/deleteProduct.ts new file mode 100644 index 0000000..43ec3d1 --- /dev/null +++ b/src/services/productServices/deleteProduct.ts @@ -0,0 +1,32 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + + +export const deleteProductService = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const productRepository = getRepository(Product); + + const product = await productRepository.findOne({ + where: { + id: id, + vendor: { + id: req.user?.id + } + } + }); + + if (product) { + await productRepository.remove(product); + return responseSuccess(res, 200, 'Product successfully deleted'); + } + + return responseError(res, 404, 'Product not found'); + + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/getRecommendedProductsService.ts b/src/services/productServices/getRecommendedProductsService.ts new file mode 100644 index 0000000..19368e1 --- /dev/null +++ b/src/services/productServices/getRecommendedProductsService.ts @@ -0,0 +1,62 @@ +import { Request, Response } from "express"; +import { responseError, responseSuccess } from "../../utils/response.utils"; +import { getRepository } from "typeorm"; +import { Product } from "../../entities/Product"; + +interface conditionDoc { + categories: any[] | null; + vendor: any | null +} + +export const getRecommendedProductsService = async (req: Request, res: Response) => { + + try { + // Define pagination parameters + const page = req.query.page ? Number(req.query.page) : 1; + const limit = req.query.limit ? Number(req.query.limit) : 10; + const skip = (page - 1) * limit; + const condition: conditionDoc = { + categories: null, + vendor: null + }; + + if (req.query.categories) { + const categoryIds = Array.isArray(req.query.categories) ? req.query.categories : [req.query.categories]; + condition.categories = categoryIds; + }; + if (req.query.vendor) condition.vendor = req.query.vendor; + + const productRepository = getRepository(Product); + const productsQuery = productRepository.createQueryBuilder("product") + .leftJoinAndSelect("product.categories", "category") + .leftJoinAndSelect("product.vendor", "vendor") + .where("1 = 1"); + + if (condition.categories && condition.categories.length > 0) { + productsQuery.andWhere("category.id IN (:...categories)", { categories: condition.categories }); + } + if (condition.vendor) { + productsQuery.andWhere("vendor.id = :vendorId", { vendorId: condition.vendor }); + } + + const products = await productsQuery + .skip(skip) + .take(limit) + .getMany(); + if (products.length < 1) { + return responseSuccess(res, 200, `No products found for the specified ${condition.vendor ? 'vendor' : 'category'}`); + } + const sanitizedProducts = products.map(product => ({ + ...product, + vendor: { + firstName: product.vendor.firstName, + lastName: product.vendor.lastName, + phoneNumber: product.vendor.phoneNumber, + photoUrl: product.vendor.photoUrl + } + })); + return responseSuccess(res, 200, 'Products retrieved', { products: sanitizedProducts }); + } catch (error) { + return responseError(res, 400, (error as Error).message); + } +}; \ No newline at end of file diff --git a/src/services/productServices/listAllProductsService.ts b/src/services/productServices/listAllProductsService.ts new file mode 100644 index 0000000..8950abd --- /dev/null +++ b/src/services/productServices/listAllProductsService.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { validate } from 'uuid'; + +export const listAllProductsService = async (req: Request, res: Response) => { + try { + const page = req.query.page ? Number(req.query.page) : 1; + const limit = req.query.limit ? Number(req.query.limit) : 10; + const skip = (page - 1) * limit; + const category = req.query.category ; + + + const productRepository = getRepository(Product); + const products = await productRepository.find({ + where: { + categories: { + name: category as string + } + }, + skip, + take: limit, + relations: ["categories","vendor"], + select: { + vendor: { + id: true, firstName: true, lastName: true, + email: true, phoneNumber: true, photoUrl: true + } + } + } + ); + + if (products.length < 1) { + return responseSuccess(res, 200, 'No products found'); + } + + return responseSuccess(res, 200, 'Products retrieved', { products }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/productStatus.ts b/src/services/productServices/productStatus.ts new file mode 100644 index 0000000..16509c3 --- /dev/null +++ b/src/services/productServices/productStatus.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseSuccess, responseError, responseServerError } from '../../utils/response.utils'; + +export const productStatusServices = async (req: Request, res: Response) => { + try { + const { isAvailable } = req.body; + const availability = isAvailable; + const { id } = req.params; + + if (availability === undefined) { + console.log('Error: Please fill all the required fields'); + return responseError(res, 401, 'Please fill all t he required fields'); + } + + const userRepository = getRepository(User); + const user = await userRepository.findOne({ where: { id: req.user?.id } }); + + if (!user) { + responseError(res, 404, 'User not found'); + return; + } + + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ where: { id: id } }); + + if (!product) return responseError(res, 404, 'Product not found'); + + const hasProduct = await productRepository.findOne({ + where: { + id, + vendor: { + id: req.user?.id, + }, + }, + relations: ['vendor'], + }); + + if (!hasProduct) { + return responseError(res, 404, 'Product not found in your stock'); + } + + if (hasProduct.expirationDate && hasProduct.expirationDate < new Date()) { + hasProduct.isAvailable = false; + await productRepository.save(hasProduct); + return responseSuccess(res, 201, 'Product status is set to false because it is expired.'); + } else if (hasProduct.quantity < 1) { + product.isAvailable = false; + await productRepository.save(hasProduct); + return responseSuccess(res, 202, 'Product status is set to false because it is out of stock.'); + } + + if (hasProduct.isAvailable === isAvailable) { + console.log('Error: Product status is already updated'); + responseError(res, 400, 'Product status is already up to date'); + return; + } + + hasProduct.isAvailable = isAvailable; + await productRepository.save(hasProduct); + + return responseSuccess(res, 200, 'Product status updated successfully'); + } catch (error) { + console.log('Error: Product status is not update due to this error:\n', error); + return responseServerError(res, 'Sorry, Something went wrong. Try again later.'); + } +}; diff --git a/src/services/productServices/readProduct.ts b/src/services/productServices/readProduct.ts new file mode 100644 index 0000000..5c9257c --- /dev/null +++ b/src/services/productServices/readProduct.ts @@ -0,0 +1,70 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError, responseSuccess } from '../../utils/response.utils'; + +export const readProductsService = async (req: Request, res: Response) => { + try { + // Define pagination parameters + const page = req.query.page ? Number(req.query.page) : 1; + const limit = req.query.limit ? Number(req.query.limit) : 10; + const skip = (page - 1) * limit; + + // Retrieve products + const productRepository = getRepository(Product); + const products = await productRepository.find({ + where: { + vendor: { + id: req.user?.id, + }, + }, + skip, + take: limit, + relations: ['categories', 'vendor'], + select: { + vendor: { + id: true, firstName: true, lastName: true, + email: true, phoneNumber: true, photoUrl: true + } + } + }); + + if (products.length < 1) { + return responseSuccess(res, 200, 'You have no products yet'); + } + return responseSuccess(res, 200, 'Products retrieved', { products }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; + +export const readProductService = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id: id, + vendor: { + id: req.user?.id, + }, + }, + relations: ['categories', 'vendor'], + select: { + vendor: { + id: true, firstName: true, lastName: true, + email: true, phoneNumber: true, photoUrl: true + } + } + }); + + if (!product) { + return responseError(res, 404, 'Product not found'); + } + + return responseSuccess(res, 200, 'Product retrieved', { product }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/removeProductImage.ts b/src/services/productServices/removeProductImage.ts new file mode 100644 index 0000000..2995593 --- /dev/null +++ b/src/services/productServices/removeProductImage.ts @@ -0,0 +1,54 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { User } from '../../entities/User'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const removeProductImageService = async (req: Request, res: Response) => { + const { image } = req.body; + + if (!image) { + return res.status(400).json({ status: 'error', error: 'Please provide an image to remove' }); + } + + const { id } = req.params; + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id, + vendor: { id: req.user?.id } + }, + relations: ['vendor'], + }); + + if (!product) { + return res.status(404).json({ status: 'error', error: 'Product not found' }); + } + + const index = product.images.indexOf(image); + + if (index === -1) { + return res.status(404).json({ status: 'error', error: 'Image not found' }); + } + + if (product.images.length === 2) { + return res.status(400).json({ status: 'error', error: 'Product must have at least two image' }); + } + + product.images.splice(index, 1); + await productRepository.save(product); + product.vendor = product.vendor.id as unknown as User; + + return res.status(200).json({ + status: 'success', + data: { + message: 'Image removed successfully', + product, + }, + }); +}; diff --git a/src/services/productServices/searchProduct.ts b/src/services/productServices/searchProduct.ts new file mode 100644 index 0000000..765f431 --- /dev/null +++ b/src/services/productServices/searchProduct.ts @@ -0,0 +1,45 @@ +import { Request, Response } from "express"; +import { getRepository, Like } from 'typeorm'; +import { Product } from '../../entities/Product'; + +interface SearchProductParams { + name?: string; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + page?: number; + limit?: number; +} + +export const searchProductService = async (params: SearchProductParams) => { + const { name, sortBy, sortOrder, page = 1, limit = 10 } = params; + + const productRepository = getRepository(Product); + let query = productRepository.createQueryBuilder('product'); + + if (name) { + query = query.where('product.name LIKE :name', { name: `%${name}%` }); + } + + if (sortBy && sortOrder) { + query = query.orderBy(`product.${sortBy}`, sortOrder as 'ASC' | 'DESC'); + } + + const skip = (page - 1) * limit; + + const [products, total] = await query + .skip(skip) + .take(limit) + .getManyAndCount(); + + const totalPages = Math.ceil(total / limit); + + return { + data: products, + pagination: { + totalItems: total, + currentPage: page, + totalPages, + itemsPerPage: limit, + }, + }; +}; diff --git a/src/services/productServices/updateProduct.ts b/src/services/productServices/updateProduct.ts new file mode 100644 index 0000000..409dd48 --- /dev/null +++ b/src/services/productServices/updateProduct.ts @@ -0,0 +1,117 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { validateProduct } from '../../helper/productValidator'; +import cloudinary from '../../utils/cloudinary'; +import { User } from '../../entities/User'; +import { Category } from '../../entities/Category'; +import { responseError } from '../../utils/response.utils'; + +declare module 'express' { + interface Request { + files?: any; + } +} + +export const updateProductService = async (req: Request, res: Response) => { + try { + const { error } = validateProduct(req.body); + if (error !== undefined) { + return res.status(400).json({ status: 'error', error: error?.details[0].message }); + } + + const { id } = req.params; + const productRepository = getRepository(Product); + const product = await productRepository.findOne({ + where: { + id, + vendor: { + id: req.user?.id, + }, + }, + relations: ['vendor'], + }); + + if (!product) { + return res.status(404).json({ status: 'error', error: 'Product not found' }); + } + + product.name = req.body.name; + product.description = req.body.description; + product.newPrice = req.body.newPrice; + + if (parseInt(req.body.quantity) === 0) { + product.isAvailable = false; + product.quantity = req.body.quantity; + } else { + product.isAvailable = true; + product.quantity = req.body.quantity; + } + + if (req.files) { + if (product.images.length + req.files.length > 6) { + return res.status(400).json({ status: 'error', error: 'Product cannot have more than 6 images' }); + } + + const imageUrls: string[] = []; + for (const image of req.files) { + const link = await cloudinary.uploader.upload(image.path); + imageUrls.push(link.secure_url); + } + product.images = [...product.images, ...imageUrls]; + } + + if (req.body.expirationDate) { + product.expirationDate = req.body.expirationDate; + } + + if (req.body.oldPrice) { + product.oldPrice = req.body.oldPrice; + } + + const categoryRepository = getRepository(Category); + let categories = []; + if (typeof req.body.categories === 'string') { + let category = await categoryRepository.findOne({ + where: { name: req.body.categories.toLowerCase() }, + }); + if (!category) { + category = new Category(); + category.name = req.body.categories.toLowerCase(); + category = await categoryRepository.save(category); + } + categories.push(category); + } else { + categories = await Promise.all( + req.body.categories.map(async (categoryName: string) => { + let category = await categoryRepository.findOne({ where: { name: categoryName.toLowerCase() } }); + if (!category) { + category = new Category(); + category.name = categoryName.toLowerCase(); + await categoryRepository.save(category); + } + return category; + }) + ); + } + + product.categories = categories; + + await productRepository.save(product); + + product.vendor = { + id: product.vendor.id, + name: product.vendor.firstName + ' ' + product.vendor.lastName, + } as unknown as User; + + return res.status(200).json({ + status: 'success', + data: { + message: 'Product updated successfully', + product, + }, + }); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/productServices/viewSingleProduct.ts b/src/services/productServices/viewSingleProduct.ts new file mode 100644 index 0000000..f956625 --- /dev/null +++ b/src/services/productServices/viewSingleProduct.ts @@ -0,0 +1,38 @@ +import { Request, Response } from 'express'; +import { Product } from '../../entities/Product'; +import { getRepository } from 'typeorm'; +import { responseError } from '../../utils/response.utils'; +import { validate } from 'uuid'; + + + +export const viewSingleProduct = async (req: Request, res: Response) => { + try { + const productId = req.params.id; + + if (!validate(productId)) { + return res.status(400).json({ status: 'error', message: 'Invalid product ID' }); + + } + if(productId){ + const products = getRepository(Product); + const product = await products.findOneBy({ id: productId }); + + + if (!product) { + return res.status(404).send({status:'error', message: 'Product not found'}); + + } + + if (product.expirationDate && new Date(product.expirationDate) < new Date()) { + return res.status(400).json({ status: 'error', message: 'Product expired' }); + + } + res.status(200).json({ status: 'success', product: product }); + } + + } catch (error) { + console.error('Error handling request:', error); + res.status(500).send('Error fetching product details'); + } +} \ No newline at end of file diff --git a/src/services/updateUserStatus/activateUserService.ts b/src/services/updateUserStatus/activateUserService.ts new file mode 100644 index 0000000..8ec1a8a --- /dev/null +++ b/src/services/updateUserStatus/activateUserService.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { sendEmail } from '../../utils/sendStatusMail'; + +enum UserStatus { + ACTIVE = 'active', + INACTIVE = 'suspended', +} + +export const activateUserService = async (req: Request, res: Response) => { + try { + const { email } = req.body; + const userRepository = getRepository(User); + + if (!email) { + return res.status(404).json({ error: 'Email is needed' }); + } + + const user = await userRepository.findOneBy({ email }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + if (user.status === 'active') { + return res.json({ message: 'User is already active' }); + } + + user.status = UserStatus.ACTIVE; + await userRepository.save(user); + + await sendEmail('User_Account_activated', { name: user.firstName, email: user.email }); + + return res.status(200).json({ message: 'User activated successfully', user }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/services/updateUserStatus/deactivateUserService.ts b/src/services/updateUserStatus/deactivateUserService.ts new file mode 100644 index 0000000..597c8f2 --- /dev/null +++ b/src/services/updateUserStatus/deactivateUserService.ts @@ -0,0 +1,39 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { sendEmail } from '../../utils/sendStatusMail'; + +enum UserStatus { + ACTIVE = 'active', + INACTIVE = 'suspended', +} + +export const deactivateUserService = async (req: Request, res: Response) => { + try { + const { email } = req.body; + const userRepository = getRepository(User); + + if (!email) { + return res.status(404).json({ error: 'Email is needed' }); + } + + const user = await userRepository.findOneBy({ email }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + if (user.status === 'suspended') { + return res.json({ message: 'User is already suspended' }); + } + + user.status = UserStatus.INACTIVE; + await userRepository.save(user); + + await sendEmail('User_Account_diactivated', { name: user.firstName, email: user.email }); + + return res.json({ message: 'User deactivated successfully', user }); + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/src/services/userServices/logoutServices.ts b/src/services/userServices/logoutServices.ts new file mode 100644 index 0000000..541c364 --- /dev/null +++ b/src/services/userServices/logoutServices.ts @@ -0,0 +1,18 @@ +import { Request, Response } from 'express'; + +// logout method +export const logoutService = async (req: Request, res: Response): Promise => { + try { + const token = req.cookies['token'] || null; + if (!token) { + res.status(400).json({ Message: 'Access denied. You must be logged in' }); + return; + } + + res.clearCookie('token'); + res.status(200).json({ Message: 'Logged out successfully' }); + } catch (error) { + console.error('Error logging out:', error); + res.status(500).json({ error: 'Sorry, Token required.' }); + } +}; diff --git a/src/services/userServices/sendResetPasswordLinkService.ts b/src/services/userServices/sendResetPasswordLinkService.ts new file mode 100644 index 0000000..f9b7dbf --- /dev/null +++ b/src/services/userServices/sendResetPasswordLinkService.ts @@ -0,0 +1,117 @@ +import { Request, Response } from 'express'; +import { responseError, responseServerError, responseSuccess } from '../../utils/response.utils'; +import nodemailer from 'nodemailer'; +import { getRepository } from 'typeorm'; +import { User } from '../../entities/User'; + +export const sendPasswordResetLinkService = async (req: Request, res: Response) => { + try { + const transporter = nodemailer.createTransport({ + host: process.env.HOST, + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: process.env.AUTH_EMAIL, + pass: process.env.AUTH_PASSWORD, + }, + }); + const email = req.query.email as string; + + if (!email) { + return responseError(res, 404, 'Missing required field'); + } + const userRepository = getRepository(User); + const existingUser = await userRepository.findOneBy({ email }); + if (!existingUser) { + return responseError(res, 404, 'User not found', existingUser); + } + const mailOptions: nodemailer.SendMailOptions = { + to: email, + subject: `Password reset link `, + html: ` + + + + + + Reset Password Email Template + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
 
 
+ + + + + + + + + + +
 
+

You have + requested to reset your password

+ +

+ We cannot simply send you your old password. A unique link to reset your + password has been generated for you. To reset your password, click the + following link and follow the instructions. +

+ Reset + Password +
 
+
 
+

© Knights Ecommerce

+
 
+
+ + + + `, + }; + + try { + const sendMail = await transporter.sendMail(mailOptions); + return responseSuccess(res, 200, 'Code sent on your email', sendMail); + } catch (error) { + return responseError(res, 500, 'Error occurred while sending email'); + } + } catch (error) { + return responseServerError(res, `Internal server error: `); + } +}; diff --git a/src/services/userServices/userDisableTwoFactorAuth.ts b/src/services/userServices/userDisableTwoFactorAuth.ts new file mode 100644 index 0000000..63729fd --- /dev/null +++ b/src/services/userServices/userDisableTwoFactorAuth.ts @@ -0,0 +1,29 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; + +export const userDisableTwoFactorAuth = async (req: Request, res: Response) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ status: 'error', message: 'Please provide your email' }); + } + + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + return res.status(404).json({ status: 'error', message: 'User not found' }); + } + + user.twoFactorEnabled = false; + await userRepository.save(user); + + return res.status(200).json({ status: 'success', message: 'Two factor authentication disabled successfully' }); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ status: 'error', message: error.message }); + } + } +}; diff --git a/src/services/userServices/userEnableTwoFactorAuth.ts b/src/services/userServices/userEnableTwoFactorAuth.ts new file mode 100644 index 0000000..16b36be --- /dev/null +++ b/src/services/userServices/userEnableTwoFactorAuth.ts @@ -0,0 +1,29 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; + +export const userEnableTwoFactorAuth = async (req: Request, res: Response) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ status: 'error', message: 'Please provide your email' }); + } + + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + return res.status(404).json({ status: 'error', message: 'User not found' }); + } + + user.twoFactorEnabled = true; + await userRepository.save(user); + + return res.status(200).json({ status: 'success', message: 'Two factor authentication enabled successfully' }); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ status: 'error', message: error.message }); + } + } +}; diff --git a/src/services/userServices/userIsOTPValid.ts b/src/services/userServices/userIsOTPValid.ts new file mode 100644 index 0000000..287954b --- /dev/null +++ b/src/services/userServices/userIsOTPValid.ts @@ -0,0 +1,26 @@ +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const is2FAValid = async (email: string, code: string) => { + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + if (!user) { + return [false, 'User not found']; + } + + if (user.twoFactorCode !== code) { + return [false, 'Invalid authentication code']; + } + + if (user.twoFactorCodeExpiresAt && user.twoFactorCodeExpiresAt < new Date()) { + return [false, 'Authentication code expired']; + } + + // Force 2FA code to expire after usage + user.twoFactorCodeExpiresAt = new Date(Date.now() - 10 * 60 * 1000); + await userRepository.save(user); + return [true]; +}; diff --git a/src/services/userServices/userLoginService.ts b/src/services/userServices/userLoginService.ts new file mode 100644 index 0000000..57633d0 --- /dev/null +++ b/src/services/userServices/userLoginService.ts @@ -0,0 +1,77 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { otpTemplate } from '../../helper/emailTemplates'; +import { sendOTPEmail } from './userSendOTPEmail'; +import { sendOTPSMS } from './userSendOTPMessage'; +import { start2FAProcess } from './userStartTwoFactorAuthProcess'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const userLoginService = async (req: Request, res: Response) => { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ status: 'error', message: 'Please provide an email and password' }); + } + + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + return res.status(404).json({ status: 'error', message: 'Incorrect email or password' }); + } + + if (!user.verified) { + return res.status(400).json({ status: 'error', message: 'Please verify your account' }); + } + + if (user.status === 'suspended') { + return res.status(400).json({ status: 'error', message: 'Your account has been suspended' }); + } + + const isPasswordValid = await bcrypt.compare(password, user.password); + if (!isPasswordValid) { + return res.status(401).json({ status: 'error', message: 'Incorrect email or password' }); + } + + if (!user.twoFactorEnabled) { + const token = jwt.sign( + { + id: user.id, + email: user.email, + userType: user.userType, + }, + process.env.JWT_SECRET as string, + { expiresIn: '24h' } + ); + + if (process.env.APP_ENV === 'production') { + res.cookie('token', token, { httpOnly: true, sameSite: false, secure: true }); + } else { + res.cookie('token', token, { httpOnly: true, sameSite: 'lax', secure: false }); + } + + return res.status(200).json({ + status: 'success', + data: { + token, + message: 'Login successful', + }, + }); + } + + const otpCode = await start2FAProcess(user.email); + const OTPEmailcontent = otpTemplate(user.firstName, otpCode.toString()); + await sendOTPEmail('Login OTP Code', user.email, OTPEmailcontent); + await sendOTPSMS(user.phoneNumber, otpCode.toString()); + return res.status(200).json({ + status: 'success', + data: { + message: 'Please provide the OTP sent to your email or phone', + }, + }); +}; diff --git a/src/services/userServices/userPasswordResetService.ts b/src/services/userServices/userPasswordResetService.ts new file mode 100644 index 0000000..8428f1a --- /dev/null +++ b/src/services/userServices/userPasswordResetService.ts @@ -0,0 +1,38 @@ +import bcrypt from 'bcrypt'; +import { Request, Response } from 'express'; +import { responseError, responseServerError, responseSuccess } from '../../utils/response.utils'; +import { getRepository } from 'typeorm'; +import { User } from '../../entities/User'; + +export const userPasswordResetService = async (req: Request, res: Response) => { + try { + const { email, userid } = req.query; + const { newPassword, confirmPassword } = req.body; + const mail: any = email; + const userId: any = userid; + const userRepository = getRepository(User); + if (!email || !userid) { + return responseError(res, 404, `Something went wrong while fetching your data`); + } + const existingUser = await userRepository.findOneBy({ email: mail, id: userId }); + if (!existingUser) { + return responseError(res, 404, `We can't find you data`); + } + + if (!newPassword || !confirmPassword) { + return responseError(res, 200, 'Please provide all required fields'); + } + if (newPassword !== confirmPassword) { + return responseError(res, 200, 'new password must match confirm password'); + } + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(newPassword, saltRounds); + + existingUser.password = hashedPassword; + const updadeUser = await userRepository.save(existingUser); + return responseSuccess(res, 201, 'Password updated successfully', updadeUser); + } catch (error) { + console.log('error: reseting password in password reset service'); + return responseServerError(res, 'Internal server error'); + } +}; diff --git a/src/services/userServices/userProfileUpdateServices.ts b/src/services/userServices/userProfileUpdateServices.ts new file mode 100644 index 0000000..c140e38 --- /dev/null +++ b/src/services/userServices/userProfileUpdateServices.ts @@ -0,0 +1,57 @@ +import { Request, Response } from 'express'; +import { responseError, responseSuccess } from '../../utils/response.utils'; +import { User, UserInterface } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { userProfileUpdate } from '../../controllers/authController'; + +declare module 'express' { + interface Request { + user?: Partial; + } +} + +export const userProfileUpdateServices = async (req: Request, res: Response) => { + try { + if (!req.body) { + return responseError(res, 401, 'body required'); + } + + const { firstName, lastName, gender, phoneNumber, photoUrl, email, id } = req.body; + + // Validate user input + if ( + !firstName.trim() && + !lastName.trim() && + !gender.trim() && + !phoneNumber.trim() && + !photoUrl.trim() && + !email.trim() && + !id.trim() + ) { + return responseError(res, 400, 'Fill all the field'); + } + + const userRepository = getRepository(User); + const existingUser = await userRepository.findOne({ + where: { email: req.body.email }, + }); + + if (!existingUser) { + return responseError(res, 401, 'User not found'); + } + if (existingUser.id !== id) { + return responseError(res, 403, 'You are not authorized to edit this profile.'); + } + + existingUser.firstName = firstName; + existingUser.lastName = lastName; + existingUser.gender = gender; + existingUser.phoneNumber = phoneNumber; + existingUser.photoUrl = photoUrl; + + await userRepository.save(existingUser); + return responseSuccess(res, 201, 'User Profile has successfully been updated'); + } catch (error) { + responseError(res, 400, (error as Error).message); + } +}; diff --git a/src/services/userServices/userRegistrationService.ts b/src/services/userServices/userRegistrationService.ts new file mode 100644 index 0000000..2d30fe4 --- /dev/null +++ b/src/services/userServices/userRegistrationService.ts @@ -0,0 +1,69 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import bcrypt from 'bcrypt'; +import { getRepository } from 'typeorm'; +import { responseError, responseServerError, responseSuccess } from '../../utils/response.utils'; +import sendMail from '../../utils/sendMail'; +import dotenv from 'dotenv'; +dotenv.config(); + +export const userRegistrationService = async (req: Request, res: Response) => { + const { firstName, lastName, email, password, gender, phoneNumber, userType } = req.body; + + // Validate user input + if (!firstName || !lastName || !email || !password || !gender || !phoneNumber) { + return responseError(res, 400, 'Please fill all the required fields'); + } + + const userRepository = getRepository(User); + + try { + // Check for existing user + const existingUser = await userRepository.findOneBy({ email }); + const existingUserNumber = await userRepository.findOneBy({ phoneNumber }); + + if (existingUser || existingUserNumber) { + return responseError(res, 409, 'Email or phone number already in use'); + } + + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + // Create user + const user = new User(); + user.firstName = firstName; + user.lastName = lastName; + user.email = email; + user.password = hashedPassword; + user.userType = userType; + user.gender = gender; + user.phoneNumber = phoneNumber; + + // Save user + await userRepository.save(user); + if (process.env.AUTH_EMAIL && process.env.AUTH_PASSWORD) { + const message = { + to: email, + from: process.env.AUTH_EMAIL, + subject: 'Welcome to the knights app', + text: `Welcome to the app, ${firstName} ${lastName}!`, + lastName: lastName, + firstName: firstName, + }; + const link = `http://localhost:${process.env.PORT}/user/verify/${user.id}`; + + sendMail(process.env.AUTH_EMAIL, process.env.AUTH_PASSWORD, message, link); + } else { + // return res.status(500).json({ error: 'Email or password for mail server not configured' }); + return responseError(res, 500, 'Email or password for mail server not configured'); + } + + return responseSuccess(res, 201, 'User registered successfully'); + } catch (error) { + if (error instanceof Error) { + return responseServerError(res, error.message); + } + + return responseServerError(res, 'Unknown error occurred'); + } +}; diff --git a/src/services/userServices/userResendOTP.ts b/src/services/userServices/userResendOTP.ts new file mode 100644 index 0000000..f728b31 --- /dev/null +++ b/src/services/userServices/userResendOTP.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { otpTemplate } from '../../helper/emailTemplates'; +import { sendOTPEmail } from './userSendOTPEmail'; +import { sendOTPSMS } from './userSendOTPMessage'; +import { start2FAProcess } from './userStartTwoFactorAuthProcess'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const userResendOtpService = async (req: Request, res: Response) => { + const { email } = req.body; + + if (!email) { + console.log('No email address provided'); + return res.status(400).json({ status: 'error', message: 'Please provide an email' }); + } + + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + if (!user) { + console.log('User not found'); + return res.status(404).json({ status: 'error', message: 'Incorrect email' }); + } + + const otpCode = await start2FAProcess(user.email); + if (!otpCode) throw new Error('Error generating OTP'); + const OTPEmailcontent = otpTemplate(user.firstName, otpCode.toString()); + if (!OTPEmailcontent) throw new Error('Error generating OTP email content'); + await sendOTPEmail('Login OTP', user.email, OTPEmailcontent); + if (process.env.APP_ENV !== 'test') { + await sendOTPSMS(user.phoneNumber, otpCode.toString()); + } + return res.status(200).json({ + status: 'success', + data: { + message: 'OTP sent successfully', + }, + }); +}; diff --git a/src/services/userServices/userSendOTPEmail.ts b/src/services/userServices/userSendOTPEmail.ts new file mode 100644 index 0000000..f80e39c --- /dev/null +++ b/src/services/userServices/userSendOTPEmail.ts @@ -0,0 +1,27 @@ +import nodemailer from 'nodemailer'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const sendOTPEmail = async (subject: string, email: string, content: any) => { + const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.AUTH_EMAIL, + pass: process.env.AUTH_PASSWORD, + }, + }); + + const mailOptions = { + from: `Knights E-commerce <${process.env.AUTH_EMAIL}>`, + to: email, + subject: subject, + html: content, + }; + + try { + const info = await transporter.sendMail(mailOptions); + } catch (error) { + console.log('Error occurred while sending email', error); + } +}; diff --git a/src/services/userServices/userSendOTPMessage.ts b/src/services/userServices/userSendOTPMessage.ts new file mode 100644 index 0000000..05a0758 --- /dev/null +++ b/src/services/userServices/userSendOTPMessage.ts @@ -0,0 +1,26 @@ +import dotenv from 'dotenv'; +import axios from 'axios'; + +dotenv.config(); + +export const sendOTPSMS = async (phone: string, code: string) => { + const data = { + to: `+25${phone}`, + text: `use this code to confirm your login. ${code}`, + sender: `${process.env.PINDO_SENDER}`, + }; + + const options = { + headers: { + 'Authorization': `Bearer ${process.env.PINDO_API_KEY}`, + 'Content-Type': 'application/json', + }, + }; + + try { + const response = await axios.post(`${process.env.PINDO_API_URL}`, data, options); + console.log('SMS sent:', response.data.sms_id); + } catch (error) { + console.error('Failed to send SMS:', error); + } +}; diff --git a/src/services/userServices/userStartTwoFactorAuthProcess.ts b/src/services/userServices/userStartTwoFactorAuthProcess.ts new file mode 100644 index 0000000..bf43e7f --- /dev/null +++ b/src/services/userServices/userStartTwoFactorAuthProcess.ts @@ -0,0 +1,19 @@ +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const start2FAProcess = async (email: string) => { + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + if (!user) { + return [false, 'User not found']; + } + + user.twoFactorCode = Math.floor(100000 + Math.random() * 900000).toString(); + const timeout = (parseInt(process.env.TWO_FA_MINS as string) || 3) * 60 * 1000; + user.twoFactorCodeExpiresAt = new Date(Date.now() + timeout); + await userRepository.save(user); + return user.twoFactorCode; +}; diff --git a/src/services/userServices/userValidateOTP.ts b/src/services/userServices/userValidateOTP.ts new file mode 100644 index 0000000..c26003f --- /dev/null +++ b/src/services/userServices/userValidateOTP.ts @@ -0,0 +1,47 @@ +import { Request, Response } from 'express'; +import { is2FAValid } from './userIsOTPValid'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import jwt from 'jsonwebtoken'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const userValidateOTP = async (req: Request, res: Response) => { + try { + const { email, otp } = req.body; + + if (!email || !otp) { + return res.status(400).json({ status: 'error', message: 'Please provide an email and OTP code' }); + } + const [isvalid, message] = await is2FAValid(email, otp); + + if (!isvalid) { + return res.status(403).json({ status: 'error', message }); + } + + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ email }); + + const token = jwt.sign( + { + id: user?.id, + email: user?.email, + userType: user?.userType, + }, + process.env.JWT_SECRET as string, + { expiresIn: '24h' } + ); + return res.status(200).json({ + status: 'success', + data: { + token, + message: 'Login successful', + }, + }); + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ status: 'error', message: error.message }); + } + } +}; diff --git a/src/services/userServices/userValidationService.ts b/src/services/userServices/userValidationService.ts new file mode 100644 index 0000000..a28ff4e --- /dev/null +++ b/src/services/userServices/userValidationService.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; + +export const userVerificationService = async (req: Request, res: Response) => { + const { id } = req.params; + + // Validate user input + if (!id) { + return res.status(400).json({ error: 'Missing user ID' }); + } + + const userRepository = getRepository(User); + const user = await userRepository.findOneBy({ id }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + user.verified = true; + + await userRepository.save(user); + + return res.status(200).send('

User verified successfully

'); +}; diff --git a/src/services/wishListServices/addProduct.ts b/src/services/wishListServices/addProduct.ts new file mode 100644 index 0000000..da3db89 --- /dev/null +++ b/src/services/wishListServices/addProduct.ts @@ -0,0 +1,60 @@ +import { Request, Response } from 'express'; +import { User } from '../../entities/User'; +import { getRepository } from 'typeorm'; +import { wishList } from '../../entities/wishList'; +import { Product } from '../../entities/Product'; + +export const addProductService = async (req:Request,res:Response)=>{ + try { + + const id = req.params.id; + const wishListRepository = getRepository(wishList); + const productRepository = getRepository(Product); + + + const product = await productRepository.findOne({where: { id }}); + + if(!product){ + return res.status(404).json({message: "Product not found"}); + } + + const productDetails = { + productId: product.id, + name: product.name, + image: product.images, + newPrice: product.newPrice, + vendorId: product.vendor + } + + const alreadyIn = await wishListRepository.findOne({where: {productId: id, buyer:{ id: req.user?.id} }}) + + if(alreadyIn){ + return res.status(401).json({ + data: { + message: 'Product Already in the wish list', + wishlistAdded: alreadyIn, + product: productDetails, + }, + }) + } + + const addNewProduct = new wishList(); + addNewProduct.productId = id; + addNewProduct.buyer = req.user as User; + + await wishListRepository.save(addNewProduct); + + addNewProduct.buyer = { id: addNewProduct.buyer.id } as unknown as User; + + return res.status(201).json({ + data: { + message: 'Product Added to wish list', + wishlistAdded: addNewProduct, + product: productDetails, + }, + }); + + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/src/services/wishListServices/clearAll.ts b/src/services/wishListServices/clearAll.ts new file mode 100644 index 0000000..88af3c6 --- /dev/null +++ b/src/services/wishListServices/clearAll.ts @@ -0,0 +1,20 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { wishList } from '../../entities/wishList'; + +export const clearAllProductService = async (req:Request,res:Response)=>{ + try { + const wishListRepository = getRepository(wishList); + const productsForBuyer = await wishListRepository.find({where: { buyer:{ id: req.user?.id} }}); + + if (productsForBuyer.length === 0) { + return res.status(404).json({ message: 'No products in wish list' }); + } + + await wishListRepository.remove(productsForBuyer); + return res.status(200).json({ message: 'All products removed successfully'}); + + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/src/services/wishListServices/getProducts.ts b/src/services/wishListServices/getProducts.ts new file mode 100644 index 0000000..107f3aa --- /dev/null +++ b/src/services/wishListServices/getProducts.ts @@ -0,0 +1,38 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { wishList } from '../../entities/wishList'; +import { Product } from '../../entities/Product'; + +export const getProductsService = async (req:Request,res:Response)=>{ + try { + const wishListRepository = getRepository(wishList); + const productRepository =getRepository(Product); + + const productsForBuyer = await wishListRepository.find({where: { buyer:{ id: req.user?.id} }}); + + if (productsForBuyer.length === 0) { + return res.status(404).json({ message: 'No products in wish list', products: productsForBuyer }); + } + + const buyerWishProducts = await Promise.all(productsForBuyer.map(async (product) => { + const productDetails = await productRepository.findOne({ where: { id: product.productId } }); + if(productDetails){ + return { + wishListDetails: product, + productInfo: { + productId: productDetails.id, + name: productDetails.name, + image: productDetails.images, + newPrice: productDetails.newPrice, + vendorId: productDetails.vendor + } + }; + } + })); + + return res.status(200).json({ message: 'Products retrieved', productsForBuyer: buyerWishProducts }); + + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/src/services/wishListServices/removeProducts.ts b/src/services/wishListServices/removeProducts.ts new file mode 100644 index 0000000..cb99c0f --- /dev/null +++ b/src/services/wishListServices/removeProducts.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import { wishList } from '../../entities/wishList'; + +export const removeProductService = async (req:Request,res:Response)=>{ + try { + + const id = parseInt(req.params.id); + const wishListRepository = getRepository(wishList); + + const product = await wishListRepository.findOne({where: { id }}); + + if(!product){ + return res.status(404).json({message: "Product not found in wish list"}); + } + + await wishListRepository.remove(product); + return res.status(200).json({ message: "Product removed from wish list" }); + + } catch (error) { + return res.status(500).json({ error: 'Internal server error' }); + } +} \ No newline at end of file diff --git a/src/startups/dbConnection.ts b/src/startups/dbConnection.ts new file mode 100644 index 0000000..44887d8 --- /dev/null +++ b/src/startups/dbConnection.ts @@ -0,0 +1,12 @@ +import { createConnection } from 'typeorm'; + +const dbConnection = async () => { + try { + const connection = await createConnection(); + console.log(`Connected to the ${process.env.APP_ENV} database`); + return connection; + } catch (error) { + console.error('Error connecting to the database:', error); + } +}; +export { dbConnection }; diff --git a/src/startups/docs.ts b/src/startups/docs.ts new file mode 100644 index 0000000..fa257fc --- /dev/null +++ b/src/startups/docs.ts @@ -0,0 +1,16 @@ +import { type Express } from 'express'; +import swaggerUI from 'swagger-ui-express'; +import swaggerSpec from '../configs/swagger'; +import fs from 'fs'; + +export const addDocumentation = (app: Express): void => { + app.use( + '/api/v1/docs', + swaggerUI.serve, + swaggerUI.setup(swaggerSpec, { + customCss: ` + ${fs.readFileSync('./src/docs/swaggerDark.css')} + `, + }) + ); +}; diff --git a/src/startups/getSwaggerServer.ts b/src/startups/getSwaggerServer.ts new file mode 100644 index 0000000..efe12fa --- /dev/null +++ b/src/startups/getSwaggerServer.ts @@ -0,0 +1,13 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +function getSwaggerServer (): string { + if (process.env.SWAGGER_SERVER !== undefined) { + return process.env.SWAGGER_SERVER; + } + + return `http://localhost:${process.env.PORT}/api/v1`; +} + +export { getSwaggerServer }; diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 0000000..91874e3 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,72 @@ +/* eslint-disable camelcase */ +import passport from 'passport'; +import { Strategy } from "passport-google-oauth20"; +import { User } from '../entities/User'; +import { getRepository } from 'typeorm'; +import bcrypt from 'bcrypt'; +import "../utils/auth"; +passport.use( + new Strategy( + { + clientID: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + callbackURL: 'http://localhost:6890/user/auth/google/callback/', + scope: ['email', 'profile'], + }, + async (accessToken: any, refreshToken: any, profile: any, cb: any) => { + const userRepository = getRepository(User); + const { family_name, + name, + picture, + email, + email_verified + + } = profile._json; + const { familyName, givenName } = profile.name; + + if (email || givenName || family_name || picture) { + try { + // Check for existing user + const existingUser = await userRepository.findOneBy({ email }); + + if (existingUser) { + return await cb(null, existingUser); + } + const saltRounds = 10; + const hashedPassword = await bcrypt.hash("password", saltRounds); + const newUser = new User(); + newUser.firstName = givenName; + newUser.lastName = family_name ?? familyName ?? "undefined"; + newUser.email = email; + newUser.userType = 'Buyer'; + newUser.photoUrl = picture; + newUser.gender = "Not specified"; + newUser.phoneNumber = "Not specified"; + newUser.password = hashedPassword; + newUser.verified = email_verified; + + await userRepository.save(newUser); + return await cb(null, newUser); + } catch (error) { + console.error(error); + return await cb(error, null); + } + } + return await cb(null, profile, { message: 'Missing required profile information' }); + } + ) +); + +passport.serializeUser((user: any, cb) => { + cb(null, user.id); +}); + +passport.deserializeUser(async (id: any, cb) => { + const userRepository = getRepository(User); + try { + const user = await userRepository.findOneBy({id}); + cb(null, user); + } catch (error) { + cb(error); + } +}); diff --git a/src/utils/cloudinary.ts b/src/utils/cloudinary.ts new file mode 100644 index 0000000..18b8db3 --- /dev/null +++ b/src/utils/cloudinary.ts @@ -0,0 +1,13 @@ +/* eslint-disable camelcase */ +import { v2 as cloudinary } from 'cloudinary'; +import dotenv from 'dotenv'; + +dotenv.config(); + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDNARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +export default cloudinary; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..27e92ae --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,20 @@ +// export all utils +/** + * Format a number as a currency string. + * @param amount - The amount to format. + * @param currency - The currency code (e.g., 'USD', 'EUR'). Defaults to 'USD'. + * @returns The formatted currency string. + */ +export function formatMoney(amount: number , currency: string = 'RWF'): string { + return amount.toLocaleString('en-US', { style: 'currency', currency }); + } + /** + * Format a date string into a more readable format. + * @param dateString - The date string to format. + * @returns The formatted date string. + */ + export function formatDate(dateString: Date): string { + const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric' }; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', options); + } \ No newline at end of file diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..d9c996a --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,59 @@ +import { createLogger, format, transports } from 'winston'; + +// Define custom logging levels and colors +const logLevels = { + levels: { + error: 0, + warn: 1, + info: 2, + http: 3, + debug: 4, + }, + colors: { + error: 'red', + warn: 'yellow', + info: 'green', + http: 'magenta', + debug: 'cyan', + }, +}; + +// Configure Winston logger +const logger = createLogger({ + level: 'info', + levels: logLevels.levels, + format: format.combine( + format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + format.errors({ stack: true }), + format.splat(), + format.json() + ), + transports: [ + new transports.Console({ + format: format.combine( + format.colorize(), // Enable colorization + format.printf(({ level, message, timestamp }) => { + const color = logLevels.colors[level as keyof typeof logLevels.colors] || 'white'; + return `\x1b[${color}m${timestamp} [${level}]: ${message}\x1b[0m`; // Apply color to log message + }) + ), + }), + ], +}); + +// Add colors to the logger instance +const { combine, timestamp, printf, colorize } = format; +logger.add( + new transports.Console({ + format: combine( + colorize(), + timestamp(), + printf(({ level, message, timestamp }) => { + const color = logLevels.colors[level as keyof typeof logLevels.colors] || 'white'; + return `\x1b[${color}m${timestamp} [${level}]: ${message}\x1b[0m`; // Apply color to log message + }) + ), + }) +); + +export default logger; diff --git a/src/utils/response.utils.ts b/src/utils/response.utils.ts new file mode 100644 index 0000000..f0e5513 --- /dev/null +++ b/src/utils/response.utils.ts @@ -0,0 +1,55 @@ +import { Response } from 'express'; +import jsend from 'jsend'; + +interface ApiResponse { + code: number; + resp_msg: string; + data?: any; +} + +export const responseSuccess = ( + res: Response, + statusCode: number, + message: string, + data?: any +): Response => { + return res.status(statusCode).json( + jsend.success({ + code: statusCode, + message, + ...data, + }) + ); +}; + +export const responseError = ( + res: Response, + statusCode: number, + message: string, + data?: any +): Response => { + return res.status(statusCode).json( + jsend.error({ + code: statusCode, + message, + data, + }) + ); +}; + +export const responseServerError = (res: Response, error: string): Response => { + return res.status(500).json( + jsend.error({ + code: 999, + message: `There is a problem with the server!: ${error}`, + }) + ); +}; + +export const sendSuccessResponse = (res: Response, statusCode: number, message: string, data?: any) => { + return res.status(statusCode).json({ status: 'success', message, data }); +}; + +export const sendErrorResponse = (res: Response, statusCode: number, message: string) => { + return res.status(statusCode).json({ status: 'error', message }); +}; diff --git a/src/utils/roles.ts b/src/utils/roles.ts new file mode 100644 index 0000000..5d95634 --- /dev/null +++ b/src/utils/roles.ts @@ -0,0 +1,5 @@ +export const roles = { + admin: 'ADMIN', + vendor: 'VENDOR', + buyer: 'BUYER', +}; diff --git a/src/utils/sendMail.ts b/src/utils/sendMail.ts new file mode 100644 index 0000000..0836765 --- /dev/null +++ b/src/utils/sendMail.ts @@ -0,0 +1,90 @@ +import nodemailer from 'nodemailer'; + +const sendMail = async ( + userAuth: string, + passAuth: string, + message: { from: string; to: string; subject: string; text: string; firstName: string; lastName: string }, + link: string = '' +) => { + const transporter = nodemailer.createTransport({ + host: process.env.HOST, + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: userAuth, + pass: passAuth, + }, + }); + + const { from, to, subject, text, firstName, lastName } = message; + + const mailOptions = { + from: from, + to: to, + subject: subject, + text: text, + firstName: firstName, + lastName: lastName, + html: ` + + + + + + +
+

+ Hello ${firstName} ${lastName}, +

+
+

${text}

+

+

${link && `click here to verifie your account`}

+

This message is from: Knights Andela

+
+ +
+ + + `, + }; + + try { + const info = await transporter.sendMail(mailOptions); + } catch (error) { + console.log('Error occurred while sending email', error); + } +}; + +export default sendMail; diff --git a/src/utils/sendOrderMail.ts b/src/utils/sendOrderMail.ts new file mode 100644 index 0000000..a58fe09 --- /dev/null +++ b/src/utils/sendOrderMail.ts @@ -0,0 +1,214 @@ +import nodemailer from 'nodemailer'; +import { formatMoney, formatDate } from './index'; + +interface Product { + name: string; + newPrice: number; + quantity: number; +} + +interface Message { + subject: string; + fullName: string; + email: string; + products: Product[]; + totalAmount: number; + quantity: number; + orderDate: Date; + address: string; +} + +const sendMail = async (message: Message) => { + const transporter = nodemailer.createTransport({ + host: process.env.HOST, + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: process.env.AUTH_EMAIL as string, + pass: process.env.AUTH_PASSWORD as string, + }, + }); + + const { subject, fullName, email, products, totalAmount, + quantity, + orderDate, + address } = message; + + const mailOptions = { + to: email, + subject: subject, + html: ` + + + + + + + Order Details + + + + +
+ shoping image +

Order Success

+
+

User information

+ + + + + + + + + + + + + + + +
Full Name:Email:Address:
${fullName}${email}${address}
+ + + + + + + + + + + + + + + +
Order Date:Quantity:Total Amount:
${formatDate(orderDate)}${quantity}${formatMoney(totalAmount)}
+
+
+

Products

+ + + + + + + + ${products.map((product: Product) => ` + + + + + + + `).join('')} + + + + +
Product NameProduct PriceQuantityTotal
${product.name}${formatMoney(product.newPrice)}${product.quantity}${product.quantity * product.newPrice}
Total${totalAmount}
+
+ +
+ + + `, + }; + + try { + const info = await transporter.sendMail(mailOptions); + console.log('Email sent: ' + info.response); + } catch (error) { + console.log('Error occurred while sending email', error); + } +}; + +export default sendMail; \ No newline at end of file diff --git a/src/utils/sendOrderMailUpdated.ts b/src/utils/sendOrderMailUpdated.ts new file mode 100644 index 0000000..ed2cf83 --- /dev/null +++ b/src/utils/sendOrderMailUpdated.ts @@ -0,0 +1,214 @@ +import nodemailer from 'nodemailer'; +import { formatMoney, formatDate } from './index'; + +interface Product { + name: string; + newPrice: number; + quantity: number; +} + +interface Message { + subject: string; + fullName: string; + email: string; + products: Product[]; + totalAmount: number; + quantity: number; + orderDate: Date; + address: string; +} + +const sendMail = async (message: Message) => { + const transporter = nodemailer.createTransport({ + host: process.env.HOST, + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: process.env.AUTH_EMAIL as string, + pass: process.env.AUTH_PASSWORD as string, + }, + }); + + const { subject, fullName, email, products, totalAmount, + quantity, + orderDate, + address } = message; + + const mailOptions = { + to: email, + subject: subject, + html: ` + + + + + + + Your order details have been updated + + + + +
+ shoping image +

Order Updated

+
+

User information

+ + + + + + + + + + + + + + + +
Full Name:Email:Address:
${fullName}${email}${address}
+ + + + + + + + + + + + + + + +
Order Date:Quantity:Total Amount:
${formatDate(orderDate)}${quantity}${formatMoney(totalAmount)}
+
+
+

Products

+ + + + + + + + ${products.map((product: Product) => ` + + + + + + + `).join('')} + + + + +
Product NameProduct PriceQuantityTotal
${product.name}${formatMoney(product.newPrice)}${product.quantity}${product.quantity * product.newPrice}
Total${totalAmount}
+
+ +
+ + + `, + }; + + try { + const info = await transporter.sendMail(mailOptions); + console.log('Email sent: ' + info.response); + } catch (error) { + console.log('Error occurred while sending email', error); + } +}; + +export default sendMail; \ No newline at end of file diff --git a/src/utils/sendStatusMail.ts b/src/utils/sendStatusMail.ts new file mode 100644 index 0000000..0f363ac --- /dev/null +++ b/src/utils/sendStatusMail.ts @@ -0,0 +1,94 @@ +import { config } from 'dotenv'; +import nodemailer from 'nodemailer'; +import Mailgen from 'mailgen'; + +config(); + +interface IData { + email: string; + name: string; +} + +const EMAIL = process.env.AUTH_EMAIL; +const PASSWORD = process.env.AUTH_PASSWORD; + +export const sendEmail = async (type: string, data: IData) => { + if (EMAIL && PASSWORD) { + try { + const mailGenerator = new Mailgen({ + theme: 'default', + product: { + name: 'Knights', + link: 'https://mailgen.js/', + }, + }); + + const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: EMAIL, + pass: PASSWORD, + }, + }); + + let email; + let subject; + switch (type) { + case 'User_Account_diactivated': + email = { + body: { + name: data.name, + intro: 'Your account has been blocked due to violation of our terms and conditions.', + action: { + instructions: 'If you believe this is an error, please contact support at knights.andela@gmail.com.', + button: { + color: '#22BC66', + text: 'Contact Support', + link: 'mailto:knights.andela@gmail.com', + }, + }, + outro: 'Thank you for your understanding.', + }, + }; + subject = 'Account Suspended'; + break; + case 'User_Account_activated': + email = { + body: { + name: data.name, + intro: 'Your account has been unblocked.', + action: { + instructions: 'You can now access your account again.', + button: { + color: '#22BC66', + text: 'Access Account', + link: 'https://knights-e-commerce.com/login', + }, + }, + outro: 'If you did not request this action, please contact support immediately.', + }, + }; + subject = 'Account Unblocked'; + break; + default: + throw new Error('Invalid email type'); + } + + const html = mailGenerator.generate(email); + + const mailOptions = { + from: EMAIL, + to: data.email, + subject: subject, + html: html, + }; + + const info = await transporter.sendMail(mailOptions); + } catch (error) { + console.error('Error sending email:', error); + } + } else { + console.error('Email or password for mail server not configured'); + return; + } +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4665a6d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,108 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + /* Language and Environment */ + "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + "experimentalDecorators": true /* Enable experimental support for legacy experimental decorators. */, + "emitDecoratorMetadata": true /* Emit design-type metadata for decorated declarations in source files. */, + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + /* Modules */ + "module": "commonjs" /* Specify what module code is generated. */, + "rootDir": "./src" /* Specify the root folder within your source files. */, + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": [ + "node", + "jest", + "express", + "joi" + ] /* Specify type package names to be included without being referenced in a source file. */, + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + /* JavaScript Support */ + "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, + /* Type Checking */ + "strict": true /* Enable all strict type-checking options. */, + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] + } \ No newline at end of file