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 0000000..14aacbc Binary files /dev/null and b/src/__test__/test-assets/photo1.png differ diff --git a/src/__test__/test-assets/photo2.webp b/src/__test__/test-assets/photo2.webp new file mode 100644 index 0000000..4c5743d Binary files /dev/null and b/src/__test__/test-assets/photo2.webp differ diff --git a/src/__test__/userServices.test.ts b/src/__test__/userServices.test.ts new file mode 100644 index 0000000..b4e87f9 --- /dev/null +++ b/src/__test__/userServices.test.ts @@ -0,0 +1,230 @@ +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('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