From 618f6487ff4e26c682999ca3401d7c9125b2c161 Mon Sep 17 00:00:00 2001 From: EddyShimwa Date: Wed, 8 May 2024 19:38:59 +0200 Subject: [PATCH 1/3] implemented two-factor authentication for enhanced security implemented two-factor authentication for enhanced security check whether the usertype is vendor to proceed with 2FA test the 2fa authentication --- .github/workflows/workflow_for_ecomm.yml | 2 + package-lock.json | 90 +++++++++++++++++++ package.json | 10 ++- src/__test__/userController.test.ts | 76 +++++++++++++--- src/controller/userController.ts | 67 +++++++++++--- src/controller/verification.js | 0 src/database/models/userModel.ts | 4 + src/docs/2Fadocs.ts | 55 ++++++++++++ src/emails/mailer.ts | 30 +++++++ src/emails/templates/2fa.html | 109 +++++++++++++++++++++++ src/routes/userRoutes.ts | 4 +- 11 files changed, 419 insertions(+), 28 deletions(-) create mode 100644 src/controller/verification.js create mode 100644 src/docs/2Fadocs.ts create mode 100644 src/emails/mailer.ts create mode 100644 src/emails/templates/2fa.html diff --git a/.github/workflows/workflow_for_ecomm.yml b/.github/workflows/workflow_for_ecomm.yml index 298cd09e..6ed0974f 100644 --- a/.github/workflows/workflow_for_ecomm.yml +++ b/.github/workflows/workflow_for_ecomm.yml @@ -55,3 +55,5 @@ jobs: FACEBOOK_APP_SECRET: ${{ secrets.FACEBOOK_APP_SECRET }} FACEBOOK_CALLBACK_URL: ${{ secrets.FACEBOOK_CALLBACK_URL }} COOKIES_KEY: ${{ secrets.COOKIES_KEY }} + EMAIL_USER: ${{ secrets.EMAIL_USER }} + EMAIL_PASS: ${{ secrets.EMAIL_PASS }} diff --git a/package-lock.json b/package-lock.json index b778d18b..a1c7feb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,9 @@ "joi": "^17.13.1", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "nodemailer": "^6.9.13", "nodemon": "^3.1.0", + "otplib": "^12.0.1", "passport": "^0.7.0", "passport-facebook": "^3.0.0", "passport-google-oauth": "^2.0.0", @@ -38,6 +40,7 @@ "typeorm": "^0.3.20" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/bcryptjs": "^2.4.6", "@types/cookie-session": "^2.0.49", "@types/cors": "^2.8.17", @@ -45,6 +48,7 @@ "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", + "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.16", "@types/passport-facebook": "^3.0.3", "@types/supertest": "^6.0.2", @@ -1445,6 +1449,48 @@ "node": ">= 8" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1600,6 +1646,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -1796,6 +1851,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.15", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.15.tgz", + "integrity": "sha512-0EBJxawVNjPkng1zm2vopRctuWVCxk34JcIlRuXSf54habUWdz1FB7wHDqOqvDa8Mtpt0Q3LTXQkAs2LNyK5jQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/oauth": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.4.tgz", @@ -6802,6 +6866,14 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", "dev": true }, + "node_modules/nodemailer": { + "version": "6.9.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.13.tgz", + "integrity": "sha512-7o38Yogx6krdoBf3jCAqnIN4oSQFx+fMa0I7dK1D+me9kBxx12D+/33wSb+fhOCtIxvYJ+4x4IMEhmhCKfAiOA==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", @@ -7086,6 +7158,16 @@ "node": ">= 0.8.0" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -8689,6 +8771,14 @@ "node": ">=0.8" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index 5ac153e3..bbfdef59 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint --config .eslintrc.json .", "format": "prettier --write .", "test": "jest --no-cache", - "test:ci": "jest --coverage" + "test:ci": "jest --coverage --detectOpenHandles" }, "repository": { "type": "git", @@ -29,8 +29,8 @@ "bcrypt": "^5.1.1", "bcryptjs": "^2.4.3", "check-code-coverage": "^1.10.5", - "cookie-session": "^2.1.0", "class-validator": "^0.14.1", + "cookie-session": "^2.1.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "ejs": "^3.1.10", @@ -41,7 +41,9 @@ "joi": "^17.13.1", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "nodemailer": "^6.9.13", "nodemon": "^3.1.0", + "otplib": "^12.0.1", "passport": "^0.7.0", "passport-facebook": "^3.0.0", "passport-google-oauth": "^2.0.0", @@ -63,13 +65,15 @@ ] }, "devDependencies": { - "@types/cookie-session": "^2.0.49", + "@types/bcrypt": "^5.0.2", "@types/bcryptjs": "^2.4.6", + "@types/cookie-session": "^2.0.49", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", "@types/morgan": "^1.9.9", + "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.16", "@types/passport-facebook": "^3.0.3", "@types/supertest": "^6.0.2", diff --git a/src/__test__/userController.test.ts b/src/__test__/userController.test.ts index cd49bb8e..9f6b8a33 100644 --- a/src/__test__/userController.test.ts +++ b/src/__test__/userController.test.ts @@ -1,9 +1,12 @@ import request from 'supertest'; import app from '../app'; +// import { Role } from '../database/models'; import { afterAllHook, beforeAllHook } from './testSetup'; import jwt from 'jsonwebtoken'; import dbConnection from '../database'; +// import bcrpt from 'bcrypt'; import UserModel from '../database/models/userModel'; +// import { use } from 'passport'; const userRepository = dbConnection.getRepository(UserModel); beforeAll(beforeAllHook); @@ -147,19 +150,24 @@ describe('User Registration Tests', () => { }); - - - describe('User Login Tests', () => { it('should log in a user with valid credentials', async () => { + const formData = { + name: 'Vendor', + permissions: ['test-permission1', 'test-permission2'], + }; + + await request(app).post('/api/v1/roles/create_role').send(formData); + const userData = { firstName: 'Test', lastName: 'User', - email: 'test@gmail.com', + email: 'test1@gmail.com', password: 'TestPassword123', - userType: 'buyer', + userType: 'vendor' }; - await request(app).post('/api/v1/register').send(userData); + await request(app).post('/api/v1/register').send(userData); + const updatedUser = await userRepository.findOne({ where: { email: userData.email } }); if (updatedUser) { updatedUser.isVerified = true; @@ -171,13 +179,61 @@ describe('User Login Tests', () => { }); expect(loginResponse.status).toBe(200); - expect(loginResponse.body.token).toBeDefined(); - expect(loginResponse.body.message).toBe('Successfully Logged in'); + expect(loginResponse.body.message).toBe('Please provide the 2FA code sent to your email.'); } else { throw new Error('User not found'); } }); - + + + it('should verify the 2FA code for a vendor user', async () => { + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'test1@gmail.com', + password: 'TestPassword123', + userType: 'vendor', + }; + + // Register the user + await request(app).post('/api/v1/register').send(userData); + + // Verify the user + let user = await userRepository.findOne({ where: { email: userData.email } }); + if (user) { + user.isVerified = true; + await userRepository.save(user); + } else { + throw new Error('User not found'); + } + + + const loginResponse = await request(app).post('/api/v1/login').send({ + email: userData.email, + password: userData.password, + }); + + + expect(loginResponse.status).toBe(200); + expect(loginResponse.body.message).toBe('Please provide the 2FA code sent to your email.'); + + user = await userRepository.findOne({ where: { email: userData.email } }); + + if (user) { + + const verifyResponse = await request(app).post(`/api/v1/verify2FA/${user.id}`).send({ + code: user.twoFactorCode, + }); + + + expect(verifyResponse.status).toBe(200); + expect(verifyResponse.body).toHaveProperty('token'); + } else { + throw new Error('User not found'); + } + }); + + it('should return a 401 status code if the email is not verified', async () => { const userData = { firstName: 'Test', @@ -235,4 +291,4 @@ describe('User Login Tests', () => { expect(loginResponse.status).toBe(404); expect(loginResponse.body.message).toBe('User Not Found'); }); - }); + }); \ No newline at end of file diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 64c7865e..ef933fa8 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -5,9 +5,9 @@ import dbConnection from '../database'; import { Role } from '../database/models/roleEntity'; import UserModel from '../database/models/userModel'; import sendEmail from '../emails/index'; +import { sendCode } from '../emails/mailer'; import jwt from 'jsonwebtoken'; - // Assuming dbConnection.getRepository(UserModel) returns a repository instance const userRepository = dbConnection.getRepository(UserModel); const roleRepository = dbConnection.getRepository(Role); @@ -122,6 +122,7 @@ export const getAllUsers = async (req: Request, res: Response) => { email: true, userType: { id: true, + name: true, }, }, relations: ['userType'], @@ -166,10 +167,12 @@ export const deleteUser = async (req: Request, res: Response) => { }} - - export const Login = async (req: Request, res: Response) => { +export const Login = async (req: Request, res: Response) => { try { - const user = await userRepository.findOne({ where: { email: req.body['email'] } }); + const user = await userRepository.findOne({ + where: { email: req.body['email'] }, + relations: ['userType'] + }); if (!user) { return res.status(404).send({ message: 'User Not Found' }); } @@ -188,23 +191,59 @@ export const deleteUser = async (req: Request, res: Response) => { await sendEmail('confirm', user.email, { name: user.firstName, link: confirmLink }); return res.status(401).send({ message: 'Please verify your email. Confirmation link has been sent.' }); } - const userToken = jwt.sign({ - id: user.id, - firstName: user.firstName, - lastName: user.lastName, - email: user.email, - userType: user.userType - }, process.env.JWT_SECRET as string, { expiresIn: '7d' }); - const { id, firstName, lastName, email, userType } = user; - res.status(200).json({ token: userToken, message: 'Successfully Logged in', id, firstName, lastName, email, userType }); + + // If user is a vendor, proceed with 2FA + if (user.userType.name === 'Vendor') { + // Generate a new 2FA code + const twoFactorCode = Math.floor(100000 + Math.random() * 900000); + + // Store the 2FA code in the user's record in the database + await userRepository.update(user.id, { twoFactorCode }); + + // Send 2FA code to user's email + await sendCode( + user.email, + 'Your 2FA Code', + './templates/2fa.html', + { name: user.firstName, twoFactorCode: twoFactorCode.toString() } + ); + + // Respond with a message asking for the 2FA code + res.status(200).json({ message: 'Please provide the 2FA code sent to your email.' }); + } else { + // If user is not a vendor, + const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); + res.status(200).json({ token }); + } } catch (error) { - console.error('Error occurred while logging in:', error); res.status(500).send(error); } } +export const verify2FA = async (req: Request, res: Response): Promise => { + try { + const { code } = req.body; + const { userId } = req.params; + // Use the repository to find the user by their id + const user = await userRepository.findOne({ where: { id: Number(userId) } }); + if (!user) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + if (code !== user.twoFactorCode) { + res.status(401).json({ error: 'Invalid code' }); + return; + } + // Generate JWT + const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); + // Send JWT to the user + res.status(200).json({ token }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}; \ No newline at end of file diff --git a/src/controller/verification.js b/src/controller/verification.js new file mode 100644 index 00000000..e69de29b diff --git a/src/database/models/userModel.ts b/src/database/models/userModel.ts index 9a500a11..3851440c 100644 --- a/src/database/models/userModel.ts +++ b/src/database/models/userModel.ts @@ -44,6 +44,10 @@ export default class UserModel { @Column({ default: false }) isVerified: boolean; + + @Column({ nullable: true }) + twoFactorCode: number; + constructor(user: Partial) { Object.assign(this, user); } diff --git a/src/docs/2Fadocs.ts b/src/docs/2Fadocs.ts new file mode 100644 index 00000000..510122d5 --- /dev/null +++ b/src/docs/2Fadocs.ts @@ -0,0 +1,55 @@ +/** + * @swagger + * /api/v1/verify2FA/{userId}: + * post: + * summary: Verify 2FA code + * tags: [Login] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: integer + * description: The user ID + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * code: + * type: number + * description: The 2FA code + * responses: + * '200': + * description: Successful verification + * content: + * application/json: + * schema: + * type: object + * properties: + * token: + * type: string + * description: JWT token for authentication + * '401': + * description: Unauthorized - Invalid credentials or code + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * description: An error message indicating invalid credentials or code + * '500': + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * description: An error message indicating internal server error + */ \ No newline at end of file diff --git a/src/emails/mailer.ts b/src/emails/mailer.ts new file mode 100644 index 00000000..f7109e47 --- /dev/null +++ b/src/emails/mailer.ts @@ -0,0 +1,30 @@ +import nodemailer from 'nodemailer'; +import fs from 'fs'; +import path from 'path'; + +export async function sendCode(to: string, subject: string, htmlTemplatePath: string, replacements: Record) { + const template = await fs.promises.readFile(path.resolve(__dirname, htmlTemplatePath), 'utf8'); + + let html = template; + for (const placeholder in replacements) { + html = html.replace(new RegExp(`{{${placeholder}}}`, 'g'), replacements[placeholder]); + } + + const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS + } + }); + + const mailOptions = { + from: 'dynamitesecommerce@gmail.com', + to, + subject, + html + }; + + // Send the email + return transporter.sendMail(mailOptions); +} \ No newline at end of file diff --git a/src/emails/templates/2fa.html b/src/emails/templates/2fa.html new file mode 100644 index 00000000..2c9718f3 --- /dev/null +++ b/src/emails/templates/2fa.html @@ -0,0 +1,109 @@ + + + + + + + + + + +
+
+
+ Welcome to Dynamites Ecommerce,{{name}}! +
+

+ We've generated a 2FA code for you. +

+
+ {{twoFactorCode}} +
+

+ Please enter this code to complete your authentication process. Let's keep your account safe and secure! +

+
+
+ + + \ No newline at end of file diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index 0ecfaaf4..f1b1ac6a 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -5,7 +5,8 @@ import { deleteAllUsers, getAllUsers, deleteUser, - Login + Login, + verify2FA } from '../controller/userController'; const route = Router(); @@ -16,6 +17,7 @@ route.delete('/delete/:id', deleteUser); route.delete('/deleteAllUsers', deleteAllUsers); route.post('/login',Login) route.get('/all-users', getAllUsers); +route.post('/verify2FA/:userId', verify2FA); export default route; From c62efdae9bbad4ef687fd8e12f0d8ecf6e12dfd0 Mon Sep 17 00:00:00 2001 From: EddyShimwa Date: Mon, 13 May 2024 12:33:27 +0200 Subject: [PATCH 2/3] add new tests for buyers login --- .github/workflows/workflow_for_ecomm.yml | 2 - package-lock.json | 588 ++++++++++++++++++++++- package.json | 2 + src/__test__/testSetup.ts | 10 +- src/__test__/userController.test.ts | 58 ++- src/controller/userController.ts | 121 ++--- src/database/models/userModel.ts | 6 +- src/emails/mailer.ts | 29 +- src/middlewares/errorHandler.ts | 18 +- 9 files changed, 707 insertions(+), 127 deletions(-) diff --git a/.github/workflows/workflow_for_ecomm.yml b/.github/workflows/workflow_for_ecomm.yml index 6ed0974f..298cd09e 100644 --- a/.github/workflows/workflow_for_ecomm.yml +++ b/.github/workflows/workflow_for_ecomm.yml @@ -55,5 +55,3 @@ jobs: FACEBOOK_APP_SECRET: ${{ secrets.FACEBOOK_APP_SECRET }} FACEBOOK_CALLBACK_URL: ${{ secrets.FACEBOOK_CALLBACK_URL }} COOKIES_KEY: ${{ secrets.COOKIES_KEY }} - EMAIL_USER: ${{ secrets.EMAIL_USER }} - EMAIL_PASS: ${{ secrets.EMAIL_PASS }} diff --git a/package-lock.json b/package-lock.json index a1c7feb5..ff66d0d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "jest": "^29.7.0", "joi": "^17.13.1", "jsonwebtoken": "^9.0.2", + "mailgun-js": "^0.22.0", "morgan": "^1.10.0", "nodemailer": "^6.9.13", "nodemon": "^3.1.0", @@ -47,6 +48,7 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", + "@types/mailgun-js": "^0.22.18", "@types/morgan": "^1.9.9", "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.16", @@ -1823,6 +1825,30 @@ "@types/node": "*" } }, + "node_modules/@types/mailgun-js": { + "version": "0.22.18", + "resolved": "https://registry.npmjs.org/@types/mailgun-js/-/mailgun-js-0.22.18.tgz", + "integrity": "sha512-xp1QwOjlgvbV9MmKEEmmXN0PkfDxkVQjq0IU1c9DL7VBIQCPxnrqLTaCVm40Ig+YxUnt6e2dT/dNUhaKGQ+V6A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/mailgun-js/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2529,6 +2555,17 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, + "node_modules/ast-types": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", + "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -3191,7 +3228,6 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -3341,6 +3377,11 @@ "node": ">= 0.8" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -3393,6 +3434,11 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz", + "integrity": "sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ==" + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -3507,8 +3553,7 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -3560,6 +3605,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-1.0.4.tgz", + "integrity": "sha512-EMAC+riLSC64jKfOs1jp8J7M4ZXstUUwTdwFBEv6HOzL/Ae+eAzMKEK0nJnpof2fnw9IOjmE6u6qXFejVyk8AA==", + "dependencies": { + "ast-types": "0.x.x", + "escodegen": "1.x.x", + "esprima": "3.x.x" + } + }, + "node_modules/degenerator/node_modules/esprima": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz", + "integrity": "sha512-AWwVMNxwhN8+NIPQzAQZCm7RkLC4RbM3B1OobMuyp3i+w73X57KCKaVIxaRZb+DYCojq7rspo+fmuQfAboyhFg==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3888,6 +3955,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", + "dependencies": { + "es6-promise": "^4.0.3" + } + }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", @@ -3913,6 +3993,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", @@ -4210,7 +4366,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -4394,6 +4549,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4443,8 +4603,7 @@ "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -4481,6 +4640,11 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -4705,6 +4869,39 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, + "node_modules/ftp": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", + "integrity": "sha512-faFVML1aBx2UoDStmLwv2Wptt4vw5x03xxX172nhA5Y5HBshW5JweqQ2W4xL4dezQTG8inJsuYcpPHHU3X5OTQ==", + "dependencies": { + "readable-stream": "1.1.x", + "xregexp": "2.0.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ftp/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "node_modules/ftp/node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/ftp/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4834,6 +5031,64 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-uri": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-2.0.4.tgz", + "integrity": "sha512-v7LT/s8kVjs+Tx0ykk1I+H/rbpzkHvuIq87LmeXptcf5sNWm9uQiwjNAt94SJPA1zOlCntmnOlJvVWKmzsxG8Q==", + "dependencies": { + "data-uri-to-buffer": "1", + "debug": "2", + "extend": "~3.0.2", + "file-uri-to-path": "1", + "ftp": "~0.3.10", + "readable-stream": "2" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/get-uri/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/get-uri/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/get-uri/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/get-uri/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/get-uri/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5126,6 +5381,42 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz", + "integrity": "sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==", + "dependencies": { + "agent-base": "4", + "debug": "3.1.0" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dependencies": { + "es6-promisify": "^5.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -5247,6 +5538,14 @@ "node": ">=0.8.19" } }, + "node_modules/inflection": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz", + "integrity": "sha512-lRy4DxuIFWXlJU7ed8UiTJOSTqStqYdEb4CEbtXfNbkdj3nH1L+reUWiE10VWcJS2yR7tge8Z74pJjtBjNwj0w==", + "engines": [ + "node >= 0.4.0" + ] + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5275,6 +5574,11 @@ "node": ">= 0.4" } }, + "node_modules/ip": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -6550,11 +6854,59 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, "dependencies": { "yallist": "^3.0.2" } }, + "node_modules/mailgun-js": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/mailgun-js/-/mailgun-js-0.22.0.tgz", + "integrity": "sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dependencies": { + "async": "^2.6.1", + "debug": "^4.1.0", + "form-data": "^2.3.3", + "inflection": "~1.12.0", + "is-stream": "^1.1.0", + "path-proxy": "~1.0.0", + "promisify-call": "^2.0.2", + "proxy-agent": "^3.0.3", + "tsscmp": "^1.0.6" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/mailgun-js/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/mailgun-js/node_modules/form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/mailgun-js/node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6830,6 +7182,14 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/netmask": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz", + "integrity": "sha512-3DWDqAtIiPSkBXZyYEjwebfK56nrlQfRGt642fu8RPaL+ePu750+HCMHxjJCG3iEHq/0aeMvX6KIzlv7nuhfrA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -7215,6 +7575,64 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-3.0.1.tgz", + "integrity": "sha512-44DUg21G/liUZ48dJpUSjZnFfZro/0K5JTyFYLBcmh9+T6Ooi4/i4efwUiEy0+4oQusCBqWdhv16XohIj1GqnQ==", + "dependencies": { + "agent-base": "^4.2.0", + "debug": "^4.1.1", + "get-uri": "^2.0.0", + "http-proxy-agent": "^2.1.0", + "https-proxy-agent": "^3.0.0", + "pac-resolver": "^3.0.0", + "raw-body": "^2.2.0", + "socks-proxy-agent": "^4.0.1" + } + }, + "node_modules/pac-proxy-agent/node_modules/agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dependencies": { + "es6-promisify": "^5.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/pac-proxy-agent/node_modules/https-proxy-agent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz", + "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==", + "dependencies": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/pac-proxy-agent/node_modules/https-proxy-agent/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/pac-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-3.0.0.tgz", + "integrity": "sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==", + "dependencies": { + "co": "^4.6.0", + "degenerator": "^1.0.4", + "ip": "^1.1.5", + "netmask": "^1.0.6", + "thunkify": "^2.1.2" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7410,6 +7828,22 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-proxy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-proxy/-/path-proxy-1.0.0.tgz", + "integrity": "sha512-p9IuY9FRY1nU59RDW+tnLL6qMxmBnY03WGYxzy1FcqE5OMO5ggz7ahmOBH0JBS+9f95Yc7V5TZ+kHpTeFWaLQA==", + "dependencies": { + "inflection": "~1.3.0" + } + }, + "node_modules/path-proxy/node_modules/inflection": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.3.8.tgz", + "integrity": "sha512-xRvG6XhAkbneGO5BXP0uKyGkzmZ2bBbrFkx4ZVNx2TmsECbiq/pJapbbx/NECh+E85IfZwW5+IeVNJfkQgavag==", + "engines": [ + "node >= 0.4.0" + ] + }, "node_modules/path-scurry": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.2.tgz", @@ -7729,6 +8163,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/promisify-call": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/promisify-call/-/promisify-call-2.0.4.tgz", + "integrity": "sha512-ZX68J1+1Pe0I8NC0P6Ji3fDDcJceVfpoygfDLgdb1fp5vW9IRlwSpDaxe1T5HgwchyHV2DsL/pWzWikUiWEbLQ==", + "dependencies": { + "with-callback": "^1.0.2" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -7754,6 +8204,55 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-3.1.1.tgz", + "integrity": "sha512-WudaR0eTsDx33O3EJE16PjBRZWcX8GqCEeERw1W3hZJgH/F2a46g7jty6UGty6NeJ4CKQy8ds2CJPMiyeqaTvw==", + "dependencies": { + "agent-base": "^4.2.0", + "debug": "4", + "http-proxy-agent": "^2.1.0", + "https-proxy-agent": "^3.0.0", + "lru-cache": "^5.1.1", + "pac-proxy-agent": "^3.0.1", + "proxy-from-env": "^1.0.0", + "socks-proxy-agent": "^4.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/proxy-agent/node_modules/agent-base": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz", + "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==", + "dependencies": { + "es6-promisify": "^5.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/proxy-agent/node_modules/https-proxy-agent": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz", + "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==", + "dependencies": { + "agent-base": "^4.3.0", + "debug": "^3.1.0" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/proxy-agent/node_modules/https-proxy-agent/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -8309,6 +8808,56 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.3.3.tgz", + "integrity": "sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==", + "dependencies": { + "ip": "1.1.5", + "smart-buffer": "^4.1.0" + }, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz", + "integrity": "sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==", + "dependencies": { + "agent-base": "~4.2.1", + "socks": "~2.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz", + "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==", + "dependencies": { + "es6-promisify": "^5.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/socks/node_modules/ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha512-rBtCAQAJm8A110nbwn6YdveUnuZH3WrC36IwkRXxDnq53JvXA2NVQvB7IHyKomxK1MJ4VDNw3UtFDdXQ+AvLYA==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8779,6 +9328,11 @@ "node": ">=0.2.6" } }, + "node_modules/thunkify": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/thunkify/-/thunkify-2.1.2.tgz", + "integrity": "sha512-w9foI80XcGImrhMQ19pxunaEC5Rp2uzxZZg4XBAFRfiLOplk3F0l7wo+bO16vC2/nlQfR/mXZxcduo0MF2GWLg==" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9589,11 +10143,18 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/with-callback": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/with-callback/-/with-callback-1.0.2.tgz", + "integrity": "sha512-zaUhn7OWgikdqWlPYpZ4rTX/6IAV0czMVyd+C6QLVrif2tATF28CYUnHBmHs2a5EaZo7bB1+plBUPHto+HW8uA==", + "engines": { + "node": ">=4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -9654,6 +10215,14 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/xregexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz", + "integrity": "sha512-xl/50/Cf32VsGq/1R8jJE5ajH1yMCQkpmoS10QbFZWl2Oor4H0Me64Pu2yxvsRWK3m6soJbmGfzSR7BYmDcWAA==", + "engines": { + "node": "*" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -9673,8 +10242,7 @@ "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { "version": "2.0.0-1", diff --git a/package.json b/package.json index bbfdef59..dc3f715f 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "jest": "^29.7.0", "joi": "^17.13.1", "jsonwebtoken": "^9.0.2", + "mailgun-js": "^0.22.0", "morgan": "^1.10.0", "nodemailer": "^6.9.13", "nodemon": "^3.1.0", @@ -72,6 +73,7 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.6", + "@types/mailgun-js": "^0.22.18", "@types/morgan": "^1.9.9", "@types/nodemailer": "^6.4.15", "@types/passport": "^1.0.16", diff --git a/src/__test__/testSetup.ts b/src/__test__/testSetup.ts index dc34aeaf..e4a5759b 100644 --- a/src/__test__/testSetup.ts +++ b/src/__test__/testSetup.ts @@ -1,12 +1,16 @@ import { DbConnection } from '../database/index'; import UserModel from '../database/models/userModel'; - import { Role } from '../database/models'; export async function beforeAllHook() { await DbConnection.instance.initializeDb(); - // removing all data from role table + + // Get repositories + const userRepository = await DbConnection.connection.getRepository(UserModel); const roleRepository = await DbConnection.connection.getRepository(Role); + + // Delete all users and roles + await userRepository.createQueryBuilder().delete().execute(); await roleRepository.createQueryBuilder().delete().execute(); } @@ -17,4 +21,4 @@ export async function afterAllHook() { console.log(repository); await DbConnection.instance.disconnectDb(); -} +} \ No newline at end of file diff --git a/src/__test__/userController.test.ts b/src/__test__/userController.test.ts index 9f6b8a33..69d33326 100644 --- a/src/__test__/userController.test.ts +++ b/src/__test__/userController.test.ts @@ -1,12 +1,9 @@ import request from 'supertest'; import app from '../app'; -// import { Role } from '../database/models'; import { afterAllHook, beforeAllHook } from './testSetup'; import jwt from 'jsonwebtoken'; import dbConnection from '../database'; -// import bcrpt from 'bcrypt'; import UserModel from '../database/models/userModel'; -// import { use } from 'passport'; const userRepository = dbConnection.getRepository(UserModel); beforeAll(beforeAllHook); @@ -151,7 +148,7 @@ describe('User Registration Tests', () => { describe('User Login Tests', () => { - it('should log in a user with valid credentials', async () => { + it('should log in a vendor with valid credentials', async () => { const formData = { name: 'Vendor', permissions: ['test-permission1', 'test-permission2'], @@ -180,11 +177,8 @@ describe('User Login Tests', () => { expect(loginResponse.status).toBe(200); expect(loginResponse.body.message).toBe('Please provide the 2FA code sent to your email.'); - } else { - throw new Error('User not found'); } }); - it('should verify the 2FA code for a vendor user', async () => { const userData = { @@ -203,11 +197,7 @@ describe('User Login Tests', () => { if (user) { user.isVerified = true; await userRepository.save(user); - } else { - throw new Error('User not found'); } - - const loginResponse = await request(app).post('/api/v1/login').send({ email: userData.email, password: userData.password, @@ -228,12 +218,50 @@ describe('User Login Tests', () => { expect(verifyResponse.status).toBe(200); expect(verifyResponse.body).toHaveProperty('token'); - } else { - throw new Error('User not found'); } }); + it('should log in a buyer with valid credentials', async () => { + const formData = { + name: 'Buyer', + permissions: ['test-permission1', 'test-permission2'], + }; + + // Create the role first + const roleResponse = await request(app).post('/api/v1/roles/create_role').send(formData); + + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'test2@gmail.com', + password: 'TestPassword123', + userType: roleResponse.body.id + }; + await request(app).post('/api/v1/register').send(userData); + + const updatedUser = await userRepository.findOne({ where: { email: userData.email } }); + if (updatedUser) { + updatedUser.isVerified = true; + await userRepository.save(updatedUser); + + const loginResponse = await request(app).post('/api/v1/login').send({ + email: userData.email, + password: userData.password, + }); + + expect(loginResponse.status).toBe(200); + expect(loginResponse.body.token).toBeDefined(); + expect(loginResponse.body.message).toBe('Buyer Logged in successfully'); + + // Decode the token and check its properties + const decodedToken = jwt.decode(loginResponse.body.token); + expect(decodedToken).toHaveProperty('userId'); + expect(decodedToken).toHaveProperty('iat'); + expect(decodedToken).toHaveProperty('exp'); + } + }); + it('should return a 401 status code if the email is not verified', async () => { const userData = { firstName: 'Test', @@ -257,9 +285,7 @@ describe('User Login Tests', () => { expect(loginResponse.status).toBe(401); expect(loginResponse.body.message).toBe('Please verify your email. Confirmation link has been sent.'); // Corrected message - } else { - throw new Error('User not found'); - } + } }); it('should return a 401 status code if the password does not match', async () => { diff --git a/src/controller/userController.ts b/src/controller/userController.ts index ef933fa8..83e782c1 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -7,6 +7,7 @@ import UserModel from '../database/models/userModel'; import sendEmail from '../emails/index'; import { sendCode } from '../emails/mailer'; import jwt from 'jsonwebtoken'; +import errorHandler from '../middlewares/errorHandler' // Assuming dbConnection.getRepository(UserModel) returns a repository instance const userRepository = dbConnection.getRepository(UserModel); @@ -167,83 +168,61 @@ export const deleteUser = async (req: Request, res: Response) => { }} -export const Login = async (req: Request, res: Response) => { - try { - const user = await userRepository.findOne({ - where: { email: req.body['email'] }, - relations: ['userType'] - }); - if (!user) { - return res.status(404).send({ message: 'User Not Found' }); - } - const passwordMatch = await bcrypt.compare(req.body.password, user.password); // Compare with hashed password from the database - if (!passwordMatch) { - return res.status(401).send({ message: 'Password does not match' }); - } - if (!user.isVerified) { - // Send confirmation email if user is not verified - const token = jwt.sign( - { userId: user.id, email: user.email }, - process.env.JWT_SECRET as jwt.Secret, - { expiresIn: '1d' } - ); - const confirmLink = `${process.env.APP_URL}/api/v1/confirm?token=${token}`; - await sendEmail('confirm', user.email, { name: user.firstName, link: confirmLink }); - return res.status(401).send({ message: 'Please verify your email. Confirmation link has been sent.' }); - } - - // If user is a vendor, proceed with 2FA - if (user.userType.name === 'Vendor') { - // Generate a new 2FA code - const twoFactorCode = Math.floor(100000 + Math.random() * 900000); - - // Store the 2FA code in the user's record in the database - await userRepository.update(user.id, { twoFactorCode }); - - // Send 2FA code to user's email - await sendCode( - user.email, - 'Your 2FA Code', - './templates/2fa.html', - { name: user.firstName, twoFactorCode: twoFactorCode.toString() } - ); - - // Respond with a message asking for the 2FA code - res.status(200).json({ message: 'Please provide the 2FA code sent to your email.' }); - } else { - // If user is not a vendor, - const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); - res.status(200).json({ token }); - } - } catch (error) { - res.status(500).send(error); + export const Login = errorHandler(async (req: Request, res: Response) => { + const user = await userRepository.findOne({ + where: { email: req.body['email'] }, + relations: ['userType'] + }); + if (!user) { + return res.status(404).send({ message: 'User Not Found' }); + } + const passwordMatch = await bcrypt.compare(req.body.password, user.password); // Compare with hashed password from the database + if (!passwordMatch) { + return res.status(401).send({ message: 'Password does not match' }); + } + if (!user.isVerified) { + const token = jwt.sign( + { userId: user.id, email: user.email }, + process.env.JWT_SECRET as jwt.Secret, + { expiresIn: '1d' } + ); + const confirmLink = `${process.env.APP_URL}/api/v1/confirm?token=${token}`; + await sendEmail('confirm', user.email, { name: user.firstName, link: confirmLink }); + return res.status(401).send({ message: 'Please verify your email. Confirmation link has been sent.' }); } -} -export const verify2FA = async (req: Request, res: Response): Promise => { - try { - const { code } = req.body; - const { userId } = req.params; + if (user.userType.name === 'Vendor') { + const twoFactorCode = Math.floor(100000 + Math.random() * 900000); - // Use the repository to find the user by their id - const user = await userRepository.findOne({ where: { id: Number(userId) } }); + await userRepository.update(user.id, { twoFactorCode }); - if (!user) { - res.status(401).json({ error: 'Invalid credentials' }); - return; - } - if (code !== user.twoFactorCode) { - res.status(401).json({ error: 'Invalid code' }); - return; - } + await sendCode( + user.email, + 'Your 2FA Code', + './templates/2fa.html', + { name: user.firstName, twoFactorCode: twoFactorCode.toString() } + ); - // Generate JWT + res.status(200).json({ message: 'Please provide the 2FA code sent to your email.' }); + } else { const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); + res.status(200).json({ token, message: 'Buyer Logged in successfully'}); + } +}); - // Send JWT to the user - res.status(200).json({ token }); +export const verify2FA = errorHandler(async (req: Request, res: Response) => { + const { code } = req.body; + const { userId } = req.params; - } catch (error) { - res.status(500).json({ error: (error as Error).message }); + const user = await userRepository.findOne({ where: { id: Number(userId) } }); + + if (!user) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + if (code !== user.twoFactorCode) { + return res.status(401).json({ error: 'Invalid code' }); } -}; \ No newline at end of file + + const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); + return res.status(200).json({ token }); +}); diff --git a/src/database/models/userModel.ts b/src/database/models/userModel.ts index 3851440c..38e64b63 100644 --- a/src/database/models/userModel.ts +++ b/src/database/models/userModel.ts @@ -2,8 +2,7 @@ import { Entity, PrimaryGeneratedColumn, Column, - OneToOne, - JoinColumn, + ManyToOne, } from 'typeorm'; import { Role } from './roleEntity'; @@ -25,8 +24,7 @@ export default class UserModel { @Column({ nullable: true }) password: string; - @OneToOne(() => Role, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - @JoinColumn() + @ManyToOne(() => Role) userType: Role; @Column({ nullable: true }) diff --git a/src/emails/mailer.ts b/src/emails/mailer.ts index f7109e47..f6a1b712 100644 --- a/src/emails/mailer.ts +++ b/src/emails/mailer.ts @@ -1,23 +1,20 @@ -import nodemailer from 'nodemailer'; +import mailgun from 'mailgun-js'; import fs from 'fs'; import path from 'path'; +import dotenv from 'dotenv'; +dotenv.config() +const mg = mailgun({ + apiKey: process.env.MAILGUN_TOKEN || 'default_api_key', + domain: process.env.MAILGUN_DOMAIN || 'default_domain' +}); export async function sendCode(to: string, subject: string, htmlTemplatePath: string, replacements: Record) { const template = await fs.promises.readFile(path.resolve(__dirname, htmlTemplatePath), 'utf8'); - let html = template; for (const placeholder in replacements) { html = html.replace(new RegExp(`{{${placeholder}}}`, 'g'), replacements[placeholder]); } - const transporter = nodemailer.createTransport({ - service: 'gmail', - auth: { - user: process.env.EMAIL_USER, - pass: process.env.EMAIL_PASS - } - }); - const mailOptions = { from: 'dynamitesecommerce@gmail.com', to, @@ -26,5 +23,13 @@ export async function sendCode(to: string, subject: string, htmlTemplatePath: st }; // Send the email - return transporter.sendMail(mailOptions); -} \ No newline at end of file + return new Promise((resolve, reject) => { + mg.messages().send(mailOptions, (error, body) => { + if (error) { + reject(error); + } else { + resolve(body); + } + }); + }); +} diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index 9837635f..bed0a0a7 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,16 +1,16 @@ -import { Request, Response, NextFunction } from 'express'; +import { Request, Response } from 'express'; -type MiddlewareFunction = (req: Request, res: Response, next: NextFunction) => Promise; +type MiddlewareFunction = (req: Request, res: Response) => Promise> | undefined>; -function errorHandler(func: MiddlewareFunction) { - return async (req: Request, res: Response, next: NextFunction) => { +function errorHandler(func: MiddlewareFunction): MiddlewareFunction { + return async (req: Request, res: Response) => { try { - await func(req, res, next); - } catch (error) { // Removed the type annotation from the catch clause variable because it caused liting errors - const message = error.detail || 'Internal Server Error'; - res.status(500).send(message); + return await func(req, res); + } catch (error) { + const message = (error as { detail?: string }).detail || 'Internal Server Error'; + return res.status(500).send(message); } }; } -export default errorHandler; +export default errorHandler; \ No newline at end of file From 9082fc4af3390829f7fc046ad185faaee33a0d85 Mon Sep 17 00:00:00 2001 From: Joslyn Manzi Karenzi Date: Tue, 7 May 2024 20:25:43 +0200 Subject: [PATCH 3/3] * feat(rbac): Implement role based access control -define roles and permissions for vendors and buyers -assign roles and permissions to users during registration or profile update -enforce role-based access control throughout the application -write comprehensive unit tests [Delivers #34] * feat(rbac): integrate rbac into user registration -integrate role based access control into user registration [Delivers #34] * feat(rbac): integrate rbac into user registration -integrate role based access control into user registration [Delivers #34] --------- Co-authored-by: ambroisegithub Social Logins (#45) * squashing commits implementing routes for auth create passport callback function adding new user from Google creating new user check if user is exist in db implementing cookie session Fix error of TypeError: req.session.regenerate is not a function using Passport fix secret keys remove Google client secret keys working on facebook strategy get email from fb login and update the scope after verification save the user into db add profile image in db fixing minor bugs fix minor bugs in codes after rebasing & updating some fts link social login with userModel Addong Google client keys & FB client key into yml send confrim email after register a new user send email after register from facebook fix minor bugs * fix minor errors * remove lints errors user register register user test register user testing fix register user testing fix register user testing fix Authentication for User Added slint changes removed mocha added new features added new features Solved comflicts changed file added changes added new Test added new Test resolved test cases resolved test cases implemented two-factor authentication for enhanced security implemented two-factor authentication for enhanced security check whether the usertype is vendor to proceed with 2FA test the 2fa authentication Design the database schema for storing seller products and related information create endpoints for category and also database schema fix routes for products & categories implement CRUD operations for category entity create swagger docs for category routes adds delete documentation for category routes complete documentation for category routes implementing craate new product route and its controller implementing get product route and its controller working on update function Extend API endpoints store reference ID of vendor ft-Product-Docs This PR add the product swagger docs and some test category bug-documentation-fixes Fixing the errors on the doumentation add new tests for buyers login Design the database schema for storing seller products and related information create endpoints for category and also database schema fix routes for products & categories implement CRUD operations for category entity create swagger docs for category routes adds delete documentation for category routes complete documentation for category routes implementing craate new product route and its controller implementing get product route and its controller working on update function Extend API endpoints store reference ID of vendor ft-Product-Docs This PR add the product swagger docs and some test category bug-documentation-fixes Fixing the errors on the doumentation store reference ID of vendor replacing try & catch with errorHandler middleware remove all try & catch blocks in product controller validating routes fix minor errors in test categories Category-testing add category testing cases init commit for prouct test product controller test complete product test merging all commits delete product data after testing add --detectOpenHandles flag --- package.json | 2 +- src/__test__/category.test.ts | 183 ++++++++++++++++ src/__test__/product.test.ts | 263 +++++++++++++++++++++++ src/__test__/testSetup.ts | 79 ++++++- src/__test__/userController.test.ts | 207 +++++++++--------- src/app.ts | 12 +- src/controller/categoryController.ts | 128 ++++++++++++ src/controller/productController.ts | 290 ++++++++++++++++++++++++++ src/controller/userController.ts | 53 +++-- src/database/models/categoryEntity.ts | 36 ++++ src/database/models/index.ts | 1 + src/database/models/productEntity.ts | 65 ++++++ src/database/models/roleEntity.ts | 6 +- src/database/models/userModel.ts | 14 +- src/docs/2Fadocs.ts | 4 +- src/docs/categoryDocs.ts | 207 ++++++++++++++++++ src/docs/productDoc.ts | 186 +++++++++++++++++ src/docs/userAuth.ts | 4 +- src/docs/userRegisterDocs.ts | 41 +++- src/middlewares/authorize.ts | 35 +++- src/middlewares/errorHandler.ts | 24 ++- src/middlewares/isLoggedIn.ts | 33 +++ src/middlewares/passport-setup.ts | 2 +- src/routes/categoryRoutes.ts | 23 ++ src/routes/index.ts | 16 +- src/routes/productRoutes.ts | 26 +++ src/routes/userRoutes.ts | 22 +- 27 files changed, 1775 insertions(+), 187 deletions(-) create mode 100644 src/__test__/category.test.ts create mode 100644 src/__test__/product.test.ts create mode 100644 src/controller/categoryController.ts create mode 100644 src/controller/productController.ts create mode 100644 src/database/models/categoryEntity.ts create mode 100644 src/database/models/productEntity.ts create mode 100644 src/docs/categoryDocs.ts create mode 100644 src/docs/productDoc.ts create mode 100644 src/middlewares/isLoggedIn.ts create mode 100644 src/routes/categoryRoutes.ts create mode 100644 src/routes/productRoutes.ts diff --git a/package.json b/package.json index dc3f715f..c3b9ae67 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "tsc", "lint": "eslint --config .eslintrc.json .", "format": "prettier --write .", - "test": "jest --no-cache", + "test": "jest --no-cache --detectOpenHandles", "test:ci": "jest --coverage --detectOpenHandles" }, "repository": { diff --git a/src/__test__/category.test.ts b/src/__test__/category.test.ts new file mode 100644 index 00000000..21b161bc --- /dev/null +++ b/src/__test__/category.test.ts @@ -0,0 +1,183 @@ +import request from 'supertest'; +import app from '../app'; +import { afterAllHook, beforeAllHook, getVendorToken } from './testSetup'; + +beforeAll(beforeAllHook); +afterAll(afterAllHook); + +describe('Category Creation Tests', () => { + beforeAll(async () => { + token = await getVendorToken(); + }); + let token: string; + let categoryId: number; + + it('should create a new category with valid data', async () => { + const categoryData = { + name: 'Test Category', + description: 'Test category description', + }; + + const response = await request(app) + .post('/api/v1/category') + .set('Authorization', `Bearer ${token}`) + .send(categoryData); + expect(response.status).toBe(201); + expect(response.body.message).toBe('Category successfully created'); + expect(response.body.data).toHaveProperty('id'); + expect(response.body.data).toHaveProperty('name', categoryData.name); + expect(response.body.data).toHaveProperty( + 'description', + categoryData.description + ); + categoryId = response.body.data.id; + }); + + it('should return a 400 status code if name is missing', async () => { + const invalidData = { + description: 'Test category description', + }; + + const response = await request(app) + .post('/api/v1/category') + .set('Authorization', `Bearer ${token}`) + .send(invalidData); + + expect(response.status).toBe(400); + expect(response.body.errors[0].msg).toBe('Category name is required'); + }); + + it('should return 400 if request data is invalid', async () => { + const invalidData = {}; + + const response = await request(app) + .put(`/api/v1/category/${categoryId}`) + .set('Authorization', `Bearer ${token}`) + .send(invalidData); + + expect(response.status).toBe(400); + expect(response.body.errors).toBeDefined(); + }); + + it('should return a 409 status code if category name already exists', async () => { + const existingCategoryData = { + name: 'Existing Category', + description: 'Existing category description', + }; + await request(app) + .post('/api/v1/category') + .set('Authorization', `Bearer ${token}`) + .send(existingCategoryData); + + const newCategoryData = { + name: 'Existing Category', + description: 'Existing category description', + }; + const response = await request(app) + .post('/api/v1/category') + .set('Authorization', `Bearer ${token}`) + .send(newCategoryData); + + expect(response.status).toBe(409); + expect(response.body.message).toBe('Category name already exists'); + }); + + it('should return all categories with status 200', async () => { + const response = await request(app).get('/api/v1/category'); + expect(response.status).toBe(200); + expect(response.body.message).toBe('Data retrieved successfully'); + expect(response.body.data).toBeDefined; + }); + + it('should return a category by ID with status 200', async () => { + const response = await request(app).get(`/api/v1/category/${categoryId}`); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Data retrieved successfully'); + expect(response.body.data).toBeDefined; + }); + + it('should return 404 if category is not found', async () => { + const nonExistentCategoryId = 9999; + + const response = await request(app).get( + `/api/v1/category/${nonExistentCategoryId}` + ); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Category Not Found'); + }); + + it('should update the category with status 200', async () => { + const updatedCategoryData = { + name: 'Updated Category Name', + description: 'Updated category description', + }; + + const response = await request(app) + .put(`/api/v1/category/${categoryId}`) + .set('Authorization', `Bearer ${token}`) + .send(updatedCategoryData); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Category successfully updated'); + expect(response.body.data.name).toBe(updatedCategoryData.name); + expect(response.body.data.description).toBe( + updatedCategoryData.description + ); + }); + + it('should return a 409 status code if category update name already exists', async () => { + const existingCategoryData = { + name: 'Existing Category', + description: 'Existing category description', + }; + await request(app) + .post('/api/v1/category') + .set('Authorization', `Bearer ${token}`) + .send(existingCategoryData); + + const updateCategoryData = { + name: 'Existing Category', + description: 'Existing category description', + }; + const response = await request(app) + .put(`/api/v1/category/${categoryId}`) + .set('Authorization', `Bearer ${token}`) + .send(updateCategoryData); + + expect(response.status).toBe(409); + expect(response.body.message).toBe('Category name already exists'); + }); + + it('should return 404 if category is not found', async () => { + const response = await request(app) + .put('/api/v1/category/9999') + .set('Authorization', `Bearer ${token}`) + .send({ + name: 'Updated Category Name', + description: 'Updated category description', + }); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Category Not Found'); + }); + + it('should delete the category with status 200', async () => { + const response = await request(app) + .delete(`/api/v1/category/${categoryId}`) + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Category deleted successfully'); + }); + + it('should return 404 if category is not found', async () => { + const response = await request(app) + .delete('/api/v1/category/9999') + .set('Authorization', `Bearer ${token}`); + + expect(response.status).toBe(404); + expect(response.body.message).toBe('Category Not Found'); + }); +}); diff --git a/src/__test__/product.test.ts b/src/__test__/product.test.ts new file mode 100644 index 00000000..fc976ed8 --- /dev/null +++ b/src/__test__/product.test.ts @@ -0,0 +1,263 @@ +import request from 'supertest'; +import app from '../app'; +import { getVendorToken, afterAllHook, beforeAllHook } from './testSetup'; + +beforeAll(beforeAllHook); +afterAll(afterAllHook); + +describe('Product Controller Tests', () => { + let token: string; + let productId: number; + let categoryId: number; + + beforeAll(async () => { + token = await getVendorToken(); + }); + + it('should create a new product with valid data', async () => { + // create a category + const categoryData = { + name: 'Category', + description: 'category description', + }; + + const categoryResponse = await request(app) + .post('/api/v1/category') + .set('Authorization', `Bearer ${token}`) + .send(categoryData); + + categoryId = categoryResponse.body.data.id; + + const productData = { + name: 'New Product', + image: 'new_product.jpg', + gallery: [], + shortDesc: 'This is a new product', + longDesc: 'Detailed description of the new product', + categoryId: categoryId, + quantity: 10, + regularPrice: 5, + salesPrice: 4, + tags: ['tag1', 'tag2'], + type: 'Simple', + isAvailable: true, + }; + + const response = await request(app) + .post('/api/v1/product') + .set('Authorization', `Bearer ${token}`) + .send(productData); + expect(response.statusCode).toEqual(201); + expect(response.body.message).toEqual('Product successfully created'); + expect(response.body.data).toBeDefined(); + productId = response.body.data.id; + }); + + it('should return 409 if product name already exists', async () => { + const productData = { + name: 'New Product', + image: 'new_product.jpg', + gallery: [], + shortDesc: 'This is a new product', + longDesc: 'Detailed description of the new product', + categoryId: categoryId, + quantity: 10, + regularPrice: 5, + salesPrice: 4, + tags: ['tag1', 'tag2'], + type: 'Simple', + isAvailable: true, + }; + + const response = await request(app) + .post('/api/v1/product') + .set('Authorization', `Bearer ${token}`) + .send(productData); + + expect(response.statusCode).toEqual(409); + expect(response.body.message).toEqual('Product name already exists'); + }); + + it('should return 404 if category not found', async () => { + const nonExistentCategoryId = 999; + + const productData = { + name: 'Test Product', + image: 'test.jpg', + gallery: [], + shortDesc: 'A test product', + longDesc: 'Description of a test product', + categoryId: nonExistentCategoryId, + quantity: 10, + regularPrice: 20, + salesPrice: 18, + tags: ['tag1', 'tag2'], + type: 'Simple', + isAvailable: true, + }; + + const response = await request(app) + .post('/api/v1/product') + .set('Authorization', `Bearer ${token}`) + .send(productData); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Category not found'); + }); + + it('should return validation errors for invalid product data', async () => { + const invalidProductData = { + name: '', + image: '', + }; + const response = await request(app) + .post('/api/v1/product') + .set('Authorization', `Bearer ${token}`) + .send(invalidProductData); + expect(response.statusCode).toEqual(400); + expect(response.body.errors).toBeDefined(); + }); + + it('should retrieve all products', async () => { + const response = await request(app).get('/api/v1/product'); + + expect(response.statusCode).toEqual(200); + expect(response.body.message).toEqual('Data retrieved successfully'); + expect(Array.isArray(response.body.data)).toBeTruthy(); + }); + + it('should retrieve a single product by ID', async () => { + const response = await request(app) + .get(`/api/v1/product/${productId}`) + .set('Authorization', `Bearer ${token}`); + + expect(response.statusCode).toEqual(200); + expect(response.body.message).toEqual('Data retrieved successfully'); + expect(response.body.data).toBeDefined(); + }); + + it('should update a product by ID', async () => { + const updatedProductData = { + name: 'Updated Product Name', + image: 'Updated.jpg', + gallery: [], + shortDesc: 'This is a updated', + longDesc: 'Detailed description of the Updated product', + categoryId: categoryId, + quantity: 3, + regularPrice: 10, + salesPrice: 7, + tags: ['tag1', 'tag2'], + type: 'Variable', + isAvailable: true, + }; + + const response = await request(app) + .put(`/api/v1/product/${productId}`) + .set('Authorization', `Bearer ${token}`) + .send(updatedProductData); + + expect(response.statusCode).toEqual(200); + expect(response.body.message).toEqual('Product successfully updated'); + expect(response.body.data).toBeDefined(); + }); + + it('should return a 404 for a non-existent product while updating', async () => { + const updatedProductData = { + name: 'Updated Product Name', + image: 'Updated.jpg', + gallery: [], + shortDesc: 'This is a updated', + longDesc: 'Detailed description of the Updated product', + categoryId: categoryId, + quantity: 3, + regularPrice: 10, + salesPrice: 7, + tags: ['tag1', 'tag2'], + type: 'Variable', + isAvailable: true, + }; + const nonExistentProductId = -999; + const response = await request(app) + .put(`/api/v1/product/${nonExistentProductId}`) + .set('Authorization', `Bearer ${token}`) + .send(updatedProductData); + + expect(response.statusCode).toEqual(404); + expect(response.body.message).toEqual('Product not found'); + }); + + it('should return 404 if category not found while updating', async () => { + const nonExistentCategoryId = 999; + + const productData = { + name: 'Test Product', + image: 'test.jpg', + gallery: [], + shortDesc: 'A test product', + longDesc: 'Description of a test product', + categoryId: nonExistentCategoryId, + quantity: 10, + regularPrice: 20, + salesPrice: 18, + tags: ['tag1', 'tag2'], + type: 'Simple', + isAvailable: true, + }; + + const response = await request(app) + .put(`/api/v1/product/${productId}`) + .set('Authorization', `Bearer ${token}`) + .send(productData); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Category not found'); + }); + it('should return a 404 for a non-existent product', async () => { + const nonExistentProductId = -999; + const response = await request(app) + .get(`/api/v1/product/${nonExistentProductId}`) + .set('Authorization', `Bearer ${token}`); + expect(response.statusCode).toEqual(404); + expect(response.body.message).toEqual('Product not found'); + }); + + it('should return validation errors for invalid update data', async () => { + const invalidUpdateData = { + name: '', + }; + const response = await request(app) + .put(`/api/v1/product/${productId}`) + .set('Authorization', `Bearer ${token}`) + .send(invalidUpdateData); + expect(response.statusCode).toEqual(400); + expect(response.body.errors).toBeDefined(); + }); + + it('should delete a product by ID', async () => { + const response = await request(app) + .delete(`/api/v1/product/${productId}`) + .set('Authorization', `Bearer ${token}`); + + expect(response.statusCode).toEqual(200); + expect(response.body.message).toEqual('Product deleted successfully'); + }); + + it('should return a 404 for a non-existent product', async () => { + const nonExistentProductId = -999; + const response = await request(app) + .delete(`/api/v1/product/${nonExistentProductId}`) + .set('Authorization', `Bearer ${token}`); + expect(response.statusCode).toEqual(404); + expect(response.body.message).toEqual('Product Not Found'); + }); + + it('should delete all products', async () => { + const response = await request(app) + .delete('/api/v1/product') + .set('Authorization', `Bearer ${token}`); + + expect(response.statusCode).toEqual(200); + expect(response.body.message).toEqual('All product deleted successfully'); + }); +}); diff --git a/src/__test__/testSetup.ts b/src/__test__/testSetup.ts index e4a5759b..969af324 100644 --- a/src/__test__/testSetup.ts +++ b/src/__test__/testSetup.ts @@ -1,6 +1,10 @@ import { DbConnection } from '../database/index'; import UserModel from '../database/models/userModel'; import { Role } from '../database/models'; +import Category from '../database/models/categoryEntity'; +import Product from '../database/models/productEntity'; +import request from 'supertest'; +import app from '../app'; export async function beforeAllHook() { await DbConnection.instance.initializeDb(); @@ -8,17 +12,80 @@ export async function beforeAllHook() { // Get repositories const userRepository = await DbConnection.connection.getRepository(UserModel); const roleRepository = await DbConnection.connection.getRepository(Role); + const categoryRepository = + await DbConnection.connection.getRepository(Category); + const productRepository = + await DbConnection.connection.getRepository(Product); - // Delete all users and roles + // Delete all users,roles and categories await userRepository.createQueryBuilder().delete().execute(); await roleRepository.createQueryBuilder().delete().execute(); + await categoryRepository.createQueryBuilder().delete().execute(); + await productRepository.createQueryBuilder().delete().execute(); +} + +// Get Vendor Token function +export async function getVendorToken() { + const userRepository = await DbConnection.connection.getRepository(UserModel); + + const formData = { + name: 'Vendor', + permissions: ['test-permission1', 'test-permission2'], + }; + await request(app).post('/api/v1/roles/create_role').send(formData); + + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'test1@gmail.com', + password: 'TestPassword123', + userType: 'vendor', + }; + await request(app).post('/api/v1/register').send(userData); + + const updatedUser = await userRepository.findOne({ + where: { email: userData.email }, + }); + if (updatedUser) { + updatedUser.isVerified = true; + await userRepository.save(updatedUser); + } + + const loginResponse = await request(app).post('/api/v1/login').send({ + email: userData.email, + password: userData.password, + }); + + expect(loginResponse.status).toBe(200); + expect(loginResponse.body.message).toBe( + 'Please provide the 2FA code sent to your email.' + ); + + const user = await userRepository.findOne({ + where: { email: userData.email }, + }); + if (!user) throw new Error('User not found'); + + const verifyResponse = await request(app) + .post(`/api/v1/verify2FA/${user.id}`) + .send({ + code: user.twoFactorCode, + }); + expect(verifyResponse.status).toBe(200); + expect(verifyResponse.body).toHaveProperty('token'); + expect(verifyResponse.body.token).toBeDefined(); + return verifyResponse.body.token; } export async function afterAllHook() { - const userRepository = DbConnection.connection.getRepository(UserModel); - const repository = await userRepository.clear(); - // eslint-disable-next-line no-console - console.log(repository); + await DbConnection.connection.transaction(async (transactionManager) => { + const userRepository = transactionManager.getRepository(UserModel); + const categoryRepository = transactionManager.getRepository(Category); + const productRepository = transactionManager.getRepository(Product); + await userRepository.createQueryBuilder().delete().execute(); + await categoryRepository.createQueryBuilder().delete().execute(); + await productRepository.createQueryBuilder().delete().execute(); + }); await DbConnection.instance.disconnectDb(); -} \ No newline at end of file +} diff --git a/src/__test__/userController.test.ts b/src/__test__/userController.test.ts index 69d33326..1a48549d 100644 --- a/src/__test__/userController.test.ts +++ b/src/__test__/userController.test.ts @@ -3,7 +3,7 @@ import app from '../app'; import { afterAllHook, beforeAllHook } from './testSetup'; import jwt from 'jsonwebtoken'; import dbConnection from '../database'; -import UserModel from '../database/models/userModel'; +import UserModel from '../database/models/userModel'; const userRepository = dbConnection.getRepository(UserModel); beforeAll(beforeAllHook); @@ -25,7 +25,10 @@ describe('User Registration Tests', () => { expect(response.body.user).toHaveProperty('firstName', userData.firstName); expect(response.body.user).toHaveProperty('lastName', userData.lastName); expect(response.body.user).toHaveProperty('email', userData.email); - expect(response.body.user).toHaveProperty('userType', response.body.user.userType); + expect(response.body.user).toHaveProperty( + 'userType', + response.body.user.userType + ); }); it('should return a 400 status code if validation fails', async () => { @@ -146,38 +149,41 @@ describe('User Registration Tests', () => { }); }); - describe('User Login Tests', () => { it('should log in a vendor with valid credentials', async () => { - const formData = { - name: 'Vendor', - permissions: ['test-permission1', 'test-permission2'], - }; - - await request(app).post('/api/v1/roles/create_role').send(formData); - - const userData = { - firstName: 'Test', - lastName: 'User', - email: 'test1@gmail.com', - password: 'TestPassword123', - userType: 'vendor' - }; + const formData = { + name: 'Vendor', + permissions: ['test-permission1', 'test-permission2'], + }; + + await request(app).post('/api/v1/roles/create_role').send(formData); + + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'test1@gmail.com', + password: 'TestPassword123', + userType: 'vendor', + }; await request(app).post('/api/v1/register').send(userData); - const updatedUser = await userRepository.findOne({ where: { email: userData.email } }); - if (updatedUser) { - updatedUser.isVerified = true; - await userRepository.save(updatedUser); - - const loginResponse = await request(app).post('/api/v1/login').send({ - email: userData.email, - password: userData.password, - }); - - expect(loginResponse.status).toBe(200); - expect(loginResponse.body.message).toBe('Please provide the 2FA code sent to your email.'); - } + const updatedUser = await userRepository.findOne({ + where: { email: userData.email }, + }); + if (updatedUser) { + updatedUser.isVerified = true; + await userRepository.save(updatedUser); + + const loginResponse = await request(app).post('/api/v1/login').send({ + email: userData.email, + password: userData.password, + }); + + expect(loginResponse.status).toBe(200); + expect(loginResponse.body.message).toBe( + 'Please provide the 2FA code sent to your email.' + ); + } }); it('should verify the 2FA code for a vendor user', async () => { @@ -188,12 +194,14 @@ describe('User Login Tests', () => { password: 'TestPassword123', userType: 'vendor', }; - + // Register the user await request(app).post('/api/v1/register').send(userData); - + // Verify the user - let user = await userRepository.findOne({ where: { email: userData.email } }); + let user = await userRepository.findOne({ + where: { email: userData.email }, + }); if (user) { user.isVerified = true; await userRepository.save(user); @@ -202,119 +210,124 @@ describe('User Login Tests', () => { email: userData.email, password: userData.password, }); - expect(loginResponse.status).toBe(200); - expect(loginResponse.body.message).toBe('Please provide the 2FA code sent to your email.'); + expect(loginResponse.body.message).toBe( + 'Please provide the 2FA code sent to your email.' + ); user = await userRepository.findOne({ where: { email: userData.email } }); - + if (user) { - - const verifyResponse = await request(app).post(`/api/v1/verify2FA/${user.id}`).send({ - code: user.twoFactorCode, - }); - + const verifyResponse = await request(app) + .post(`/api/v1/verify2FA/${user.id}`) + .send({ + code: user.twoFactorCode, + }); expect(verifyResponse.status).toBe(200); expect(verifyResponse.body).toHaveProperty('token'); } }); - it('should log in a buyer with valid credentials', async () => { const formData = { name: 'Buyer', permissions: ['test-permission1', 'test-permission2'], }; - + // Create the role first - const roleResponse = await request(app).post('/api/v1/roles/create_role').send(formData); - + const roleResponse = await request(app) + .post('/api/v1/roles/create_role') + .send(formData); + const userData = { firstName: 'Test', lastName: 'User', email: 'test2@gmail.com', password: 'TestPassword123', - userType: roleResponse.body.id + userType: roleResponse.body.id, }; await request(app).post('/api/v1/register').send(userData); - - const updatedUser = await userRepository.findOne({ where: { email: userData.email } }); + + const updatedUser = await userRepository.findOne({ + where: { email: userData.email }, + }); if (updatedUser) { updatedUser.isVerified = true; await userRepository.save(updatedUser); - + const loginResponse = await request(app).post('/api/v1/login').send({ email: userData.email, password: userData.password, }); - + expect(loginResponse.status).toBe(200); expect(loginResponse.body.token).toBeDefined(); expect(loginResponse.body.message).toBe('Buyer Logged in successfully'); - + // Decode the token and check its properties const decodedToken = jwt.decode(loginResponse.body.token); - expect(decodedToken).toHaveProperty('userId'); + expect(decodedToken).toHaveProperty('user'); expect(decodedToken).toHaveProperty('iat'); expect(decodedToken).toHaveProperty('exp'); } }); it('should return a 401 status code if the email is not verified', async () => { - const userData = { - firstName: 'Test', - lastName: 'User', - email: 'test@gmail.com', - password: 'TestPassword123', - userType: 'buyer', - }; - await request(app).post('/api/v1/register').send(userData); - const updatedUser = await userRepository.findOne({ - where: { email: userData.email }, - }); - - if (updatedUser) { - updatedUser.isVerified = false; - await userRepository.save(updatedUser); - const loginResponse = await request(app).post('/api/v1/login').send({ - email: userData.email, - password: userData.password, - }); - - expect(loginResponse.status).toBe(401); - expect(loginResponse.body.message).toBe('Please verify your email. Confirmation link has been sent.'); // Corrected message - } + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'test@gmail.com', + password: 'TestPassword123', + userType: 'buyer', + }; + await request(app).post('/api/v1/register').send(userData); + const updatedUser = await userRepository.findOne({ + where: { email: userData.email }, + }); + + if (updatedUser) { + updatedUser.isVerified = false; + await userRepository.save(updatedUser); + const loginResponse = await request(app).post('/api/v1/login').send({ + email: userData.email, + password: userData.password, + }); + + expect(loginResponse.status).toBe(401); + expect(loginResponse.body.message).toBe( + 'Please verify your email. Confirmation link has been sent.' + ); // Corrected message + } }); - + it('should return a 401 status code if the password does not match', async () => { - const userData = { - firstName: 'Test', - lastName: 'User', - email: 'test@gmail.com', - password: 'TestPassword123', - userType: 'buyer', - }; - await request(app).post('/api/v1/register').send(userData); - - const loginResponse = await request(app).post('/api/v1/login').send({ - email: userData.email, - password: 'IncorrectPassword', - }); - expect(loginResponse.status).toBe(401); - expect(loginResponse.body.message).toBe('Password does not match'); + const userData = { + firstName: 'Test', + lastName: 'User', + email: 'test@gmail.com', + password: 'TestPassword123', + userType: 'buyer', + }; + await request(app).post('/api/v1/register').send(userData); + + const loginResponse = await request(app).post('/api/v1/login').send({ + email: userData.email, + password: 'IncorrectPassword', + }); + expect(loginResponse.status).toBe(401); + expect(loginResponse.body.message).toBe('Password does not match'); }); it('should return a 404 status code if the user is not found', async () => { - const nonExistentEmail = 'nonexistent@example.com'; const loginResponse = await request(app).post('/api/v1/login').send({ - email: nonExistentEmail, - password: 'TestPassword123', + email: nonExistentEmail, + password: 'TestPassword123', }); - + expect(loginResponse.status).toBe(404); expect(loginResponse.body.message).toBe('User Not Found'); - }); - }); \ No newline at end of file + }); +}); diff --git a/src/app.ts b/src/app.ts index 7c3b5d2f..821bbf85 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,14 +4,14 @@ import morgan from 'morgan'; import swaggerUi from 'swagger-ui-express'; import swaggerSpec from './docs/swaggerconfig'; import 'reflect-metadata'; -import userRoute from './routes/userRoutes'; -import roleRoutes from './routes/roleRoutes'; +import router from './routes/index'; import fs from 'fs'; import path from 'path'; import authRoutes from './routes/auth-routes'; import cookieSession from 'cookie-session'; import passport from 'passport'; +import userRouter from './routes/userRoutes'; // Require Passport midleware require('./middlewares/passport-setup'); @@ -25,9 +25,7 @@ const logStream = fs.createWriteStream(path.join(__dirname, 'output.log'), { flags: 'a', }); -//Data Sanitation Against SQL injection - -//Data Sanitation Against SiteScripts +//Data Sanitation Against SQL injection morgan.token('type', function (req: Request) { return req.headers['content-type']; @@ -80,8 +78,8 @@ app.get('/', (req: Request, res: Response) => { }); // Middleware to handle all endpoint routes -app.use('/api/v1', userRoute); -app.use('/api/v1/roles', roleRoutes); +app.use('/api/v1', router); +app.use('/api/v1', userRouter); // Endpoints for serving social login app.use('/auth', authRoutes); diff --git a/src/controller/categoryController.ts b/src/controller/categoryController.ts new file mode 100644 index 00000000..92bddbff --- /dev/null +++ b/src/controller/categoryController.ts @@ -0,0 +1,128 @@ +import { Request, Response } from 'express'; +import dbConnection from '../database'; +import Category from '../database/models/categoryEntity'; +import { check, validationResult } from 'express-validator'; +import errorHandler from '../middlewares/errorHandler'; + +const categoryRepository = dbConnection.getRepository(Category); + +interface categoryRequestBody { + name: string; + description: string; +} + +const createCategoryRules = [ + check('name').isLength({ min: 1 }).withMessage('Category name is required'), + check('description') + .isLength({ min: 1 }) + .withMessage('Category description is required'), +]; + +export const createCategory = [ + ...createCategoryRules, + errorHandler(async (req: Request, res: Response) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const { name, description } = req.body as categoryRequestBody; + + const existingCategory = await categoryRepository.findOne({ + where: { name }, + }); + if (existingCategory) { + return res.status(409).json({ message: 'Category name already exists' }); + } + const newCategory = new Category({ + name: name, + description: description, + }); + const updatedCategory = await categoryRepository.save(newCategory); + return res.status(201).json({ + message: 'Category successfully created', + data: updatedCategory, + }); + }), +]; + +export const getAllCategories = errorHandler( + async (req: Request, res: Response) => { + const categories = await categoryRepository.find(); + return res + .status(200) + .json({ message: 'Data retrieved successfully', data: categories }); + } +); + +export const getCategory = errorHandler(async (req: Request, res: Response) => { + const categoryId: number = parseInt(req.params.categoryId); + + const category = await categoryRepository.findOne({ + where: { id: categoryId }, + }); + + if (!category) { + return res.status(404).json({ message: 'Category Not Found' }); + } + + res + .status(200) + .json({ message: 'Data retrieved successfully', data: category }); +}); + +export const updateCategory = [ + ...createCategoryRules, + errorHandler(async (req: Request, res: Response) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const categoryId: number = parseInt(req.params.categoryId); + const { name, description } = req.body as categoryRequestBody; + + const category = await categoryRepository.findOne({ + where: { id: categoryId }, + }); + + if (!category) { + return res.status(404).json({ message: 'Category Not Found' }); + } + + const existingCategory = await categoryRepository.findOne({ + where: { name }, + }); + + if (existingCategory && existingCategory.id !== categoryId) { + return res.status(409).json({ message: 'Category name already exists' }); + } + + category.name = name; + category.description = description; + + const updatedCategory = await categoryRepository.save(category); + + return res.status(200).json({ + message: 'Category successfully updated', + data: updatedCategory, + }); + }), +]; + +export const deleteCategory = errorHandler( + async (req: Request, res: Response) => { + const categoryId: number = parseInt(req.params.categoryId); + + const category = await categoryRepository.findOne({ + where: { id: categoryId }, + }); + + if (!category) { + return res.status(404).json({ message: 'Category Not Found' }); + } + + await categoryRepository.delete(categoryId); + + res.status(200).json({ message: 'Category deleted successfully' }); + } +); diff --git a/src/controller/productController.ts b/src/controller/productController.ts new file mode 100644 index 00000000..3fe2f009 --- /dev/null +++ b/src/controller/productController.ts @@ -0,0 +1,290 @@ +import { Request, Response } from 'express'; +import Product from '../database/models/productEntity'; +import Category from '../database/models/categoryEntity'; +import UserModel from '../database/models/userModel'; +import dbConnection from '../database'; +import { check, validationResult } from 'express-validator'; +import errorHandler from '../middlewares/errorHandler'; + +const productRepository = dbConnection.getRepository(Product); +const categoryRepository = dbConnection.getRepository(Category); +const userRepository = dbConnection.getRepository(UserModel); + +interface ProductRequestBody { + name: string; + image: string; + gallery: string[]; + shortDesc: string; + longDesc: string; + categoryId: number; + quantity: number; + regularPrice: number; + salesPrice: number; + tags: string[]; + type: 'Simple' | 'Grouped' | 'Variable'; + isAvailable: boolean; +} + +const createProductRules = [ + check('name').isLength({ min: 1 }).withMessage('Product name is required'), + check('image').isLength({ min: 1 }).withMessage('Product image is required'), + check('gallery').isArray().withMessage('Gallery must be an array'), + check('shortDesc') + .isLength({ min: 1 }) + .withMessage('Short description is required'), + check('longDesc') + .isLength({ min: 1 }) + .withMessage('Long description is required'), + check('categoryId') + .isInt({ min: 1 }) + .withMessage('Valid category ID is required'), + check('quantity') + .isInt({ min: 0 }) + .withMessage('Quantity must be a non-negative integer'), + check('regularPrice') + .isFloat({ min: 0 }) + .withMessage('Regular price must be a non-negative number'), + check('salesPrice') + .isFloat({ min: 0 }) + .withMessage('Sales price must be a non-negative number'), + check('tags').isArray().withMessage('Tags must be an array'), + check('type') + .isIn(['Simple', 'Grouped', 'Variable']) + .withMessage('Invalid product type'), +]; + +export const createProduct = [ + ...createProductRules, + errorHandler(async (req: Request, res: Response) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + const vendorId = req.user!.id; + + const { + name, + image, + gallery, + shortDesc, + longDesc, + categoryId, + quantity, + regularPrice, + salesPrice, + tags, + type, + } = req.body as ProductRequestBody; + + const category = await categoryRepository.findOne({ + where: { id: categoryId }, + }); + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + const vendor = await userRepository.findOne({ + where: { id: vendorId }, + select: { + id: true, + firstName: true, + }, + }); + + if (!vendor) { + return res.status(404).json({ message: 'Vendor not found' }); + } + + const existingProduct = await productRepository.findOne({ + where: { name }, + }); + + if (existingProduct) { + return res.status(409).json({ message: 'Product name already exists' }); + } + + const newProduct = new Product({ + name, + image, + gallery, + shortDesc, + longDesc, + category, + vendor, + quantity, + regularPrice, + salesPrice, + tags, + type, + }); + const updatedProduct = await productRepository.save(newProduct); + return res.status(201).json({ + message: 'Product successfully created', + data: updatedProduct, + }); + }), +]; + +const updateProductRules = [ + check('name').isLength({ min: 1 }).withMessage('Product name is required'), + check('image').isLength({ min: 1 }).withMessage('Product image is required'), + check('gallery').isArray().withMessage('Gallery must be an array'), + check('shortDesc') + .isLength({ min: 1 }) + .withMessage('Short description is required'), + check('longDesc') + .isLength({ min: 1 }) + .withMessage('Long description is required'), + check('categoryId') + .isInt({ min: 1 }) + .withMessage('Valid category ID is required'), + check('quantity') + .isInt({ min: 0 }) + .withMessage('Quantity must be a non-negative integer'), + check('regularPrice') + .isFloat({ min: 0 }) + .withMessage('Regular price must be a non-negative number'), + check('salesPrice') + .isFloat({ min: 0 }) + .withMessage('Sales price must be a non-negative number'), + check('tags').isArray().withMessage('Tags must be an array'), + check('type') + .isIn(['Simple', 'Grouped', 'Variable']) + .withMessage('Invalid product type'), + check('isAvailable') + .isBoolean() + .withMessage('isAvailable must be a boolean value'), +]; +export const updateProduct = [ + ...updateProductRules, + errorHandler(async (req: Request, res: Response) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const productId: number = parseInt(req.params.productId); + const { + name, + image, + gallery, + shortDesc, + longDesc, + categoryId, + quantity, + regularPrice, + salesPrice, + tags, + type, + isAvailable, + } = req.body as ProductRequestBody; + + const product = await productRepository.findOne({ + where: { id: productId }, + }); + + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + const category = await categoryRepository.findOne({ + where: { id: categoryId }, + }); + + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + product.name = name; + product.image = image; + product.gallery = gallery; + product.shortDesc = shortDesc; + product.longDesc = longDesc; + product.category = category; + product.quantity = quantity; + product.regularPrice = regularPrice; + product.salesPrice = salesPrice; + product.tags = tags; + product.type = type; + product.isAvailable = isAvailable; + + const updatedProduct = await productRepository.save(product); + + return res.status(200).json({ + message: 'Product successfully updated', + data: updatedProduct, + }); + }), +]; + +export const getAllProducts = errorHandler( + async (req: Request, res: Response) => { + const products = await productRepository.find({ + select: { + category: { + name: true, + }, + vendor: { + firstName: true, + }, + }, + relations: ['category', 'vendor'], + }); + return res + .status(200) + .json({ message: 'Data retrieved successfully', data: products }); + } +); + +export const getProduct = errorHandler(async (req: Request, res: Response) => { + const productId: number = parseInt(req.params.productId); + + const product = await productRepository.findOne({ + where: { id: productId }, + select: { + category: { + name: true, + }, + vendor: { + firstName: true, + }, + }, + relations: ['category', 'vendor'], + }); + + if (!product) { + return res.status(404).json({ message: 'Product not found' }); + } + + return res + .status(200) + .json({ message: 'Data retrieved successfully', data: product }); +}); + +export const deleteProduct = errorHandler( + async (req: Request, res: Response) => { + const productId: number = parseInt(req.params.productId); + + const product = await productRepository.findOne({ + where: { id: productId }, + }); + + if (!product) { + return res.status(404).json({ message: 'Product Not Found' }); + } + + await productRepository.delete(productId); + + return res.status(200).json({ message: 'Product deleted successfully' }); + } +); + +export const deleteAllProduct = errorHandler( + async (req: Request, res: Response) => { + const deletedProducts = await productRepository.delete({}); + return res.status(200).json({ + message: 'All product deleted successfully', + count: deletedProducts.affected, + }); + } +); diff --git a/src/controller/userController.ts b/src/controller/userController.ts index 83e782c1..62597b3c 100644 --- a/src/controller/userController.ts +++ b/src/controller/userController.ts @@ -7,7 +7,7 @@ import UserModel from '../database/models/userModel'; import sendEmail from '../emails/index'; import { sendCode } from '../emails/mailer'; import jwt from 'jsonwebtoken'; -import errorHandler from '../middlewares/errorHandler' +import errorHandler from '../middlewares/errorHandler'; // Assuming dbConnection.getRepository(UserModel) returns a repository instance const userRepository = dbConnection.getRepository(UserModel); @@ -164,14 +164,13 @@ export const deleteUser = async (req: Request, res: Response) => { return res .status(500) .json({ error: 'An error occurred while deleting the record.' }); + } +}; - }} - - - export const Login = errorHandler(async (req: Request, res: Response) => { - const user = await userRepository.findOne({ - where: { email: req.body['email'] }, - relations: ['userType'] +export const Login = errorHandler(async (req: Request, res: Response) => { + const user = await userRepository.findOne({ + where: { email: req.body['email'] }, + relations: ['userType'], }); if (!user) { return res.status(404).send({ message: 'User Not Found' }); @@ -187,8 +186,13 @@ export const deleteUser = async (req: Request, res: Response) => { { expiresIn: '1d' } ); const confirmLink = `${process.env.APP_URL}/api/v1/confirm?token=${token}`; - await sendEmail('confirm', user.email, { name: user.firstName, link: confirmLink }); - return res.status(401).send({ message: 'Please verify your email. Confirmation link has been sent.' }); + await sendEmail('confirm', user.email, { + name: user.firstName, + link: confirmLink, + }); + return res.status(401).send({ + message: 'Please verify your email. Confirmation link has been sent.', + }); } if (user.userType.name === 'Vendor') { @@ -196,17 +200,19 @@ export const deleteUser = async (req: Request, res: Response) => { await userRepository.update(user.id, { twoFactorCode }); - await sendCode( - user.email, - 'Your 2FA Code', - './templates/2fa.html', - { name: user.firstName, twoFactorCode: twoFactorCode.toString() } - ); + await sendCode(user.email, 'Your 2FA Code', './templates/2fa.html', { + name: user.firstName, + twoFactorCode: twoFactorCode.toString(), + }); - res.status(200).json({ message: 'Please provide the 2FA code sent to your email.' }); + res + .status(200) + .json({ message: 'Please provide the 2FA code sent to your email.' }); } else { - const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); - res.status(200).json({ token, message: 'Buyer Logged in successfully'}); + const token = jwt.sign({ user }, process.env.JWT_SECRET as jwt.Secret, { + expiresIn: '1h', + }); + res.status(200).json({ token, message: 'Buyer Logged in successfully' }); } }); @@ -214,7 +220,10 @@ export const verify2FA = errorHandler(async (req: Request, res: Response) => { const { code } = req.body; const { userId } = req.params; - const user = await userRepository.findOne({ where: { id: Number(userId) } }); + const user = await userRepository.findOne({ + where: { id: Number(userId) }, + relations: ['userType'], + }); if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); @@ -223,6 +232,8 @@ export const verify2FA = errorHandler(async (req: Request, res: Response) => { return res.status(401).json({ error: 'Invalid code' }); } - const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET as jwt.Secret, { expiresIn: '1h' }); + const token = jwt.sign({ user }, process.env.JWT_SECRET as jwt.Secret, { + expiresIn: '1h', + }); return res.status(200).json({ token }); }); diff --git a/src/database/models/categoryEntity.ts b/src/database/models/categoryEntity.ts new file mode 100644 index 00000000..31a5dbf8 --- /dev/null +++ b/src/database/models/categoryEntity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, +} from 'typeorm'; +import Product from './productEntity'; + +@Entity() +export default class Category { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true }) + name: string; + + @Column({ length: 250 }) + description: string; + + @OneToMany(() => Product, (product) => product.category, { + cascade: ['update'], + }) + products: Product[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + constructor(category: Partial) { + Object.assign(this, category); + } +} diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 42df6b1e..000308b3 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -1,2 +1,3 @@ export * from './userModel'; export * from './roleEntity'; +export * from './productEntity'; diff --git a/src/database/models/productEntity.ts b/src/database/models/productEntity.ts new file mode 100644 index 00000000..4c2fa97c --- /dev/null +++ b/src/database/models/productEntity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, +} from 'typeorm'; +import Category from './categoryEntity'; +import UserModel from './userModel'; + +@Entity() +export default class Product { + @PrimaryGeneratedColumn() + id: number; + + @Column({ length: 200 }) + name: string; + + @Column() + image: string; + + @Column('simple-array') + gallery: string[]; + + @Column({ length: 250 }) + shortDesc: string; + + @Column() + longDesc: string; + + @ManyToOne(() => Category) + category: Category; + + @Column() + quantity: number; + + @Column() + regularPrice: number; + + @Column() + salesPrice: number; + + @Column('simple-array') + tags: string[]; + + @Column({ default: 'Simple' }) + type: 'Simple' | 'Grouped' | 'Variable'; + + @Column({ default: true }) + isAvailable: boolean; + + @ManyToOne(() => UserModel) + vendor: UserModel; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + constructor(product: Partial) { + Object.assign(this, product); + } +} diff --git a/src/database/models/roleEntity.ts b/src/database/models/roleEntity.ts index 8fcf0706..d7c76ad6 100644 --- a/src/database/models/roleEntity.ts +++ b/src/database/models/roleEntity.ts @@ -1,4 +1,5 @@ -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; +import UserModel from './userModel'; @Entity() export class Role { @@ -8,6 +9,9 @@ export class Role { @Column({ unique: true }) name: string; + @OneToMany(() => UserModel, (user) => user.userType, { cascade: ['update'] }) + users: UserModel[]; + @Column('simple-array') permissions: string[]; } diff --git a/src/database/models/userModel.ts b/src/database/models/userModel.ts index 38e64b63..f71170e2 100644 --- a/src/database/models/userModel.ts +++ b/src/database/models/userModel.ts @@ -1,12 +1,6 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, -} from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; import { Role } from './roleEntity'; - @Entity() export default class UserModel { @PrimaryGeneratedColumn() @@ -18,7 +12,7 @@ export default class UserModel { @Column() lastName: string; - @Column() + @Column({ unique: true }) email: string; @Column({ nullable: true }) @@ -42,8 +36,10 @@ export default class UserModel { @Column({ default: false }) isVerified: boolean; + @Column({ default: 'active' }) + status: 'active' | 'inactive'; - @Column({ nullable: true }) + @Column({ nullable: true }) twoFactorCode: number; constructor(user: Partial) { diff --git a/src/docs/2Fadocs.ts b/src/docs/2Fadocs.ts index 510122d5..e5ac8bff 100644 --- a/src/docs/2Fadocs.ts +++ b/src/docs/2Fadocs.ts @@ -1,6 +1,6 @@ /** * @swagger - * /api/v1/verify2FA/{userId}: + * /api/v1/user/verify2FA/{userId}: * post: * summary: Verify 2FA code * tags: [Login] @@ -52,4 +52,4 @@ * error: * type: string * description: An error message indicating internal server error - */ \ No newline at end of file + */ diff --git a/src/docs/categoryDocs.ts b/src/docs/categoryDocs.ts new file mode 100644 index 00000000..5891db19 --- /dev/null +++ b/src/docs/categoryDocs.ts @@ -0,0 +1,207 @@ +/** + * @swagger + * tags: + * name: Category + * description: Category management + */ +/** + * @swagger + * /api/v1/category/: + * post: + * summary: create a new category + * tags: [Category] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * responses: + * '201': + * description: Category successfully created + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: A message indicating successful registration + * data: + * type: object + * properties: + * id: + * type: integer + * description: The unique identifier of the category + * name: + * type: string + * description: The name of the category + * description: + * type: string + * description: description of the category + * '400': + * description: Bad request + * content: + * application/json: + * schema: + * type: object + * properties: + * errors: + * type: array + * items: + * type: object + * properties: + * msg: + * type: string + * description: The error message + * '409': + * description: Conflict - Category name already exists + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: A message indicating the name of category already exists + */ +/** +/** + * @swagger + * /api/v1/category/{categoryId}: + * put: + * summary: update an existing category + * tags: [Category] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: categoryId + * type: string + * required: true + * description: ID of the category + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * responses: + * '200': + * description: Category successfully updated + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: A message indicating successful updation + * data: + * type: object + * properties: + * id: + * type: integer + * description: The unique identifier of the category + * name: + * type: string + * description: The name of the category + * description: + * type: string + * description: description of the category + * '400': + * description: Bad request + * content: + * application/json: + * schema: + * type: object + * properties: + * errors: + * type: array + * items: + * type: object + * properties: + * msg: + * type: string + * description: The error message + * '409': + * description: Conflict - Category name already exists + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * description: A message indicating the name of category already exists + */ +/** +/** + * @swagger + * /api/v1/category/: + * get: + * summary: Get all categories + * tags: [Category] + * responses: + * '200': + * description: Successful + * '404': + * description: Categories not found + * '500': + * description: Internal Server Error + */ +/** + * @swagger + * /api/v1/category/{categoryId}: + * get: + * summary: Get an existing category + * tags: [Category] + * parameters: + * - in: path + * name: categoryId + * type: string + * required: true + * description: ID of the category + * responses: + * '200': + * description: Successful + * '404': + * description: category not found + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/v1/category/{categoryId}: + * delete: + * summary: Deletes an existing category + * tags: [Category] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: categoryId + * type: string + * required: true + * description: ID of the category to delete + * responses: + * '200': + * description: Delete Successful + * '404': + * description: category not found + * '500': + * description: Internal Server Error + */ diff --git a/src/docs/productDoc.ts b/src/docs/productDoc.ts new file mode 100644 index 00000000..f2c0bd27 --- /dev/null +++ b/src/docs/productDoc.ts @@ -0,0 +1,186 @@ +/** + * @swagger + * tags: + * name: Product + * description: Operations related to products + */ + +/** + * @swagger + * /api/v1/product: + * get: + * summary: Get all products + * tags: [Product] + * responses: + * '200': + * description: Successful operation + * '500': + * description: Internal server error + * + * delete: + * summary: Delete all products + * tags: [Product] + * security: + * - bearerAuth: [] + * responses: + * '200': + * description: Products deleted successfully + * '500': + * description: Failed to delete products + */ + +/** + * @swagger + * /api/v1/product/{productId}: + * get: + * summary: Get a product by ID + * tags: [Product] + * parameters: + * - in: path + * name: productId + * type: integer + * required: true + * description: ID of the product to retrieve + * responses: + * '200': + * description: Successful operation + * '404': + * description: Product not found + * '500': + * description: Internal server error + * + * delete: + * summary: Delete a product by ID + * tags: [Product] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: productId + * type: integer + * required: true + * description: ID of the product to delete + * responses: + * '200': + * description: Product deleted successfully + * '404': + * description: Product not found + * '500': + * description: Internal server error + */ + +/** + * @swagger + * /api/v1/product: + * post: + * summary: Create a new product + * tags: [Product] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * image: + * type: string + * gallery: + * type: array + * items: + * type: string + * shortDesc: + * type: string + * longDesc: + * type: string + * categoryId: + * type: integer + * quantity: + * type: integer + * regularPrice: + * type: number + * salesPrice: + * type: number + * tags: + * type: array + * items: + * type: string + * type: + * type: string + * enum: ['Simple', 'Grouped', 'Variable'] + * isAvailable: + * type: boolean + * responses: + * '201': + * description: Product successfully created + * '400': + * description: Bad request + * '404': + * description: Category not found + * '409': + * description: Product name already exists + */ + +/** + * @swagger + * /api/v1/product/{productId}: + * put: + * summary: Update an existing product + * tags: [Product] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: productId + * type: integer + * required: true + * description: ID of the product to update + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * image: + * type: string + * gallery: + * type: array + * items: + * type: string + * shortDesc: + * type: string + * longDesc: + * type: string + * categoryId: + * type: integer + * quantity: + * type: integer + * regularPrice: + * type: number + * salesPrice: + * type: number + * tags: + * type: array + * items: + * type: string + * type: + * type: string + * enum: ['Simple', 'Grouped', 'Variable'] + * isAvailable: + * type: boolean + * responses: + * '200': + * description: Product successfully updated + * '400': + * description: Bad request + * '404': + * description: Product not found + * '500': + * description: Internal server error + */ diff --git a/src/docs/userAuth.ts b/src/docs/userAuth.ts index c9a3d2eb..4787521f 100644 --- a/src/docs/userAuth.ts +++ b/src/docs/userAuth.ts @@ -1,6 +1,6 @@ /** * @swagger - * /api/v1/login: + * /api/v1/user/login: * post: * summary: Login user * tags: [Login] @@ -90,4 +90,4 @@ * message: * type: string * description: An error message indicating internal server error - */ \ No newline at end of file + */ diff --git a/src/docs/userRegisterDocs.ts b/src/docs/userRegisterDocs.ts index de7bda60..893944d4 100644 --- a/src/docs/userRegisterDocs.ts +++ b/src/docs/userRegisterDocs.ts @@ -6,7 +6,7 @@ */ /** * @swagger - * /api/v1/register: + * /api/v1/user/register: * post: * summary: Register a new user * tags: [User] @@ -90,7 +90,7 @@ /** * @swagger - * /api/v1/confirm: + * /api/v1/user/confirm: * get: * summary: Confirm user email * tags: [User] @@ -133,3 +133,40 @@ * type: string * description: An error message indicating user not found */ + +/** + * @swagger + * /api/v1/user/getAllUsers: + * get: + * summary: Get all Users + * tags: [User] + * responses: + * '200': + * description: Successful + * '500': + * description: Internal Server Error + */ + +/** + * @swagger + * /api/v1/user/delete/{id}: + * delete: + * summary: Deletes an existing User + * tags: [User] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * type: string + * required: true + * description: ID of the user to delete + * responses: + * '200': + * description: Record deleted successfully. + * '404': + * description: Record not found. + * '500': + * description: An error occurred while deleting the record. + */ + diff --git a/src/middlewares/authorize.ts b/src/middlewares/authorize.ts index 4f368c77..c5c9984c 100644 --- a/src/middlewares/authorize.ts +++ b/src/middlewares/authorize.ts @@ -6,24 +6,37 @@ const roleRepository = dbConnection.getRepository(Role); export const checkRole = (roles: string[]) => { return async (req: Request, res: Response, next: NextFunction) => { - if (req.user && roles.includes(req.user.userType.name)) { - next(); - } else { - res.status(403).json({msg:'Forbidden'}); + try { + // Assuming req.user contains the user information after authentication + if ( + req.user && + req.user.userType && + roles.includes(req.user.userType.name) + ) { + return next(); + } else { + return res.status(403).json({ msg: 'Forbidden' }); + } + } catch (error) { + return res.status(500).json({ message: 'Internal server error' }); } }; }; export const checkPermissions = async (permission: string) => { return async (req: Request, res: Response, next: NextFunction) => { - const userRole = await roleRepository.findOneBy({ - name: req.user!.userType.name, - }); + try { + const userRole = await roleRepository.findOne({ + where: { name: req.user!.userType.name }, + }); - if (userRole && userRole.permissions.includes(permission)) { - next(); - } else { - res.status(403).json({msg:'Forbidden'}); + if (userRole && userRole.permissions.includes(permission)) { + return next(); + } else { + return res.status(403).json({ msg: 'Forbidden' }); + } + } catch (error) { + return res.status(500).json({ message: 'Internal server error' }); } }; }; diff --git a/src/middlewares/errorHandler.ts b/src/middlewares/errorHandler.ts index bed0a0a7..86b8a0c6 100644 --- a/src/middlewares/errorHandler.ts +++ b/src/middlewares/errorHandler.ts @@ -1,16 +1,20 @@ import { Request, Response } from 'express'; -type MiddlewareFunction = (req: Request, res: Response) => Promise> | undefined>; +type MiddlewareFunction = ( + req: Request, + res: Response +) => Promise> | undefined>; function errorHandler(func: MiddlewareFunction): MiddlewareFunction { - return async (req: Request, res: Response) => { - try { - return await func(req, res); - } catch (error) { - const message = (error as { detail?: string }).detail || 'Internal Server Error'; - return res.status(500).send(message); - } - }; - } + return async (req: Request, res: Response) => { + try { + return await func(req, res); + } catch (error) { + const message = + (error as { detail?: string }).detail || 'Internal Server Error'; + return res.status(500).send(message); + } + }; +} export default errorHandler; \ No newline at end of file diff --git a/src/middlewares/isLoggedIn.ts b/src/middlewares/isLoggedIn.ts new file mode 100644 index 00000000..17fea984 --- /dev/null +++ b/src/middlewares/isLoggedIn.ts @@ -0,0 +1,33 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +declare module 'express-serve-static-core' { + interface Request { + userId?: number; + userEmail?: string; + userType: { + id: number; + name: string; + permissions: []; + }; + } +} + +export const IsLoggedIn = (req: Request, res: Response, next: NextFunction) => { + const token = req.headers.authorization?.split(' ')[1]; + + if (!token) { + return res.status(401).json({ message: 'Unauthorized: No token provided' }); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET as jwt.Secret); + + // @ts-expect-error this is because ts + req.user = decoded.user; + + next(); + } catch (error) { + return res.status(401).json({ message: 'Unauthorized: Invalid token' }); + } +}; diff --git a/src/middlewares/passport-setup.ts b/src/middlewares/passport-setup.ts index 5e95b267..23e86496 100644 --- a/src/middlewares/passport-setup.ts +++ b/src/middlewares/passport-setup.ts @@ -2,7 +2,7 @@ import passport from 'passport'; import GooglePassport from 'passport-google-oauth20'; import FacebookPassport from 'passport-facebook'; import dbConnection from '../database'; -import UserModel from '../database/models/userModel'; +import UserModel from '../database/models/userModel'; import dotenv from 'dotenv'; dotenv.config(); diff --git a/src/routes/categoryRoutes.ts b/src/routes/categoryRoutes.ts new file mode 100644 index 00000000..f28acf61 --- /dev/null +++ b/src/routes/categoryRoutes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { + createCategory, + deleteCategory, + getAllCategories, + getCategory, + updateCategory, +} from '../controller/categoryController'; +import { IsLoggedIn } from '../middlewares/isLoggedIn'; + +const categoryRouter = Router(); + +categoryRouter + .route('/') + .post(IsLoggedIn, createCategory) + .get(getAllCategories); +categoryRouter + .route('/:categoryId') + .get(getCategory) + .put(IsLoggedIn, updateCategory) + .delete(IsLoggedIn, deleteCategory); + +export default categoryRouter; diff --git a/src/routes/index.ts b/src/routes/index.ts index f6117b86..da75aa8f 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,8 +1,14 @@ -// import roleRoutes from './roleRoutes' -// import { Router } from 'express' +import { Router } from 'express'; +import userRouter from './userRoutes'; +import roleRoutes from './roleRoutes'; +import productRoutes from './productRoutes'; +import categoryRoutes from './categoryRoutes'; -// const router = Router() +const router = Router(); -// router.use('/roles', roleRoutes) +router.use('/user', userRouter); +router.use('/roles', roleRoutes); +router.use('/product', productRoutes); +router.use('/category', categoryRoutes); -// export default router +export default router; diff --git a/src/routes/productRoutes.ts b/src/routes/productRoutes.ts new file mode 100644 index 00000000..d1f51f25 --- /dev/null +++ b/src/routes/productRoutes.ts @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import { + createProduct, + deleteAllProduct, + deleteProduct, + getAllProducts, + getProduct, + updateProduct, +} from '../controller/productController'; +import { IsLoggedIn } from '../middlewares/isLoggedIn'; +import { checkRole } from '../middlewares/authorize'; + +const productRouter = Router(); + +productRouter + .route('/') + .post(IsLoggedIn, checkRole(['Vendor']), createProduct) + .get(getAllProducts) + .delete(IsLoggedIn, deleteAllProduct); +productRouter + .route('/:productId') + .get(getProduct) + .put(IsLoggedIn, checkRole(['Vendor']), updateProduct) + .delete(IsLoggedIn, deleteProduct); + +export default productRouter; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts index f1b1ac6a..5e89371a 100644 --- a/src/routes/userRoutes.ts +++ b/src/routes/userRoutes.ts @@ -6,18 +6,16 @@ import { getAllUsers, deleteUser, Login, - verify2FA + verify2FA, } from '../controller/userController'; -const route = Router(); -route.post('/register', registerUser); -route.get('/getAllUsers', getAllUsers); -route.get('/confirm', confirmEmail); -route.delete('/delete/:id', deleteUser); -route.delete('/deleteAllUsers', deleteAllUsers); -route.post('/login',Login) -route.get('/all-users', getAllUsers); -route.post('/verify2FA/:userId', verify2FA); - -export default route; +const userRouter = Router(); +userRouter.post('/register', registerUser); +userRouter.get('/getAllUsers', getAllUsers); +userRouter.get('/confirm', confirmEmail); +userRouter.delete('/delete/:id', deleteUser); +userRouter.delete('/deleteAllUsers', deleteAllUsers); +userRouter.post('/login', Login); +userRouter.post('/verify2FA/:userId', verify2FA); +export default userRouter;