diff --git a/.env.example b/.env.example index a9774858..cdf1da8a 100644 --- a/.env.example +++ b/.env.example @@ -29,4 +29,5 @@ GOOGLE_AUTH_CALLBACK_URL= FLW_PUBLIC_KEY= FLW_SECRET_KEY= FLW_ENCRYPTION_KEY= -BASE_URL= \ No newline at end of file +BASE_URL= +PAYSTACK_SECRET_KEY=your_paystack_secret_key \ No newline at end of file diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 20a83ed3..dc06e527 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,9 +1,11 @@ name: CI on: + push: + branches: [dev, staging, main] pull_request: - branches: - - dev + types: [opened, synchronize, reopened] + branches: [dev, staging, main] jobs: test: @@ -25,5 +27,6 @@ jobs: run: yarn test env: CI: true - - name: buld the dist + + - name: Build the dist run: yarn build diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 25ae43b0..792f4dce 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -1,4 +1,4 @@ -name: Build, Test, and Deploy for Dev Branch +name: Deploy to Dev on: push: @@ -6,35 +6,74 @@ on: - dev jobs: - build: - runs-on: self-hosted + deploy: + runs-on: bp_runner defaults: run: - working-directory: /var/www/aihomework/dev + working-directory: /var/www/aihomework/boilerplate/dev steps: - - name: Pull from github + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Remove old actions remote URL + continue-on-error: true + run: | + git remote rm action + + - name: Stash or remove local changes + run: | + if git diff --quiet; then + echo "No local changes to stash." + else + echo "Stashing local changes..." + git stash --include-untracked || echo "Failed to stash changes. Attempting to reset..." + git reset --hard || exit 1 + fi + + - name: Pull from GitHub id: pull run: | - git stash - git pull origin dev + remote_repo="https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git" + git remote add action $remote_repo + git pull $remote_repo dev + - - name: install dependencies - run: yarn install + - name: Install dependencies + run: yarn install --frozen-lockfile - - name: Run Test + - name: Run tests run: yarn test - - name: buld the dist - run: yarn build + - name: Build the application + run: yarn build && sudo rm -rf build - - name: migrate - run: yarn reset-db + # - name: Generate migrations + # run: yarn migration:generate - - name: setup service file - run: sudo cp server-script/aihomeworkdev.service /etc/systemd/system + # - name: Run migrations + # run: yarn migration:run - - name: start the app + - name: Setup and restart service run: | + sudo cp server-script/aihomeworkdev.service /etc/systemd/system sudo systemctl daemon-reload - sudo systemctl restart aihomeworkdev + sudo systemctl restart aihomeworkdev.service + + - name: Verify deployment + run: | + echo "Waiting for service to start..." + sleep 10 + if sudo systemctl is-active --quiet aihomeworkdev.service; then + echo "Deployment successful!" + else + echo "Deployment failed!" + exit 1 + fi diff --git a/.github/workflows/pr-deploy.yml b/.github/workflows/pr-deploy.yml new file mode 100644 index 00000000..135f1e38 --- /dev/null +++ b/.github/workflows/pr-deploy.yml @@ -0,0 +1,35 @@ +name: PR Deploy + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +jobs: + deploy-pr: + environment: + name: preview + url: ${{ steps.deploy.outputs.preview-url }} + runs-on: ubuntu-latest + steps: + - name: Checkout the branch + uses: actions/checkout@v4 + + - id: deploy + name: Pull Request Deploy + uses: hngprojects/pr-deploy@main-patch + with: + server_host: ${{ secrets.SERVER_HOST }} + server_username: ${{ secrets.SERVER_USERNAME }} + server_password: ${{ secrets.SERVER_PASSWORD }} + server_port: ${{ secrets.SERVER_PORT }} + comment: true + context: '.' + dockerfile: 'Dockerfile' + exposed_port: '8000' + #host_volume_path: '/var/' + #container_volume_path: '/var/' + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Print Preview URL + run: | + echo "Preview URL: ${{ steps.deploy.outputs.preview-url }}" diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 64a6096e..cc25ee19 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -1,35 +1,78 @@ -name: Build, Test, and Deploy for Prod Branch +name: Deploy to production on: push: - branches: [main] + branches: + - prod jobs: - build: - runs-on: self-hosted + deploy: + runs-on: bp_runner defaults: run: - working-directory: /var/www/aihomework/prod + working-directory: /var/www/aihomework/boilerplate/prod steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Remove old actions remote URL + continue-on-error: true + run: | + git remote rm action + + - name: Stash or remove local changes + run: | + if git diff --quiet; then + echo "No local changes to stash." + else + echo "Stashing local changes..." + git stash --include-untracked || echo "Failed to stash changes. Attempting to reset..." + git reset --hard || exit 1 + fi + - name: Pull from GitHub + id: pull run: | - git stash - git pull origin main + remote_repo="https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git" + git remote add action $remote_repo + git pull $remote_repo prod - name: Install dependencies - run: yarn install + run: yarn install --frozen-lockfile + + - name: Run tests + run: yarn test - - name: Build the dist - run: yarn build + - name: Build the application + run: yarn build && sudo rm -rf build - - name: migrate - run: yarn migrate + # - name: Generate migrations + # run: yarn migration:generate - - name: Setup service file - run: sudo cp server-script/aihomeworkprod.service /etc/systemd/system + # - name: Run migrations + # run: yarn migration:run - - name: Start the app + - name: Setup and restart service run: | + sudo cp server-script/aihomeworkprod.service /etc/systemd/system sudo systemctl daemon-reload - sudo systemctl restart aihomeworkprod + sudo systemctl restart aihomeworkprod.service + + - name: Verify deployment + run: | + echo "Waiting for service to start..." + sleep 10 + if sudo systemctl is-active --quiet aihomeworkprod.service; then + echo "Deployment successful!" + else + echo "Deployment failed!" + exit 1 + fi diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 3e020bd7..92c611b2 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -1,40 +1,78 @@ -name: Build, Test, and Deploy for Dev Branch +name: Deploy to staging on: push: branches: - - dev + - staging jobs: - build: - runs-on: self-hosted + deploy: + runs-on: bp_runner defaults: run: - working-directory: /var/www/aihomework/staging + working-directory: /var/www/aihomework/boilerplate/staging steps: - - name: Pull from github + - name: Checkout code + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Remove old actions remote URL + continue-on-error: true + run: | + git remote rm action + + - name: Stash or remove local changes + run: | + if git diff --quiet; then + echo "No local changes to stash." + else + echo "Stashing local changes..." + git stash --include-untracked || echo "Failed to stash changes. Attempting to reset..." + git reset --hard || exit 1 + fi + + - name: Pull from GitHub id: pull run: | - git stash - git pull origin staging + remote_repo="https://${GITHUB_ACTOR}:${{ secrets.GITHUB_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git" + git remote add action $remote_repo + git pull $remote_repo staging - - name: install dependencies - run: yarn install + - name: Install dependencies + run: yarn install --frozen-lockfile - - name: Run Test + - name: Run tests run: yarn test - - name: buld the dist - run: yarn build + - name: Build the application + run: yarn build && sudo rm -rf build - - name: migrate - run: yarn reset-db + # - name: Generate migrations + # run: yarn migration:generate - - name: setup service file - run: sudo cp server-script/aihomeworkstaging.service /etc/systemd/system + # - name: Run migrations + # run: yarn migration:run - - name: start the app + - name: Setup and restart service run: | + sudo cp server-script/aihomeworkstaging.service /etc/systemd/system sudo systemctl daemon-reload - sudo systemctl restart aihomeworkstaging + sudo systemctl restart aihomeworkstaging.service + + - name: Verify deployment + run: | + echo "Waiting for service to start..." + sleep 10 + if sudo systemctl is-active --quiet aihomeworkstaging.service; then + echo "Deployment successful!" + else + echo "Deployment failed!" + exit 1 + fi diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 82eb3069..82ba1d0c 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -1,12 +1,12 @@ services: - backend: + backend_prod: container_name: backend_prod build: context: . ports: - 4444:8000 env_file: - - /var/www/aihomework/prod/.env + - .env environment: NODE_ENV: production DB_HOST: backend_db_prod @@ -24,7 +24,7 @@ services: container_name: backend_db_prod restart: unless-stopped env_file: - - /var/www/aihomework/prod/.env + - .env environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index 1c49750c..3e9dba04 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -1,12 +1,12 @@ services: - backend: + backend_staging: container_name: backend_staging build: context: . ports: - 3333:8000 env_file: - - /var/www/aihomework/staging/.env + - .env environment: NODE_ENV: staging DB_HOST: backend_db_staging @@ -24,7 +24,7 @@ services: container_name: backend_db_staging restart: unless-stopped env_file: - - /var/www/aihomework/staging/.env + - .env environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} diff --git a/docker-compose.yml b/docker-compose.yml index ac4afe9e..89f740c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,4 @@ +version: "3.8" services: backend: build: @@ -5,7 +6,7 @@ services: ports: - 2222:8000 env_file: - - /var/www/aihomework/dev/.env + - .env environment: NODE_ENV: development DB_HOST: backend_db @@ -23,7 +24,7 @@ services: container_name: backend_db restart: unless-stopped env_file: - - /var/www/aihomework/dev/.env + - .env environment: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} diff --git a/package.json b/package.json index e9e61e43..f7b40c14 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "open": "^10.1.0", "passport": "^0.7.0", "passport-google-oauth2": "^0.2.0", + "paystack": "^2.0.1", "pdfkit": "^0.15.0", "pg": "^8.12.0", "pino": "^9.3.1", diff --git a/server-script/aihomeworkdev.service b/server-script/aihomeworkdev.service index 601f0ff4..e77fdf16 100644 --- a/server-script/aihomeworkdev.service +++ b/server-script/aihomeworkdev.service @@ -3,11 +3,11 @@ Description=AIHomework-Dev After=network.target [Service] -WorkingDirectory=/var/www/aihomework/dev -ExecStart=/bin/bash /var/www/aihomework/dev/server-script/startappdev.sh +WorkingDirectory=/var/www/aihomework/boilerplate/dev +ExecStart=/bin/bash /var/www/aihomework/boilerplate/dev/server-script/startappdev.sh #Restart=on-failure #RestartSec=20s StartLimitInterval=0 [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target diff --git a/server-script/aihomeworkprod.service b/server-script/aihomeworkprod.service index fe26ab18..b4fad082 100644 --- a/server-script/aihomeworkprod.service +++ b/server-script/aihomeworkprod.service @@ -3,11 +3,11 @@ Description=AIHomework-Prod After=network.target [Service] -WorkingDirectory=/var/www/aihomework/prod -ExecStart=/bin/bash /var/www/aihomework/prod/server-script/startappprod.sh +WorkingDirectory=/var/www/aihomework/boilerplate/prod +ExecStart=/bin/bash /var/www/aihomework/boilerplate/prod/server-script/startappprod.sh #Restart=on-failure #RestartSec=20s StartLimitInterval=0 [Install] -WantedBy=multi-user.target \ No newline at end of file +WantedBy=multi-user.target diff --git a/server-script/aihomeworkstaging.service b/server-script/aihomeworkstaging.service index 831038c2..a4d624df 100644 --- a/server-script/aihomeworkstaging.service +++ b/server-script/aihomeworkstaging.service @@ -3,8 +3,8 @@ Description=AIHomework-Dev After=network.target [Service] -WorkingDirectory=/var/www/aihomework/staging -ExecStart=/bin/bash /var/www/aihomework/dev/server-script/startappstaging.sh +WorkingDirectory=/var/www/aihomework/boilerplate/staging +ExecStart=/bin/bash /var/www/aihomework/dev/boilerplate/server-script/startappstaging.sh #Restart=on-failure #RestartSec=20s StartLimitInterval=0 diff --git a/server-script/startappdev.sh b/server-script/startappdev.sh index 3f9ba92e..2127cb57 100755 --- a/server-script/startappdev.sh +++ b/server-script/startappdev.sh @@ -1,5 +1,5 @@ #!/bin/bash -cd /var/www/aihomework/dev/ +cd /var/www/aihomework/boilerplate/dev/ mkdir -p logs -/usr/bin/yarn start >> logs/devoutput.log 2>&1 +/usr/local/bin/yarn start >> logs/devoutput.log 2>&1 diff --git a/server-script/startappprod.sh b/server-script/startappprod.sh index e7f711a5..44cccc70 100755 --- a/server-script/startappprod.sh +++ b/server-script/startappprod.sh @@ -1,5 +1,5 @@ #!/bin/bash -cd /var/www/aihomework/prod/ +cd /var/www/aihomework/boilerplate/prod/ mkdir -p logs /usr/bin/yarn start >> logs/prodoutput.log 2>&1 diff --git a/server-script/startappstaging.sh b/server-script/startappstaging.sh index 9e056b30..36707514 100644 --- a/server-script/startappstaging.sh +++ b/server-script/startappstaging.sh @@ -1,5 +1,5 @@ #!/bin/bash -cd /var/www/aihomework/staging/ +cd /var/www/aihomework/boilerplate/staging mkdir -p logs /usr/bin/yarn start >> logs/stagingoutput.log 2>&1 diff --git a/src/config/index.ts b/src/config/index.ts index 6f130b30..a9fe2c06 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -32,6 +32,8 @@ const config = { FLW_ENCRYPTION_KEY: process.env.FLW_ENCRYPTION_KEY, LEMONSQUEEZY_SIGNING_KEY: process.env.LEMONSQUEEZY_SIGNING_KEY, BASE_URL: process.env.BASE_URL, + PAYSTACK_SECRET_KEY: process.env.PAYSTACK_SECRET_KEY, + SWAGGER_JSON_URL: process.env.SWAGGER_JSON_URL, }; export default config; diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 0d2e735a..3480ab95 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -2,7 +2,7 @@ import { NextFunction, Request, Response } from "express"; import jwt from "jsonwebtoken"; import config from "../config"; import { verifyToken } from "../config/google.passport.config"; -import { BadRequest } from "../middleware"; +import { BadRequest, Unauthorized } from "../middleware"; import { User } from "../models"; import { AuthService } from "../services/auth.services"; import { GoogleUserInfo } from "../services/google.auth.service"; @@ -607,6 +607,24 @@ const enable2FA = async (req: Request, res: Response, next: NextFunction) => { }); }; +const verify2FA = async (req: Request, res: Response, next: NextFunction) => { + const { totp_code } = req.body; + const user = req.user; + if (!user.is_2fa_enabled) { + return next(new BadRequest("2FA is not enabled")); + } + const is_verified = authService.verify2FA(totp_code, user); + if (!is_verified) { + return next(new Unauthorized("Invalid 2FA code")); + } + + return res.status(200).json({ + status_code: 200, + message: "2FA code verified", + data: { verified: true }, + }); +}; + export { VerifyUserMagicLink, changePassword, @@ -619,4 +637,5 @@ export { signUp, verifyOtp, enable2FA, + verify2FA, }; diff --git a/src/controllers/BlogController.ts b/src/controllers/BlogController.ts index cd53fa44..70cf13b4 100644 --- a/src/controllers/BlogController.ts +++ b/src/controllers/BlogController.ts @@ -1,5 +1,6 @@ -import { Request, Response } from "express"; +import { NextFunction, Request, Response } from "express"; import { BlogService } from "../services"; +import { ResourceNotFound, ServerError, Forbidden } from "../middleware"; export class BlogController { private blogService: BlogService; @@ -384,7 +385,7 @@ export class BlogController { } } - async createBlogController(req: Request, res: Response) { + async createBlogController(req: Request, res: Response, next: NextFunction) { const { title, content, image_url, tags, categories } = req.body; try { @@ -413,4 +414,187 @@ export class BlogController { }); } } + /** + * @swagger + * /blog/edit/{id}: + * patch: + * summary: Edit a blog post + * description: Update the details of a blog post including title, content, image URL, tags, categories, and publish date. Only the author can update their blog post. + * tags: [Blog] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The ID of the blog post to edit + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * description: The title of the blog post + * content: + * type: string + * description: The content of the blog post + * image_url: + * type: string + * description: The URL of the blog post's image + * tags: + * type: string + * description: A comma-separated list of tags for the blog post + * categories: + * type: string + * description: A comma-separated list of categories for the blog post + * publish_date: + * type: string + * format: date-time + * description: The publish date of the blog post + * example: + * title: "Updated Blog Title" + * content: "This is the updated content of the blog post." + * image_url: "http://example.com/image.jpg" + * tags: "technology, AI" + * categories: "Tech News, Artificial Intelligence" + * publish_date: "2023-09-12T10:00:00Z" + * responses: + * 200: + * description: Blog post updated successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * status_code: + * type: integer + * example: 200 + * message: + * type: string + * example: Blog post updated successfully. + * post: + * type: object + * properties: + * blog_id: + * type: string + * example: "12345" + * title: + * type: string + * example: "Updated Blog Title" + * content: + * type: string + * example: "This is the updated content of the blog post." + * tags: + * type: array + * items: + * type: string + * categories: + * type: array + * items: + * type: string + * image_urls: + * type: string + * example: "http://example.com/image.jpg" + * author: + * type: string + * example: "Author Name" + * updated_at: + * type: string + * format: date-time + * example: "2023-09-12T10:00:00Z" + * 400: + * description: Bad Request - Invalid input data. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: unsuccessful + * status_code: + * type: integer + * example: 400 + * message: + * type: string + * example: Invalid request data. + * 403: + * description: Unauthorized - User is not allowed to update this blog post. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: unsuccessful + * status_code: + * type: integer + * example: 403 + * message: + * type: string + * example: Unauthorized access. + * 404: + * description: Blog post not found. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: unsuccessful + * status_code: + * type: integer + * example: 404 + * message: + * type: string + * example: Blog post not found. + * 500: + * description: An unexpected error occurred while processing the request. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: unsuccessful + * status_code: + * type: integer + * example: 500 + * message: + * type: string + * example: An unexpected error occurred. + */ + + async updateBlog(req: Request, res: Response, next: NextFunction) { + try { + const userId = req.user.id; + const blogId = req.params.id; + + const updatedBlog = await this.blogService.updateBlog( + blogId, + req.body, + userId, + ); + res.status(200).json({ + status: "success", + status_code: 200, + message: "Blog post updated successfully.", + data: updatedBlog, + }); + } catch (error) { + if (error instanceof ResourceNotFound || error instanceof Forbidden) { + next(error); + } + next(new ServerError("Internal server error.")); + } + } } diff --git a/src/controllers/FaqController.ts b/src/controllers/FaqController.ts index dff19a3e..78873329 100644 --- a/src/controllers/FaqController.ts +++ b/src/controllers/FaqController.ts @@ -2,8 +2,7 @@ import { NextFunction, Request, Response } from "express"; import { FAQService } from "../services"; import { UserRole } from "../enums/userRoles"; import isSuperAdmin from "../utils/isSuperAdmin"; -import { Category } from "../models"; -import { HttpError } from "../middleware"; +import { ServerError, BadRequest, HttpError } from "../middleware"; const faqService = new FAQService(); @@ -339,7 +338,10 @@ class FAQController { status_code: 200, }); } catch (error) { - next(error); + if (error instanceof BadRequest) { + next(error); + } + next(new ServerError("Internal server error.")); } } diff --git a/src/controllers/NotificationController.ts b/src/controllers/NotificationController.ts index 0d0e6834..4b4b0d3e 100644 --- a/src/controllers/NotificationController.ts +++ b/src/controllers/NotificationController.ts @@ -1,239 +1,71 @@ -import { NotificationSetting } from "../models/notification"; -import { Request, Response } from "express"; -import { validate } from "class-validator"; +import { Request, Response, NextFunction } from "express"; +import { NotificationsService } from "../services"; +import { User } from "../models"; +import { Repository } from "typeorm"; +import AppDataSource from "../data-source"; -/** - * @swagger - * /api/v1/settings/notification-settings: - * put: - * summary: Create or update user notification settings - * tags: [Notifications] - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * email_notifications: - * type: boolean - * push_notifications: - * type: boolean - * sms_notifications: - * type: boolean - * responses: - * 200: - * description: Notification settings created or updated successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: success - * code: - * type: number - * example: 200 - * data: - * type: object - * properties: - * id: - * type: number - * example: 1 - * user_id: - * type: number - * example: 123 - * email_notifications: - * type: boolean - * example: true - * push_notifications: - * type: boolean - * example: false - * sms_notifications: - * type: boolean - * example: true - * 400: - * description: Validation failed - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: error - * code: - * type: number - * example: 400 - * message: - * type: string - * example: Validation failed - * errors: - * type: array - * items: - * type: object - * 500: - * description: Server error - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: error - * code: - * type: number - * example: 500 - * message: - * type: string - * example: Error updating user notification settings - * error: - * type: string - */ +class NotificationController { + private notificationsService: NotificationsService; + private userRepository: Repository; -const CreateOrUpdateNotification = async (req: Request, res: Response) => { - try { - const user_id = req.user.id; - const { email_notifications, push_notifications, sms_notifications } = - req.body; + constructor() { + this.notificationsService = new NotificationsService(); + this.userRepository = AppDataSource.getRepository(User); + } - let notificationSetting = await NotificationSetting.findOne({ - where: { user_id }, - }); + public getNotificationsForUser = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const userId = req.user?.id; + if (!userId) { + res.status(400).json({ + status: "fail", + status_code: 400, + message: "User ID is required", + }); + return; + } - if (notificationSetting) { - // Update existing setting - notificationSetting.email_notifications = email_notifications; - notificationSetting.push_notifications = push_notifications; - notificationSetting.sms_notifications = sms_notifications; - } else { - // Create new setting - notificationSetting = NotificationSetting.create({ - user_id, - email_notifications, - push_notifications, - sms_notifications, - }); - } + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + res.status(404).json({ + status: "success", + status_code: 404, + message: "User not found!", + }); + return; + } + const notifications = + await this.notificationsService.getNotificationsForUser(userId); - // Validate the notificationSetting entity - const errors = await validate(notificationSetting); - if (errors.length > 0) { - return res.status(400).json({ - status: "error", - code: 400, - message: "Validation failed", - errors: errors, + res.status(200).json({ + status: "success", + status_code: 200, + message: "Notifications retrieved successfully", + data: { + total_notification_count: notifications.totalNotificationCount, + total_unread_notification_count: + notifications.totalUnreadNotificationCount, + notifications: notifications.notifications.map( + ({ id, isRead, message, createdAt }) => ({ + notification_id: id, + is_read: isRead, + message, + created_at: createdAt, + }), + ), + }, }); - } - - const result = await NotificationSetting.save(notificationSetting); - res.status(200).json({ status: "success", code: 200, data: result }); - } catch (error) { - res.status(500).json({ - status: "error", - code: 500, - message: "Error updating user notification settings", - error: error.message, - }); - } -}; - -/** - * @swagger - * api/v1/settings/notification-settings/{user_id}: - * get: - * summary: Get notification settings for a user - * tags: [Notifications] - * description: Retrieves the notification settings for a specific user - * parameters: - * - in: path - * name: user_id - * required: true - * description: ID of the user to get notification settings for - * schema: - * type: string - * responses: - * 200: - * description: Successful response - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: success - * code: - * type: integer - * example: 200 - * data: - * type: object - * properties: - * user_id: - * type: string - * example: "123456" - * email_notifications: - * type: boolean - * example: true - * push_notifications: - * type: boolean - * example: false - * sms_notifications: - * type: boolean - * example: true - * 404: - * description: User not found - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: Not found - * message: - * type: string - * example: The user with the requested id cannot be found - * 500: - * description: Server error - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: error - * code: - * type: integer - * example: 500 - * message: - * type: string - * example: Internal server error - */ - -const GetNotification = async (req: Request, res: Response) => { - try { - const settings = await NotificationSetting.findOne({ - where: { user_id: String(req.params.user_id) }, - }); - if (settings === null) { - return res.status(404).json({ - status: "Not found", - message: "The user with the requested id cannot be found", + } catch (error) { + res.status(500).json({ + status_code: 500, + error: error.message || "An unexpected error occurred", }); } - res.status(200).json({ status: "success", code: 200, data: settings }); - } catch (error) { - res - .status(500) - .json({ status: "error", code: 500, message: error.message }); - } -}; + }; +} -export { CreateOrUpdateNotification, GetNotification }; +export { NotificationController }; diff --git a/src/controllers/NotificationSettingsController.ts b/src/controllers/NotificationSettingsController.ts new file mode 100644 index 00000000..85f75277 --- /dev/null +++ b/src/controllers/NotificationSettingsController.ts @@ -0,0 +1,239 @@ +import { NotificationSetting } from "../models/notificationsettings"; +import { Request, Response } from "express"; +import { validate } from "class-validator"; + +/** + * @swagger + * /api/v1/settings/notification-settings: + * put: + * summary: Create or update user notification settings + * tags: [Notifications] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email_notifications: + * type: boolean + * push_notifications: + * type: boolean + * sms_notifications: + * type: boolean + * responses: + * 200: + * description: Notification settings created or updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * code: + * type: number + * example: 200 + * data: + * type: object + * properties: + * id: + * type: number + * example: 1 + * user_id: + * type: number + * example: 123 + * email_notifications: + * type: boolean + * example: true + * push_notifications: + * type: boolean + * example: false + * sms_notifications: + * type: boolean + * example: true + * 400: + * description: Validation failed + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: number + * example: 400 + * message: + * type: string + * example: Validation failed + * errors: + * type: array + * items: + * type: object + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: number + * example: 500 + * message: + * type: string + * example: Error updating user notification settings + * error: + * type: string + */ + +const CreateOrUpdateNotification = async (req: Request, res: Response) => { + try { + const user_id = req.user.id; + const { email_notifications, push_notifications, sms_notifications } = + req.body; + + let notificationSetting = await NotificationSetting.findOne({ + where: { user_id }, + }); + + if (notificationSetting) { + // Update existing setting + notificationSetting.email_notifications = email_notifications; + notificationSetting.push_notifications = push_notifications; + notificationSetting.sms_notifications = sms_notifications; + } else { + // Create new setting + notificationSetting = NotificationSetting.create({ + user_id, + email_notifications, + push_notifications, + sms_notifications, + }); + } + + // Validate the notificationSetting entity + const errors = await validate(notificationSetting); + if (errors.length > 0) { + return res.status(400).json({ + status: "error", + code: 400, + message: "Validation failed", + errors: errors, + }); + } + + const result = await NotificationSetting.save(notificationSetting); + res.status(200).json({ status: "success", code: 200, data: result }); + } catch (error) { + res.status(500).json({ + status: "error", + code: 500, + message: "Error updating user notification settings", + error: error.message, + }); + } +}; + +/** + * @swagger + * api/v1/settings/notification-settings/{user_id}: + * get: + * summary: Get notification settings for a user + * tags: [Notifications] + * description: Retrieves the notification settings for a specific user + * parameters: + * - in: path + * name: user_id + * required: true + * description: ID of the user to get notification settings for + * schema: + * type: string + * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * code: + * type: integer + * example: 200 + * data: + * type: object + * properties: + * user_id: + * type: string + * example: "123456" + * email_notifications: + * type: boolean + * example: true + * push_notifications: + * type: boolean + * example: false + * sms_notifications: + * type: boolean + * example: true + * 404: + * description: User not found + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: Not found + * message: + * type: string + * example: The user with the requested id cannot be found + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: error + * code: + * type: integer + * example: 500 + * message: + * type: string + * example: Internal server error + */ + +const GetNotification = async (req: Request, res: Response) => { + try { + const settings = await NotificationSetting.findOne({ + where: { user_id: String(req.params.user_id) }, + }); + if (settings === null) { + return res.status(404).json({ + status: "Not found", + message: "The user with the requested id cannot be found", + }); + } + res.status(200).json({ status: "success", code: 200, data: settings }); + } catch (error) { + res + .status(500) + .json({ status: "error", code: 500, message: error.message }); + } +}; + +export { CreateOrUpdateNotification, GetNotification }; diff --git a/src/controllers/OrgController.ts b/src/controllers/OrgController.ts index 2713ccbb..df4e6595 100644 --- a/src/controllers/OrgController.ts +++ b/src/controllers/OrgController.ts @@ -1331,87 +1331,111 @@ export class OrgController { /** * @swagger * /api/v1/organizations/{org_id}/roles: - * get: - * summary: Get all roles in an organization - * tags: [Organizations] + * post: + * summary: Create a new organization role + * tags: [Organization Roles] + * security: + * - bearerAuth: [] * parameters: * - in: path * name: org_id + * required: true * schema: * type: string - * required: true - * description: The ID of the organization + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateOrgRole' * responses: - * 200: - * description: A list of roles in the organization + * 201: + * description: Organization role created successfully * content: * application/json: * schema: - * type: object - * properties: - * status_code: - * type: integer - * example: 200 - * data: - * type: array - * items: - * type: object - * properties: - * id: - * type: string - * name: - * type: string - * description: - * type: string + * $ref: '#/components/schemas/OrganizationRole' * 400: - * description: Bad request, possibly due to invalid organization ID + * description: Bad request * content: * application/json: * schema: - * type: object - * properties: - * error: - * type: string - * status_code: - * type: integer - * example: 400 - * 401: - * description: Unauthorized, possibly due to missing or invalid credentials - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * status_code: - * type: integer - * example: 401 + * $ref: '#/components/schemas/ErrorResponse' * 404: * description: Organization not found * content: * application/json: * schema: - * type: object - * properties: - * error: - * type: string - * status_code: - * type: integer - * example: 404 + * $ref: '#/components/schemas/ErrorResponse' * 500: - * description: An error occurred while fetching the roles + * description: Internal server error * content: * application/json: * schema: - * type: object - * properties: - * error: - * type: string - * status_code: - * type: integer - * example: 500 + * $ref: '#/components/schemas/ErrorResponse' + * + * components: + * schemas: + * CreateOrgRole: + * type: object + * required: + * - name + * - description + * properties: + * name: + * type: string + * description: + * type: string + * OrganizationRole: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * description: + * type: string + * organization: + * $ref: '#/components/schemas/Organization' + * Organization: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * ErrorResponse: + * type: object + * properties: + * error: + * type: string + * status_code: + * type: integer */ + async createOrganizationRole( + req: Request, + res: Response, + next: NextFunction, + ) { + try { + const organizationId = req.params.org_id; + const payload = req.body; + const response = await this.orgService.createOrganizationRole( + payload, + organizationId, + ); + + return res.status(201).json({ + status_code: 201, + data: response, + }); + } catch (err) { + if (err instanceof ResourceNotFound) { + next(err); + } + next(new ServerError("Error creating Organization roles")); + } + } async getAllOrganizationRoles( req: Request, diff --git a/src/controllers/SqueezeController.ts b/src/controllers/SqueezeController.ts index 38a95ebe..306f4199 100644 --- a/src/controllers/SqueezeController.ts +++ b/src/controllers/SqueezeController.ts @@ -10,7 +10,7 @@ class SqueezeController { /** * @openapi - * /api/v1/squeeze-pages: + * /api/v1/squeezes: * post: * tags: * - Squeeze @@ -158,6 +158,81 @@ class SqueezeController { }); } }; + + /** + * @openapi + * /api/v1/squeezes/{squeeze_id}: + * post: + * tags: + * - Squeeze + * summary: Update a squeeze page + * description: Update a squeeze entry. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * first_name: + * type: string + * last_name: + * type: string + * phone: + * type: string + * location: + * type: string + * job_title: + * type: string + * company: + * type: string + * interests: + * type: array + * referral_source: + * type: string + * responses: + * 200: + * description: Squeeze updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * message: + * type: string + * example: "Squeeze updated successfully" + * data: + * type: object + * 400: + * description: Bad request + * 404: + * description: Squeeze service not found + * 500: + * description: Error occurred while updating the squeeze record. + */ + public updateSqueeze = async (req: Request, res: Response) => { + const { squeeze_id } = req.params; + const data = req.body; + const squeeze = await this.squeezeService.updateSqueeze(squeeze_id, data); + + if (squeeze) { + return res.status(200).json({ + status: "Success", + message: "Squeeze updated successfully", + data: squeeze, + }); + } else { + res.status(500).json({ + status: "error", + message: "Error occurred while updating the squeeze record.", + }); + } + }; } export { SqueezeController }; diff --git a/src/controllers/api-status.controller.ts b/src/controllers/api-status.controller.ts new file mode 100644 index 00000000..d9aeaf94 --- /dev/null +++ b/src/controllers/api-status.controller.ts @@ -0,0 +1,24 @@ +import { Request, Response } from "express"; +import { asyncHandler } from "../middleware/asyncHandler"; +import { + fetchApiStatusService, + parseJsonResponse, +} from "../services/api-status.services"; +import { sendJsonResponse } from "../utils/sendJsonResponse"; + +export const createApiStatus = asyncHandler( + async (req: Request, res: Response) => { + const resultJson = req.body; + + await parseJsonResponse(resultJson); + sendJsonResponse(res, 201, "API status updated successfully"); + }, +); + +export const getApiStatus = asyncHandler( + async (_req: Request, res: Response) => { + const response = await fetchApiStatusService(); + + sendJsonResponse(res, 200, "Api status", { response }); + }, +); diff --git a/src/controllers/billingplanController.ts b/src/controllers/billingplanController.ts new file mode 100644 index 00000000..61d3ec1f --- /dev/null +++ b/src/controllers/billingplanController.ts @@ -0,0 +1,162 @@ +import { NextFunction, Request, Response } from "express"; +import { BillingPlanService } from "../services/billingplan.services"; +import { HttpError } from "../middleware"; + +export class BillingPlanController { + private billingPlanService: BillingPlanService; + + constructor() { + this.billingPlanService = new BillingPlanService(); + this.createBillingPlan = this.createBillingPlan.bind(this); + this.getBillingPlans = this.getBillingPlans.bind(this); + } + + /** + * @swagger + * /api/v1/billing-plans: + * post: + * summary: Create a new billing plan + * description: Creates a new billing plan with the provided details + * tags: [Billing Plan] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - organizationId + * - price + * properties: + * name: + * type: string + * example: "hello" + * organizationId: + * type: string + * example: "a73449ef-7d16-4a72-981a-79016f30735c" + * price: + * type: number + * example: 5 + * responses: + * 201: + * description: Successful response + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: string + * example: "successful" + * status_code: + * type: number + * example: 201 + * data: + * type: object + * properties: + * id: + * type: string + * example: "7880f784-c86c-4abf-b19c-c25720fbfb7f" + * name: + * type: string + * example: "hello" + * organizationId: + * type: string + * example: "a73449ef-7d16-4a72-981a-79016f30735c" + * price: + * type: number + * example: 5 + */ + + async createBillingPlan(req: Request, res: Response, next: NextFunction) { + try { + const planData = req.body; + const createdPlan = + await this.billingPlanService.createBillingPlan(planData); + res.status(201).json({ + success: "successful", + status_code: 201, + data: createdPlan, + }); + } catch (error) { + next(new HttpError(500, error.message)); + } + } + + /** + * @swagger + * /api/v1/billing-plans/{id}: + * get: + * summary: Get a billing plan by ID + * description: Retrieves a specific billing plan by its ID + * tags: [Billing Plan] + * parameters: + * - in: path + * name: id + * required: true + * description: ID of the billing plan to retrieve + * schema: + * type: string + * responses: + * 200: + * description: Successful response + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: string + * example: "successful" + * status_code: + * type: number + * example: 200 + * data: + * type: object + * properties: + * id: + * type: string + * example: "6b792203-dc65-475c-8733-2d018b9e3c7c" + * organizationId: + * type: string + * example: "a73449ef-7d16-4a72-981a-79016f30735c" + * name: + * type: string + * example: "hello" + * price: + * type: string + * example: "4.00" + * currency: + * type: string + * example: "USD" + * duration: + * type: string + * example: "monthly" + * description: + * type: string + * nullable: true + * example: null + * features: + * type: array + * items: + * type: string + * example: [] + * 500: + * description: Internal Server Error + */ + + async getBillingPlans(req: Request, res: Response, next: NextFunction) { + try { + const planId = req.params.id; + const plan = await this.billingPlanService.getBillingPlan(planId); + res.status(200).json({ + success: "successful", + status_code: 200, + data: plan, + }); + } catch (error) { + next(new HttpError(500, error.message)); + } + } +} diff --git a/src/controllers/blogCommentController.ts b/src/controllers/blogCommentController.ts index 5c8afcb6..bbbabce7 100644 --- a/src/controllers/blogCommentController.ts +++ b/src/controllers/blogCommentController.ts @@ -3,6 +3,7 @@ import { editComment, createComment, getAllComments, + deleteComment, } from "../services/blogComment.services"; import log from "../utils/logger"; import { HttpError, ResourceNotFound } from "../middleware"; @@ -386,4 +387,119 @@ export class BlogCommentController { } } } + + /** + * @swagger + * /blog/{commentId}: + * delete: + * summary: Delete a specific comment + * tags: [Comments] + * parameters: + * - in: path + * name: commentId + * required: true + * description: The ID of the comment to be deleted + * schema: + * type: string + * example: "comment-12345" + * responses: + * 200: + * description: Comment deleted successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "success" + * status_code: + * type: integer + * example: 200 + * message: + * type: string + * example: "Comment deleted successfully" + * 400: + * description: Invalid comment ID + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: "unsuccessful" + * message: + * type: string + * example: "Invalid comment ID" + * status_code: + * type: integer + * example: 400 + * 404: + * description: Comment not found + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "Comment not found" + * status_code: + * type: integer + * example: 404 + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "Internal server error" + * status_code: + * type: integer + * example: 500 + */ + + async deleteComment(req: Request, res: Response, next: NextFunction) { + try { + const commentId = req.params?.commentId || null; + + if (!commentId) { + return res.status(400).json({ + status: "unsuccessful", + message: "Invalid comment ID", + status_code: 400, + }); + } + + const hasDeletedComment = await deleteComment(commentId, req.user.id); + + return res.status(200).json({ + status: "success", + status_code: 200, + message: "Comment deleted successfully", + }); + } catch (error) { + if (error instanceof ResourceNotFound) { + next(error); + } else if (error.message === "COMMENT_NOT_FOUND") { + return res.status(404).json({ + status: "unsuccessful", + message: "The comment you are trying to delete does not exist", + status_code: 404, + }); + } else if (error.message === "UNAUTHORIZED_ACTION") { + return res.status(404).json({ + status: "unsuccessful", + message: "Sorry, but you are not the author of this comment", + status_code: 404, + }); + } else { + next(new HttpError(500, "Internal server error")); + } + } + } } diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 4409a8b0..9fca29be 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -5,7 +5,7 @@ export * from "./ProductController"; export * from "./HelpController"; export * from "./roleController"; export * from "./AdminController"; -export * from "./NotificationController"; +export * from "./NotificationSettingsController"; export * from "./BlogController"; export * from "./exportController"; export * from "./BlogController"; @@ -17,5 +17,9 @@ export * from "./contactController"; export * from "./FaqController"; export * from "./OrgController"; export * from "./runTestController"; +export * from "./paymentPaystackController"; export * from "./billingController"; export * from "./SqueezeController"; +export * from "./NotificationController"; +export * from "./billingplanController"; +export * from "./api-status.controller"; diff --git a/src/controllers/jobController.ts b/src/controllers/jobController.ts index 73c20cc2..6fef4282 100644 --- a/src/controllers/jobController.ts +++ b/src/controllers/jobController.ts @@ -1,4 +1,4 @@ -import { Request, Response } from "express"; +import { NextFunction, Request, Response } from "express"; import { JobService } from "../services/job.service"; import { HttpError } from "../middleware"; import AppDataSource from "../data-source"; @@ -23,6 +23,88 @@ export class JobController { } } + /** + * @swagger + * /api/v1/jobs/{jobId}: + * get: + * summary: Get job details by ID + * description: Retrieve the details of a job by its unique identifier. + * tags: [Jobs] + * parameters: + * - in: path + * name: jobId + * required: true + * schema: + * type: string + * description: The unique identifier of the job. + * responses: + * 200: + * description: Successfully retrieved job details. + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * example: "1" + * title: + * type: string + * example: "Software Engineer" + * description: + * type: string + * example: "Job description here..." + * company_name: + * type: string + * example: "Company Name" + * location: + * type: string + * example: "Remote" + * salary: + * type: number + * example: 60000 + * job_type: + * type: string + * example: Backend Devloper + * 404: + * description: Job not found. + * 400: + * description: Invalid job ID format. + * 500: + * description: Internal server error. + */ + public async getJobById(req: Request, res: Response, next: NextFunction) { + try { + const jobId = req.params.id; + + const job = await this.jobService.getById(jobId); + if (!job) { + return res.status(404).json({ + status_code: 404, + success: false, + message: "Job not found", + }); + } + + res.status(200).json({ + status_code: 200, + success: true, + message: "The Job is retrieved successfully.", + data: job, + }); + } catch (error) { + next(error); + } + } + + async getAllJobs(req: Request, res: Response) { + try { + const billing = await this.jobService.getAllJobs(req); + res.status(200).json({ message: "Jobs retrieved successfully", billing }); + } catch (error) { + res.status(500).json({ message: error.message }); + } + } /** * @swagger * /jobs/{jobId}: @@ -65,7 +147,6 @@ export class JobController { * description: * type: string * example: "Develop and maintain software applications." - * // Add other job properties as needed * 404: * description: Job not found * content: @@ -80,7 +161,7 @@ export class JobController { * type: integer * example: 404 * 422: - * description: Validation failed: Valid job ID required + * description: Validation failed. Valid job ID required * content: * application/json: * schema: @@ -88,7 +169,7 @@ export class JobController { * properties: * message: * type: string - * example: "Validation failed: Valid job ID required" + * example: "Validation failed. Valid job ID required" * status_code: * type: integer * example: 422 diff --git a/src/controllers/paymentPaystackController.ts b/src/controllers/paymentPaystackController.ts new file mode 100644 index 00000000..dcab05d4 --- /dev/null +++ b/src/controllers/paymentPaystackController.ts @@ -0,0 +1,92 @@ +import { Request, Response } from "express"; +import { initializePayment, verifyPayment } from "../services"; +import log from "../utils/logger"; + +/** + * @swagger + * api/v1/payments/paystack/initiate: + * post: + * summary: Initiate a payment using Paystack + * tags: [Payments] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * organization_id: + * type: string + * plan_id: + * type: string + * full_name: + * type: string + * billing_option: + * type: string + * enum: [monthly, yearly] + * redirect_url: + * type: string + * example: http://boilerplate.com/setting + * responses: + * 200: + * description: Payment initiated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * redirect: + * type: string + * example: https://paystack.com/redirect-url + * 400: + * description: Billing plan or organization not found + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: integer + * example: 400 + * message: + * type: string + * example: Billing plan or organization not found + * 500: + * description: Error initiating payment + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: Error initiating payment + */ +export const initializePaymentPaystack = async ( + req: Request, + res: Response, +) => { + try { + const response = await initializePayment(req.body); + res.json(response); + } catch (error) { + console.log(error); + log.error("Error initiating payment:", error); + res.status(500).json({ error: "Error initiating payment" }); + } +}; + +/** + * Verifies a payment using Paystack + * @param req - Express request object + * @param res - Express response object + */ +export const verifyPaymentPaystack = async (req: Request, res: Response) => { + try { + const response = await verifyPayment(req.params.reference); + res.json(response); + } catch (error) { + log.error("Error verifying payment:", error); + res.status(500).json({ error: "Error verifying payment" }); + } +}; diff --git a/src/controllers/planController.ts b/src/controllers/planController.ts new file mode 100644 index 00000000..333920ba --- /dev/null +++ b/src/controllers/planController.ts @@ -0,0 +1,367 @@ +import { Request, Response } from "express"; +import { PlanService } from "../services/super-admin-plans"; + +const planService = new PlanService(); + +/** + * @swagger + * tags: + * - name: Plans + * description: Operations related to plans + */ + +/** + * @swagger + * /api/v1/admin/{userId}/current-plan: + * get: + * tags: + * - Plans + * summary: Get the current plan for a user + * description: Retrieve the current active plan for a specific user. + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * description: The ID of the user. + * responses: + * 200: + * description: Successfully retrieved the current plan + * content: + * application/json: + * schema: + * type: object + * properties: + * planName: + * type: string + * example: Premium Plan + * planPrice: + * type: number + * example: 99.99 + * features: + * type: string + * example: Unlimited access, 24/7 support + * startDate: + * type: string + * format: date + * example: 2024-08-15 + * renewalDate: + * type: string + * format: date + * example: 2024-09-15 + * status: + * type: string + * example: Active + * 404: + * description: User or subscription not found + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: User or subscription not found + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Server error + * error: + * type: object + */ +export const getCurrentPlan = async (req: Request, res: Response) => { + const { userId } = req.params; + + try { + const planData = await planService.getCurrentPlan(userId); + return res.status(200).json(planData); + } catch (error) { + return res.status(404).json({ message: error.message }); + } +}; + +/** + * @swagger + * /api/v1/admin/plans: + * post: + * tags: + * - Plans + * summary: Create a new plan + * description: Create a new plan with the provided details. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: Premium Plan + * price: + * type: number + * example: 99.99 + * features: + * type: string + * example: Unlimited access, 24/7 support + * limitations: + * type: string + * example: Limited to 5 devices + * responses: + * 201: + * description: Plan created successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Plan created successfully + * plan: + * $ref: '#/components/schemas/Plan' + * 400: + * description: Invalid input or plan already exists + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Invalid input or Plan already exists + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Server error + * error: + * type: object + */ +export const createPlan = async (req: Request, res: Response) => { + const { name, price, features, limitations } = req.body; + + try { + const newPlan = await planService.createPlan({ + name, + price, + features, + limitations, + }); + return res.status(201).json({ + message: "Plan created successfully", + plan: newPlan, + }); + } catch (error) { + const statusCode = + error.message === "Invalid input" || + error.message === "Plan already exists" + ? 400 + : 500; + return res.status(statusCode).json({ message: error.message }); + } +}; + +/** + * @swagger + * /api/v1/admin/{userId}/current-plan: + * put: + * tags: + * - Plans + * summary: Update a specific plan + * description: Update the details of a specific plan. + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The ID of the plan to update. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * example: Premium Plan + * price: + * type: number + * example: 99.99 + * features: + * type: string + * example: Unlimited access, 24/7 support + * limitations: + * type: string + * example: Limited to 5 devices + * responses: + * 200: + * description: Plan updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Plan updated successfully + * plan: + * $ref: '#/components/schemas/Plan' + * 400: + * description: Invalid input or plan not found + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Invalid input or Plan not found + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Server error + * error: + * type: object + */ +export const updatePlan = async (req: Request, res: Response) => { + const { id } = req.params; + const updateData = req.body; + + try { + const updatedPlan = await planService.updatePlan(id, updateData); + return res.status(200).json({ + message: "Plan updated successfully", + plan: updatedPlan, + }); + } catch (error) { + return res + .status(error.message === "Invalid price" ? 400 : 500) + .json({ message: error.message }); + } +}; + +/** + * @swagger + * /api/v1/admin/plans: + * get: + * tags: + * - Plans + * summary: Compare all plans + * description: Retrieve and compare all available plans. + * responses: + * 200: + * description: Successfully retrieved all plans + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/Plan' + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Server error + * error: + * type: object + */ +export const comparePlans = async (req: Request, res: Response) => { + try { + const plans = await planService.comparePlans(); + return res.status(200).json(plans); + } catch (error) { + return res.status(500).json({ message: error.message }); + } +}; + +/** + * @swagger + * /api/v1/admin/{userId}/current-plan: + * delete: + * tags: + * - Plans + * summary: Delete a specific plan + * description: Delete a specific plan if there are no active subscriptions associated with it. + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The ID of the plan to delete. + * responses: + * 200: + * description: Plan deleted successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Plan deleted successfully + * 400: + * description: Plan not found or has active subscriptions + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Plan not found or has active subscriptions + * 500: + * description: Server error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Server error + * error: + * type: object + */ +export const deletePlan = async (req: Request, res: Response) => { + const { id } = req.params; + + try { + const result = await planService.deletePlan(id); + return res.status(200).json(result); + } catch (error) { + return res + .status( + error.message === "Cannot delete plan with active subscriptions" + ? 400 + : 500, + ) + .json({ message: error.message }); + } +}; diff --git a/src/controllers/updateBlogController.ts b/src/controllers/updateBlogController.ts deleted file mode 100644 index a5b88741..00000000 --- a/src/controllers/updateBlogController.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Request, Response } from "express"; -import { updateBlogPost } from "../services/updateBlog.services"; - -export const updateBlogController = async (req: Request, res: Response) => { - const { id } = req.params; - const { title, content, published_at, image_url } = req.body; - - try { - const updatedBlog = await updateBlogPost( - id, - title, - content, - published_at, - image_url, - ); - - return res.status(200).json({ - status: "success", - status_code: 200, - message: "Blog post updated successfully", - data: updatedBlog, - }); - } catch (error) { - return res.status(500).json({ - status: "unsuccessful", - status_code: 500, - message: "Failed to update the blog post. Please try again later.", - }); - } -}; diff --git a/src/index.ts b/src/index.ts index eae954df..04512d89 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,10 @@ import AppDataSource from "./data-source"; import { errorHandler, routeNotFound } from "./middleware"; import { adminRouter, + apiStatusRouter, authRoute, + billingPlanRouter, + billingRouter, blogRouter, contactRouter, exportRouter, @@ -18,24 +21,26 @@ import { jobRouter, newsLetterSubscriptionRoute, notificationRouter, + notificationsettingsRouter, paymentFlutterwaveRouter, + paymentPaystackRouter, paymentRouter, paymentStripeRouter, productRouter, - billingRouter, runTestRouter, sendEmailRoute, + squeezeRoute, testimonialRoute, userRouter, - squeezeRoute, } from "./routes"; import { orgRouter } from "./routes/organisation"; +import { planRouter } from "./routes/plans"; +import { roleRouter } from "./routes/roles"; import { smsRouter } from "./routes/sms"; import swaggerSpec from "./swaggerConfig"; import { Limiter } from "./utils"; import log from "./utils/logger"; import ServerAdapter from "./views/bull-board"; -import { roleRouter } from "./routes/roles"; dotenv.config(); const port = config.port; @@ -55,8 +60,8 @@ server.use( ); server.use(Limiter); -server.use(express.json()); -server.use(express.urlencoded({ extended: true })); +server.use(express.json({ limit: "10mb" })); +server.use(express.urlencoded({ limit: "10mb", extended: true })); server.use(passport.initialize()); server.get("/", (req: Request, res: Response) => { @@ -81,6 +86,7 @@ server.use("/api/v1", productRouter); server.use("/api/v1", paymentFlutterwaveRouter); server.use("/api/v1", paymentStripeRouter); server.use("/api/v1", smsRouter); +server.use("/api/v1", notificationsettingsRouter); server.use("/api/v1", notificationRouter); server.use("/api/v1", paymentRouter); server.use("/api/v1", billingRouter); @@ -91,10 +97,18 @@ server.use("/api/v1", blogRouter); server.use("/api/v1", contactRouter); server.use("/api/v1", jobRouter); server.use("/api/v1", roleRouter); +server.use("/api/v1", paymentPaystackRouter); +server.use("/api/v1", billingPlanRouter); server.use("/api/v1", newsLetterSubscriptionRoute); server.use("/api/v1", squeezeRoute); +server.use("/api/v1", planRouter); +server.use("/api/v1", apiStatusRouter); server.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +server.use("/openapi.json", (_req: Request, res: Response) => { + res.setHeader("Content-Type", "application/json"); + res.send(swaggerSpec); +}); server.use(routeNotFound); server.use(errorHandler); diff --git a/src/middleware/asyncHandler.ts b/src/middleware/asyncHandler.ts new file mode 100644 index 00000000..de6d8198 --- /dev/null +++ b/src/middleware/asyncHandler.ts @@ -0,0 +1,14 @@ +import { NextFunction, Request, Response } from "express"; + +/** + * Async handler to wrap the API routes, this allows for async error handling. + * @param fn Function to call for the API endpoint + * @returns Promise with a catch statement + */ +const asyncHandler = + (fn: (req: Request, res: Response, next: NextFunction) => void) => + (req: Request, res: Response, next: NextFunction) => { + return Promise.resolve(fn(req, res, next)).catch(next); + }; + +export { asyncHandler }; diff --git a/src/middleware/authorisationSuperAdmin.ts b/src/middleware/authorisationSuperAdmin.ts new file mode 100644 index 00000000..28fc08a5 --- /dev/null +++ b/src/middleware/authorisationSuperAdmin.ts @@ -0,0 +1,15 @@ +import { Request, Response, NextFunction } from "express"; +import { UserRole } from "../enums/userRoles"; // Adjust the import path as needed + +export const authorizeRole = (roles: UserRole[]) => { + return (req: Request, res: Response, next: NextFunction) => { + const userRole = req.user.role as UserRole; // Assuming `req.user.role` is set during authentication + + if (!roles.includes(userRole)) { + return res + .status(403) + .json({ message: "Access forbidden: insufficient rights" }); + } + next(); + }; +}; diff --git a/src/middleware/organizationValidation.ts b/src/middleware/organizationValidation.ts index 74cc370e..c2273855 100644 --- a/src/middleware/organizationValidation.ts +++ b/src/middleware/organizationValidation.ts @@ -200,4 +200,43 @@ export const validateUserToOrg = async ( } }; +export const validateOrgRole = [ + param("org_id") + .notEmpty() + .withMessage("Organisation id is required") + .isString() + .withMessage("Organisation id must be a string") + .isUUID() + .withMessage("Valid organization ID must be provided") + .trim() + .escape(), + body("name") + .notEmpty() + .withMessage("Name is required") + .isString() + .isLength({ max: 50 }) + .withMessage("Name must be a string") + .trim() + .escape(), + body("description") + .optional() + .isString() + .isLength({ max: 200 }) + .withMessage("Description must be a string") + .trim() + .escape(), + (req: Request, res: Response, next: NextFunction) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ + status: "Error", + status_code: 422, + message: + "Valid organization ID, name, and description must be provided.", + }); + } + next(); + }, +]; + //TODO: Add validation for update organization diff --git a/src/models/api-model.ts b/src/models/api-model.ts new file mode 100644 index 00000000..4adfefbc --- /dev/null +++ b/src/models/api-model.ts @@ -0,0 +1,44 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; + +export enum API_STATUS { + OPERATIONAL = "operational", + DEGRADED = "degraded", + DOWN = "down", +} + +@Entity({ name: "api_status" }) +export class ApiStatus { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ name: "api_group" }) + api_group: string; + + @Column({ name: "api_name" }) + api_name: string; + + @Column({ name: "status", type: "enum", enum: API_STATUS }) + status: API_STATUS; + + @Column("text", { nullable: true }) + details: string; + + @Column({ name: "response_time", type: "int", nullable: true }) + response_time: string; + + @CreateDateColumn({ name: "created_at" }) + created_at: Date; + + @UpdateDateColumn({ name: "updated_at" }) + updated_at: Date; + + @DeleteDateColumn({ nullable: true }) + deleted_at: Date; +} diff --git a/src/models/billing-plan.ts b/src/models/billing-plan.ts index 63639a0c..7c078a90 100644 --- a/src/models/billing-plan.ts +++ b/src/models/billing-plan.ts @@ -13,7 +13,7 @@ export class BillingPlan { @PrimaryGeneratedColumn("uuid") id: string; - @Column("uuid") + @Column("uuid", { nullable: true }) organizationId: string; @Column() diff --git a/src/models/category.ts b/src/models/category.ts index da5bac78..b9600d74 100644 --- a/src/models/category.ts +++ b/src/models/category.ts @@ -3,7 +3,7 @@ import { Blog } from "./blog"; @Entity() export class Category { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn("uuid") id: number; @Column() diff --git a/src/models/faq.ts b/src/models/faq.ts index 86932bec..8f570d22 100644 --- a/src/models/faq.ts +++ b/src/models/faq.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; import ExtendedBaseEntity from "./extended-base-entity"; import { UserRole } from "../enums/userRoles"; @@ -18,6 +24,12 @@ class FAQ extends ExtendedBaseEntity { @Column({ nullable: false, default: UserRole.SUPER_ADMIN }) createdBy: string; + + @CreateDateColumn() + created_at: Date; + + @UpdateDateColumn() + updated_at: Date; } export { FAQ }; diff --git a/src/models/index.ts b/src/models/index.ts index 712dbafc..c7c7f50c 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -11,12 +11,14 @@ export * from "./log"; export * from "./payment"; export * from "./faq"; +// export * from "./orgInviteToken"; +export * from "./billing-plan"; export * from "./helpcentertopic"; export * from "./invitation"; export * from "./job"; export * from "./like"; export * from "./log"; -export * from "./notification"; +export * from "./notificationsettings"; export * from "./organization"; export * from "./organization-member"; export * from "./organization-role.entity"; @@ -31,3 +33,4 @@ export * from "./user"; export * from "./user-organisation"; export * from "./squeeze"; +export * from "./notification"; diff --git a/src/models/notification.ts b/src/models/notification.ts index f1a2bf14..b56c1633 100644 --- a/src/models/notification.ts +++ b/src/models/notification.ts @@ -1,26 +1,32 @@ -import { Entity, PrimaryGeneratedColumn, Column, Unique } from "typeorm"; -import { IsBoolean, IsUUID } from "class-validator"; -import ExtendedBaseEntity from "./extended-base-entity"; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; +import { User } from "./user"; @Entity() -@Unique(["user_id"]) -export class NotificationSetting extends ExtendedBaseEntity { +class Notification { @PrimaryGeneratedColumn("uuid") id: string; @Column() - @IsUUID() - user_id: string; + message: string; - @Column() - @IsBoolean() - email_notifications: boolean; + @Column({ default: false }) + isRead: boolean; - @Column() - @IsBoolean() - push_notifications: boolean; + @ManyToOne(() => User, (user) => user.notifications, { onDelete: "CASCADE" }) + user: User; - @Column() - @IsBoolean() - sms_notifications: boolean; + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; } + +export { Notification }; diff --git a/src/models/notificationsettings.ts b/src/models/notificationsettings.ts new file mode 100644 index 00000000..f1a2bf14 --- /dev/null +++ b/src/models/notificationsettings.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryGeneratedColumn, Column, Unique } from "typeorm"; +import { IsBoolean, IsUUID } from "class-validator"; +import ExtendedBaseEntity from "./extended-base-entity"; + +@Entity() +@Unique(["user_id"]) +export class NotificationSetting extends ExtendedBaseEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + @IsUUID() + user_id: string; + + @Column() + @IsBoolean() + email_notifications: boolean; + + @Column() + @IsBoolean() + push_notifications: boolean; + + @Column() + @IsBoolean() + sms_notifications: boolean; +} diff --git a/src/models/plan.ts b/src/models/plan.ts new file mode 100644 index 00000000..de3580bb --- /dev/null +++ b/src/models/plan.ts @@ -0,0 +1,20 @@ +import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; +import ExtendedBaseEntity from "../models/extended-base-entity"; + +@Entity("plans") +export class Plan extends ExtendedBaseEntity { + @PrimaryGeneratedColumn() + id: string; + + @Column() + name: string; + + @Column("decimal") + price: number; + + @Column("simple-array") + features: string[]; + + @Column("text") + limitations: string; +} diff --git a/src/models/subcription.ts b/src/models/subcription.ts new file mode 100644 index 00000000..c8d9e815 --- /dev/null +++ b/src/models/subcription.ts @@ -0,0 +1,34 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; +import { User } from "./user"; +import { Plan } from "./plan"; + +@Entity() +export class Subscription { + @PrimaryGeneratedColumn() + id: string; + + @ManyToOne(() => User, (user) => user.subscriptions) + user: User; + + @ManyToOne(() => Plan) + plan: Plan; + + @CreateDateColumn() + startDate: Date; + + @Column({ type: "date" }) + renewalDate: Date; + + @Column({ default: "Active" }) + status: string; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/models/tag.ts b/src/models/tag.ts index a234bfd1..cb9842d9 100644 --- a/src/models/tag.ts +++ b/src/models/tag.ts @@ -3,7 +3,7 @@ import { Blog } from "./blog"; @Entity() export class Tag { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn("uuid") id: number; @Column() diff --git a/src/models/user.ts b/src/models/user.ts index 06547d2e..66f943de 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -14,13 +14,22 @@ import { Unique, UpdateDateColumn, } from "typeorm"; -import { Blog, Comment, Organization, Product, Profile, Sms } from "."; +import { + Blog, + Comment, + Organization, + Product, + Profile, + Sms, + Notification, +} from "."; import { UserRole } from "../enums/userRoles"; import { getIsInvalidMessage } from "../utils"; import ExtendedBaseEntity from "./extended-base-entity"; import { Like } from "./like"; import { OrganizationMember } from "./organization-member"; import { UserOrganization } from "./user-organisation"; +import { Subscription } from "./subcription"; @Entity() @Unique(["email"]) @@ -60,6 +69,9 @@ export class User extends ExtendedBaseEntity { @Column({ nullable: true }) otp: number; + @Column({ default: false }) + is_superadmin: boolean; + @Column({ nullable: true }) otp_expires_at: Date; @@ -119,6 +131,9 @@ export class User extends ExtendedBaseEntity { ) organizationMembers: OrganizationMember[]; + @OneToMany(() => Notification, (notification) => notification.user) + notifications: Notification[]; + @OneToMany(() => Comment, (comment) => comment.author) comments: Comment[]; @@ -131,6 +146,9 @@ export class User extends ExtendedBaseEntity { @Column("simple-array", { nullable: true }) backup_codes: string[]; + @OneToMany(() => Subscription, (subscription) => subscription.user) + subscriptions: Subscription[]; + createPasswordResetToken(): string { const resetToken = crypto.randomBytes(32).toString("hex"); diff --git a/src/routes/api-status.ts b/src/routes/api-status.ts new file mode 100644 index 00000000..4b7e3cfd --- /dev/null +++ b/src/routes/api-status.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { + createApiStatus, + getApiStatus, +} from "../controllers/api-status.controller"; + +const apiStatusRouter = Router(); + +apiStatusRouter.post("/api-status", createApiStatus); +apiStatusRouter.get("/api-status", getApiStatus); + +export { apiStatusRouter }; diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 8ca8bbf5..2cf1a277 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -10,6 +10,7 @@ import { login, resetPassword, signUp, + verify2FA, verifyOtp, } from "../controllers"; import { UserRole } from "../enums/userRoles"; @@ -50,5 +51,6 @@ authRoute.post( authMiddleware, enable2FA, ); +authRoute.post("/auth/2fa/verify", authMiddleware, verify2FA); export { authRoute }; diff --git a/src/routes/billingplan.ts b/src/routes/billingplan.ts new file mode 100644 index 00000000..c996d539 --- /dev/null +++ b/src/routes/billingplan.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +import { BillingPlanController } from "../controllers"; +import { authMiddleware, checkPermissions } from "../middleware"; +import { UserRole } from "../enums/userRoles"; + +const billingPlanRouter = Router(); +const billingPlanController = new BillingPlanController(); + +billingPlanRouter.post( + "/billing-plans", + authMiddleware, + checkPermissions([UserRole.ADMIN, UserRole.SUPER_ADMIN]), + billingPlanController.createBillingPlan, +); + +billingPlanRouter.get( + "/billing-plans/:id", + authMiddleware, + checkPermissions([UserRole.ADMIN, UserRole.SUPER_ADMIN]), + billingPlanController.getBillingPlans, +); + +export { billingPlanRouter }; diff --git a/src/routes/blog.ts b/src/routes/blog.ts index ff961550..12db04ce 100644 --- a/src/routes/blog.ts +++ b/src/routes/blog.ts @@ -1,11 +1,10 @@ import { Router } from "express"; import { BlogCommentController } from "../controllers/blogCommentController"; import { BlogController } from "../controllers/BlogController"; -// import { createBlogController } from "../controllers/createBlogController" -import { updateBlogController } from "../controllers/updateBlogController"; -import { authMiddleware } from "../middleware"; +import { authMiddleware, checkPermissions } from "../middleware"; import { requestBodyValidator } from "../middleware/request-validation"; import { createBlogSchema } from "../utils/request-body-validator"; +import { UserRole } from "../enums/userRoles"; const blogRouter = Router(); const blogController = new BlogController(); @@ -24,7 +23,12 @@ blogRouter.get( authMiddleware, blogController.listBlogsByUser.bind(blogController), ); -blogRouter.put("/:id", authMiddleware, updateBlogController); +blogRouter.patch( + "/blog/edit/:id", + requestBodyValidator(createBlogSchema), + authMiddleware, + blogController.updateBlog.bind(blogController), +); blogRouter.delete( "/blog/:id", @@ -32,20 +36,24 @@ blogRouter.delete( blogController.deleteBlogPost.bind(blogController), ); -//endpoint to create a comment on a blog post blogRouter.post( "/blog/:postId/comment", authMiddleware, blogCommentController.createComment.bind(blogCommentController), ); -//endpoint to edit a comment on a blog post blogRouter.patch( "/blog/:commentId/edit-comment", authMiddleware, blogCommentController.editComment.bind(blogCommentController), ); +blogRouter.delete( + "/blog/:commentId", + authMiddleware, + blogCommentController.deleteComment.bind(blogCommentController), +); + blogRouter.get( "/blog/:blogId/comments", authMiddleware, diff --git a/src/routes/faq.ts b/src/routes/faq.ts index 50f2c04c..57db08e3 100644 --- a/src/routes/faq.ts +++ b/src/routes/faq.ts @@ -8,12 +8,12 @@ const faqController = new FAQController(); faqRouter.post("/faqs", authMiddleware, faqController.createFAQ); faqRouter.patch("/faqs/:id", authMiddleware, faqController.updateFaq); +faqRouter.get("/faqs", faqController.getFaq); faqRouter.delete( "/faqs/:faqId", authMiddleware, checkPermissions([UserRole.SUPER_ADMIN]), faqController.deleteFaq, ); -faqRouter.get("/faqs", faqController.getFaq); export { faqRouter }; diff --git a/src/routes/index.ts b/src/routes/index.ts index b4045028..f310278e 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -17,6 +17,10 @@ export * from "./testimonial"; export * from "./user"; export * from "./faq"; export * from "./run-test"; +export * from "./paymentPaystack"; export * from "./billing-plans"; export * from "./squeeze"; export * from "./newsLetterSubscription"; +export * from "./notification"; +export * from "./billingplan"; +export * from "./api-status"; diff --git a/src/routes/job.ts b/src/routes/job.ts index 2c9f7e6e..5b8720cd 100644 --- a/src/routes/job.ts +++ b/src/routes/job.ts @@ -17,4 +17,8 @@ jobRouter.delete( jobController.deleteJob.bind(jobController), ); +jobRouter.get("/jobs", jobController.getAllJobs.bind(jobController)); + +jobRouter.get("/jobs/:id", jobController.getJobById.bind(jobController)); + export { jobRouter }; diff --git a/src/routes/notification.ts b/src/routes/notification.ts new file mode 100644 index 00000000..0e4ab9e9 --- /dev/null +++ b/src/routes/notification.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { NotificationController } from "../controllers"; +import { authMiddleware } from "../middleware"; + +const notificationRouter = Router(); +const notificationsController = new NotificationController(); + +notificationRouter.get( + "/notifications/all", + authMiddleware, + notificationsController.getNotificationsForUser, +); + +export { notificationRouter }; diff --git a/src/routes/notificationsettings.ts b/src/routes/notificationsettings.ts index 76795a46..173810e9 100644 --- a/src/routes/notificationsettings.ts +++ b/src/routes/notificationsettings.ts @@ -2,17 +2,17 @@ import { CreateOrUpdateNotification, GetNotification } from "../controllers"; import { Router } from "express"; import { authMiddleware } from "../middleware"; -const notificationRouter = Router(); +const notificationsettingsRouter = Router(); -notificationRouter.put( +notificationsettingsRouter.put( "/settings/notification-settings", authMiddleware, CreateOrUpdateNotification, ); -notificationRouter.get( +notificationsettingsRouter.get( "/settings/notification-settings/:user_id", authMiddleware, GetNotification, ); -export { notificationRouter }; +export { notificationsettingsRouter }; diff --git a/src/routes/organisation.ts b/src/routes/organisation.ts index c3e8328f..5e03e9b7 100644 --- a/src/routes/organisation.ts +++ b/src/routes/organisation.ts @@ -6,6 +6,7 @@ import { checkPermissions, organizationValidation, validateOrgId, + validateOrgRole, validateUpdateOrg, } from "../middleware"; @@ -45,6 +46,14 @@ orgRouter.get( orgController.generateGenericInviteLink.bind(orgController), ); +orgRouter.post( + "organizations/:org_id/roles", + authMiddleware, + validateOrgRole, + checkPermissions([UserRole.SUPER_ADMIN, UserRole.ADMIN]), + orgController.createOrganizationRole.bind(orgController), +); + orgRouter.post( "/organizations/:org_id/send-invite", authMiddleware, diff --git a/src/routes/paymentPaystack.ts b/src/routes/paymentPaystack.ts new file mode 100644 index 00000000..23164718 --- /dev/null +++ b/src/routes/paymentPaystack.ts @@ -0,0 +1,22 @@ +import { Router } from "express"; +import { + initializePaymentPaystack, + verifyPaymentPaystack, +} from "../controllers"; +import { authMiddleware } from "../middleware"; + +const paymentPaystackRouter = Router(); + +paymentPaystackRouter.post( + "/payments/paystack/initiate", + authMiddleware, + initializePaymentPaystack, +); + +paymentPaystackRouter.get( + "/payments/paystack/verify/:reference", + authMiddleware, + verifyPaymentPaystack, +); + +export { paymentPaystackRouter }; diff --git a/src/routes/plans.ts b/src/routes/plans.ts new file mode 100644 index 00000000..ae278445 --- /dev/null +++ b/src/routes/plans.ts @@ -0,0 +1,50 @@ +import { Router } from "express"; +import { + getCurrentPlan, + comparePlans, + createPlan, + updatePlan, + deletePlan, +} from "../controllers/planController"; +import { authorizeRole } from "../middleware/authorisationSuperAdmin"; +import { authMiddleware, validOrgAdmin } from "../middleware"; +import { UserRole } from "../enums/userRoles"; + +const planRouter = Router(); + +planRouter.get( + "admin/{userId}/current-plan", + authMiddleware, + authorizeRole([UserRole.SUPER_ADMIN]), + getCurrentPlan, +); + +planRouter.get( + "admin/plans", + authMiddleware, + authorizeRole([UserRole.SUPER_ADMIN]), + comparePlans, +); + +planRouter.post( + "admin/plans", + authMiddleware, + authorizeRole([UserRole.SUPER_ADMIN]), + createPlan, +); + +planRouter.delete( + "/admin/{userId}/current-plan", + authMiddleware, + authorizeRole([UserRole.SUPER_ADMIN]), + deletePlan, +); + +planRouter.put( + "/admin/{userId}/current-plan", + authMiddleware, + authorizeRole([UserRole.SUPER_ADMIN]), + updatePlan, +); + +export { planRouter }; diff --git a/src/routes/squeeze.ts b/src/routes/squeeze.ts index ffdc12ca..ea5cfc7d 100644 --- a/src/routes/squeeze.ts +++ b/src/routes/squeeze.ts @@ -6,7 +6,7 @@ const squeezeRoute = Router(); const squeezecontroller = new SqueezeController(); squeezeRoute.post( - "/squeeze-pages", + "/squeezes", authMiddleware, squeezecontroller.createSqueeze.bind(squeezecontroller), ); @@ -17,4 +17,10 @@ squeezeRoute.get( squeezecontroller.getSqueezeById.bind(squeezecontroller), ); +squeezeRoute.put( + "/squeezes/:squeeze_id", + authMiddleware, + squeezecontroller.updateSqueeze.bind(squeezecontroller), +); + export { squeezeRoute }; diff --git a/src/services/api-status.services.ts b/src/services/api-status.services.ts new file mode 100644 index 00000000..d738f3d5 --- /dev/null +++ b/src/services/api-status.services.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import AppDataSource from "../data-source"; +import { API_STATUS, ApiStatus } from "../models/api-model"; +import { GroupedApi } from "../types"; + +const apiStatusRepository = AppDataSource.getRepository(ApiStatus); +const MAX_ALLOWED_RESPONSE_TIME = 2000; + +const determineStatus = ( + statusCode: number, + responseTime: number, +): API_STATUS => { + if (statusCode >= 200 && statusCode < 300) { + if (responseTime && responseTime > MAX_ALLOWED_RESPONSE_TIME) { + return API_STATUS.DEGRADED; + } + return API_STATUS.OPERATIONAL; + } else if (statusCode >= 500) { + return API_STATUS.DOWN; + } + return API_STATUS.DEGRADED; +}; + +const parseJsonResponse = async (resultJson: any): Promise => { + const apiGroups = resultJson.collection.item; + + for (const apiGroup of apiGroups) { + for (const api of apiGroup.item) { + let status = API_STATUS.DEGRADED; + let responseTime = null; + + if (api.response && api.response.length > 0) { + const response = api.response[0]; + responseTime = response.responseTime || null; + status = determineStatus(response.code, responseTime); + } + + const apiStatus = apiStatusRepository.create({ + api_group: apiGroup.name, + api_name: api.name, + status, + response_time: responseTime, + details: responseTime ? `Response time: ${responseTime}ms` : null, + }); + + await apiStatusRepository.save(apiStatus); + } + } +}; + +const fetchApiStatusService = async () => { + const api_status_response = await apiStatusRepository.find(); + + const groupedAPIs = api_status_response.reduce((acc, current) => { + const existingGroup = acc.find( + (group) => group.api_group === current.api_group, + ); + + if (existingGroup) { + existingGroup.collection.push({ + api_name: current.api_name, + is_operational: current.status, + details: current.details, + last_checked: current.updated_at, + }); + } else { + acc.push({ + api_group: current.api_group, + is_operational: API_STATUS.OPERATIONAL, + collection: [ + { + api_name: current.api_name, + is_operational: current.status, + details: current.details, + last_checked: current.updated_at, + }, + ], + }); + } + + return acc; + }, [] as GroupedApi[]); + + const response_dto = groupedAPIs.map((api) => { + const hasDegraded = api.collection.some( + (apiCollection) => + apiCollection.is_operational !== API_STATUS.OPERATIONAL, + ); + + if (hasDegraded) { + api.is_operational = API_STATUS.DEGRADED; + } + + return api; + }); + + return response_dto; +}; + +export { fetchApiStatusService, parseJsonResponse }; diff --git a/src/services/auth.services.ts b/src/services/auth.services.ts index 79d27cb0..639aac6e 100644 --- a/src/services/auth.services.ts +++ b/src/services/auth.services.ts @@ -396,4 +396,12 @@ export class AuthService implements IAuthService { throw new ServerError("An error occurred while trying to enable 2FA"); } } + + public verify2FA(totp_code: string, user: User) { + return speakeasy.totp.verify({ + secret: user.secret, + encoding: "base32", + token: totp_code, + }); + } } diff --git a/src/services/billingplan.services.ts b/src/services/billingplan.services.ts new file mode 100644 index 00000000..6e62aa30 --- /dev/null +++ b/src/services/billingplan.services.ts @@ -0,0 +1,56 @@ +import { Repository } from "typeorm"; +import { IBillingPlanService } from "../types"; +import { BillingPlan } from "../models/billing-plan"; +import AppDataSource from "../data-source"; +import { Organization } from "../models"; +import { ResourceNotFound } from "../middleware"; + +export class BillingPlanService implements IBillingPlanService { + private billingplanRepository: Repository; + + constructor() { + this.billingplanRepository = AppDataSource.getRepository(BillingPlan); + } + async createBillingPlan( + planData: Partial, + ): Promise { + if (!planData.organizationId) { + throw new Error("Organization ID is required."); + } + + const organization = await AppDataSource.getRepository( + Organization, + ).findOne({ + where: { id: planData.organizationId }, + }); + + if (!organization) { + throw new Error("Organization does not exist."); + } + + const newPlan = this.billingplanRepository.create({ + id: planData.id, + name: planData.name, + price: planData.price, + organizationId: planData.organizationId, + currency: "USD", + duration: "monthly", + features: [], + }); + + await this.billingplanRepository.save(newPlan); + + return [newPlan]; + } + + async getBillingPlan(planId: string): Promise { + const billingPlan = await this.billingplanRepository.findOne({ + where: { id: planId }, + }); + if (!billingPlan) { + throw new ResourceNotFound(`Billing plan with ID ${planId} not found`); + } + + return billingPlan; + } +} diff --git a/src/services/blog.services.ts b/src/services/blog.services.ts index ee2ce509..7f56fbac 100644 --- a/src/services/blog.services.ts +++ b/src/services/blog.services.ts @@ -2,6 +2,7 @@ import { Repository } from "typeorm"; import AppDataSource from "../data-source"; import { Category, Tag, User } from "../models"; import { Blog } from "../models/blog"; +import { ResourceNotFound, Forbidden } from "../middleware"; export class BlogService { getAllComments(mockBlogId: string) { @@ -117,4 +118,70 @@ export class BlogService { throw error; } } + async updateBlog(blogId: string, payload: any, userId: string) { + const blog = await this.blogRepository.findOne({ + where: { id: blogId }, + relations: ["author", "tags", "categories"], + }); + + if (!blog) { + throw new ResourceNotFound("Blog post not found"); + } + if (blog.author.id !== userId) { + throw new Forbidden("You are not authorized to edit this blog post"); + } + const user = await this.userRepository.findOne({ where: { id: userId } }); + + blog.title = payload.title; + blog.content = payload.content; + blog.image_url = payload.image_url; + blog.author = user; + blog.published_at = payload.publish_date; + if (payload.tags) { + const tagsContent = payload.tags.split(","); + const tagEntities = await Promise.all( + tagsContent.map(async (tagName: string) => { + let tag = await this.tagRepository.findOne({ + where: { name: tagName }, + }); + if (!tag) { + tag = this.tagRepository.create({ name: tagName }); + await this.tagRepository.save(tag); + } + return tag; + }), + ); + blog.tags = tagEntities; + } + + if (payload.categories) { + const categoriesContent = payload.categories.split(","); + const categoryEntities = await Promise.all( + categoriesContent.map(async (categoryName: string) => { + let category = await this.categoryRepository.findOne({ + where: { name: categoryName }, + }); + if (!category) { + category = this.categoryRepository.create({ name: categoryName }); + await this.categoryRepository.save(category); + } + return category; + }), + ); + blog.categories = categoryEntities; + } + + const updatedBlog = await this.blogRepository.save(blog); + + return { + blog_id: updatedBlog.id, + title: updatedBlog.title, + content: updatedBlog.content, + tags: updatedBlog.tags, + categories: updatedBlog.categories, + image_urls: updatedBlog.image_url, + author: updatedBlog.author.name, + updated_at: updatedBlog.updated_at, + }; + } } diff --git a/src/services/blogComment.services.ts b/src/services/blogComment.services.ts index 576c9f46..ec970492 100644 --- a/src/services/blogComment.services.ts +++ b/src/services/blogComment.services.ts @@ -106,3 +106,28 @@ export const getAllComments = async (blogId: string) => { timestamp: comment.created_at.toISOString(), })); }; + +export const deleteComment = async (commentId: string, userId: string) => { + await initializeRepositories(); + + const comment = await commentRepository.findOne({ + where: { id: commentId }, + relations: ["author"], + }); + + if (!comment) { + throw new Error("COMMENT_NOT_FOUND"); + } + + const { author } = comment; + + if (author.id !== userId) { + throw new Error("UNAUTHORIZED_ACTION"); + } + + await commentRepository.delete(commentId); + + return { + message: "Comment deleted successfully", + }; +}; diff --git a/src/services/index.ts b/src/services/index.ts index 114984bc..fc77872b 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -6,10 +6,13 @@ export * from "./blog.services"; export * from "./admin.services"; export * from "./export.services"; export * from "./sendEmail.services"; -export * from "./payment/flutter.service"; +// export * from "./payment/flutter.service"; export * from "./contactService"; export * from "./faq.services"; export * from "./org.services"; +export * from "./payment/paystack.service"; export * from "./billing-plans.services"; export * from "./squeezeService"; export * from "./blogComment.services"; +export * from "./notification.services"; +export * from "./api-status.services"; diff --git a/src/services/job.service.ts b/src/services/job.service.ts index c416c50f..adeaef60 100644 --- a/src/services/job.service.ts +++ b/src/services/job.service.ts @@ -29,6 +29,27 @@ export class JobService { return job; } + public async getById(jobId: string): Promise { + try { + const job = await this.jobRepository.findOne({ + where: { id: jobId }, + }); + + return job; + } catch (error) { + throw new Error("Failed to fetch job details"); + } + } + + public async getAllJobs(req: Request): Promise { + try { + return await Job.find(); + } catch (error) { + console.error("Failed to fetch jobs", error); + throw new Error("Could not fetch jobs "); + } + } + public async delete(jobId: string): Promise { try { const existingJob = await this.jobRepository.findOne({ diff --git a/src/services/notification.services.ts b/src/services/notification.services.ts new file mode 100644 index 00000000..494919dd --- /dev/null +++ b/src/services/notification.services.ts @@ -0,0 +1,29 @@ +import { Repository } from "typeorm"; +import { Notification, User } from "../models"; +import AppDataSource from "../data-source"; + +export class NotificationsService { + private notificationRepository: Repository; + + constructor() { + this.notificationRepository = AppDataSource.getRepository(Notification); // Inject the repository + } + + public async getNotificationsForUser(userId: string): Promise { + const notifications = await this.notificationRepository.find({ + where: { user: { id: userId } }, + order: { createdAt: "DESC" }, + }); + + const totalNotificationCount = notifications.length; + const totalUnreadNotificationCount = notifications.filter( + (notification) => !notification.isRead, + ).length; + + return { + totalNotificationCount, + totalUnreadNotificationCount, + notifications, + }; + } +} diff --git a/src/services/org.services.ts b/src/services/org.services.ts index da254c1f..f708bb06 100644 --- a/src/services/org.services.ts +++ b/src/services/org.services.ts @@ -3,11 +3,16 @@ import { v4 as uuidv4 } from "uuid"; import config from "../config/index"; import AppDataSource from "../data-source"; import { UserRole } from "../enums/userRoles"; -import { BadRequest, ResourceNotFound, Conflict } from "../middleware"; +import { + BadRequest, + ResourceNotFound, + HttpError, + Conflict, +} from "../middleware"; import { Organization, Invitation, UserOrganization } from "../models"; import { OrganizationRole } from "../models/organization-role.entity"; import { User } from "../models/user"; -import { ICreateOrganisation, IOrgService } from "../types"; +import { ICreateOrganisation, ICreateOrgRole, IOrgService } from "../types"; import log from "../utils/logger"; import { addEmailToQueue } from "../utils/queue"; @@ -374,6 +379,51 @@ export class OrgService implements IOrgService { return []; } + public async createOrganizationRole( + payload: ICreateOrgRole, + organizationid: string, + ) { + try { + const organization = await this.organizationRepository.findOne({ + where: { id: organizationid }, + }); + + if (!organization) { + throw new ResourceNotFound("Organization not found"); + } + + const existingRole = await this.organizationRoleRepository.findOne({ + where: { name: payload.name, organization: { id: organizationid } }, + }); + + if (existingRole) { + throw new Conflict("Role already exists"); + } + + const role = new OrganizationRole(); + Object.assign(role, { + name: payload.name, + description: payload.description, + organization: organization, + }); + const newRole = await this.organizationRoleRepository.save(role); + + const defaultPermissions = await this.permissionRepository.find(); + + const rolePermissions = defaultPermissions.map((defaultPerm) => { + const permission = new Permissions(); + permission.category = defaultPerm.category; + permission.permission_list = defaultPerm.permission_list; + permission.role = newRole; + return permission; + }); + + await this.permissionRepository.save(rolePermissions); + return newRole; + } catch (err) { + throw err; + } + } public async fetchSingleRole( organizationId: string, roleId: string, diff --git a/src/services/payment/paystack.service.ts b/src/services/payment/paystack.service.ts new file mode 100644 index 00000000..eb2b0653 --- /dev/null +++ b/src/services/payment/paystack.service.ts @@ -0,0 +1,91 @@ +import Paystack from "paystack"; +import { v4 as uuidv4 } from "uuid"; +import config from "../../config"; +import { Payment, Organization, BillingPlan } from "../../models"; +import AppDataSource from "../../data-source"; + +const paystack = new Paystack(config.PAYSTACK_SECRET_KEY); + +export const initializePayment = async (customerDetails: { + organization_id?: string; + plan_id?: string; + full_name?: string; + billing_option?: "monthly" | "yearly"; + redirect_url?: string; +}): Promise => { + try { + const tx_ref = `pst-${uuidv4()}-${Date.now()}`; + // Fetch billing plan and organization details + const billingPlanRepository = AppDataSource.getRepository(BillingPlan); + const organizationRepository = AppDataSource.getRepository(Organization); + + const billingPlan = await billingPlanRepository.findOneBy({ + id: customerDetails.plan_id, + }); + const organization = await organizationRepository.findOneBy({ + id: customerDetails.organization_id, + }); + + if (!billingPlan || !organization) { + throw new Error("Billing plan or organization not found"); + } + + const payload = { + email: organization?.email || "hng@gmail.com", + amount: (billingPlan?.price || 1000) * 100, // Paystack expects amount in kobo + currency: "NGN", + }; + + const response = await paystack.transaction.initialize(payload); + + await saveTransactionToDatabase({ + ...customerDetails, + description: `Payment of ${billingPlan.price || 1000} ${billingPlan.currency || "NGN"} via Paystack`, + metadata: { tx_ref, paystack_response: response }, + paymentServiceId: response.data.reference, + currency: billingPlan.currency || "NGN", + amount: billingPlan.price || 1000, + status: "pending", + provider: "paystack", + }); + + return { + status: 200, + message: "Payment initiated successfully", + data: { + payment_url: response.data.authorization_url, + }, + }; + } catch (error) { + throw error; + } +}; + +export const verifyPayment = async (reference: string): Promise => { + try { + const response = await paystack.transaction.verify(reference); + + const paymentStatus = + response.data.status === "success" ? "completed" : "failed"; + await updatePaymentStatus(reference, paymentStatus); + + return response; + } catch (error) { + throw error; + } +}; + +const saveTransactionToDatabase = async (transactionData: any) => { + const paymentRepository = AppDataSource.getRepository(Payment); + const payment = paymentRepository.create(transactionData); + await paymentRepository.save(payment); +}; + +const updatePaymentStatus = async (reference: string, status: string) => { + const paymentRepository = AppDataSource.getRepository(Payment); + const payment = await paymentRepository.findOneBy({ id: reference }); + if (payment) { + payment.status = status as "pending" | "completed" | "failed"; + await paymentRepository.save(payment); + } +}; diff --git a/src/services/squeezeService.ts b/src/services/squeezeService.ts index b41ca5b3..2f7dea69 100644 --- a/src/services/squeezeService.ts +++ b/src/services/squeezeService.ts @@ -1,6 +1,11 @@ import { Squeeze } from "../models"; import AppDataSource from "../data-source"; -import { Conflict, BadRequest } from "../middleware"; +import { + Conflict, + BadRequest, + ResourceNotFound, + ServerError, +} from "../middleware"; import { squeezeSchema } from "../schema/squeezeSchema"; import { Repository } from "typeorm"; @@ -47,6 +52,28 @@ class SqueezeService { throw new BadRequest("Failed to retrieve squeeze: " + error.message); } } + + public async updateSqueeze(id: string, data: Squeeze): Promise { + const validation = squeezeSchema.safeParse(data); + if (!validation.success) { + throw new Conflict( + "Validation failed: " + + validation.error.errors.map((e) => e.message).join(", "), + ); + } + try { + const findSqueeze = await this.squeezeRepository.findOne({ + where: { id }, + }); + if (!findSqueeze) { + throw new ResourceNotFound("Squeeze not found"); + } + const newSqueeze = this.squeezeRepository.merge(findSqueeze, data); + return await this.squeezeRepository.save(newSqueeze); + } catch (error) { + throw new ServerError(error.message); + } + } } export { SqueezeService }; diff --git a/src/services/super-admin-plans.ts b/src/services/super-admin-plans.ts new file mode 100644 index 00000000..5b9c3600 --- /dev/null +++ b/src/services/super-admin-plans.ts @@ -0,0 +1,117 @@ +import AppDataSource from "../data-source"; +import { Subscription } from "../models/subcription"; +import { Plan } from "../models/plan"; + +export class PlanService { + private subscriptionRepo = AppDataSource.getRepository(Subscription); + private planRepo = AppDataSource.getRepository(Plan); + + async getCurrentPlan(userId: string) { + try { + const subscription = await this.subscriptionRepo.findOne({ + where: { user: { id: userId }, status: "Active" }, + relations: ["plan"], + }); + + if (!subscription) { + throw new Error("User or subscription not found"); + } + + return { + planName: subscription.plan.name, + planPrice: subscription.plan.price, + features: subscription.plan.features, + startDate: subscription.startDate, + renewalDate: subscription.renewalDate, + status: subscription.status, + }; + } catch (error) { + throw new Error("Server error"); + } + } + + async createPlan(planData: { + name: string; + price: number; + features?: string; + limitations?: string; + }) { + const { name, price, features, limitations } = planData; + + if (!name || typeof price !== "number" || price <= 0) { + throw new Error("Invalid input"); + } + + const existingPlan = await this.planRepo.findOne({ where: { name } }); + + if (existingPlan) { + throw new Error("Plan already exists"); + } + const newPlan = this.planRepo.create({ + name, + price, + features: [features], + limitations, + }); + await this.planRepo.save(newPlan); + + return newPlan; + } + + async updatePlan(id: string, updateData: Partial) { + try { + const plan = await this.planRepo.findOne({ where: { id } }); + + if (!plan) { + throw new Error("Plan not found"); + } + + if ( + updateData.price !== undefined && + (typeof updateData.price !== "number" || updateData.price <= 0) + ) { + throw new Error("Invalid price"); + } + + Object.assign(plan, updateData); + + await this.planRepo.save(plan); + + return plan; + } catch (error) { + throw new Error("Server error"); + } + } + + async comparePlans() { + try { + return await this.planRepo.find(); + } catch (error) { + throw new Error("Server error"); + } + } + + async deletePlan(id: string) { + try { + const plan = await this.planRepo.findOne({ where: { id } }); + + if (!plan) { + throw new Error("Plan not found"); + } + + const hasDependencies = await this.subscriptionRepo.count({ + where: { plan }, + }); + + if (hasDependencies > 0) { + throw new Error("Cannot delete plan with active subscriptions"); + } + + await this.planRepo.remove(plan); + + return { message: "Plan deleted successfully" }; + } catch (error) { + throw new Error("Server error"); + } + } +} diff --git a/src/services/updateBlog.services.ts b/src/services/updateBlog.services.ts deleted file mode 100644 index a587e987..00000000 --- a/src/services/updateBlog.services.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Blog } from "../models/blog"; -import AppDataSource from "../data-source"; - -export const updateBlogPost = async ( - id: string, - title: string, - content: string, - published_at?: Date, - image_url?: string, -) => { - const blogRepository = AppDataSource.getRepository(Blog); - - let blog; - try { - blog = await blogRepository.findOne({ where: { id } }); - } catch (error) { - throw new Error("Error finding blog post."); - } - - if (!blog) { - throw new Error("Blog post not found."); - } - - blog.title = title; - blog.content = content; - - if (published_at) { - blog.published_at = published_at; - } - - if (image_url) { - blog.image_url = image_url; - } - - try { - await blogRepository.save(blog); - } catch (error) {} - - return blog; -}; diff --git a/src/swaggerConfig.ts b/src/swaggerConfig.ts index 35061141..1f5b07af 100644 --- a/src/swaggerConfig.ts +++ b/src/swaggerConfig.ts @@ -9,6 +9,7 @@ const swaggerDefinition: SwaggerDefinition = { version: version, // description: // "This is a simple CRUD API application made with Express and documented with Swagger", + basePath: "http://localhost:8000/api-docs", }, servers: [ { @@ -34,6 +35,9 @@ const swaggerDefinition: SwaggerDefinition = { bearerAuth: [], }, ], + externalDocs: { + url: config.SWAGGER_JSON_URL, + }, }; const options = { diff --git a/src/test/billingPlan.spec.ts b/src/test/billingPlan.spec.ts new file mode 100644 index 00000000..015ae211 --- /dev/null +++ b/src/test/billingPlan.spec.ts @@ -0,0 +1,61 @@ +import { BillingPlanService } from "../services/billingplan.services"; +import { BillingPlan } from "../models/billing-plan"; +import { ResourceNotFound } from "../middleware"; +import AppDataSource from "../data-source"; +import { Repository } from "typeorm"; +import { Organization } from "../models"; + +describe("BillingPlanService", () => { + let billingPlanService: BillingPlanService; + let mockRepository: jest.Mocked>; + + beforeEach(() => { + mockRepository = { + findOne: jest.fn(), + } as any; + + jest.spyOn(AppDataSource, "getRepository").mockReturnValue(mockRepository); + + billingPlanService = new BillingPlanService(); + }); + + describe("Get a single billing plan", () => { + it("should return a billing plan when given a valid ID", async () => { + const mockBillingPlan: BillingPlan = { + id: "6b792203-dc65-475c-8733-2d018b9e3c7c", + name: "Test Plan", + price: 100, + currency: "USD", + duration: "monthly", + features: [], + organizationId: "", + description: "", + organization: new Organization(), + payments: [], + }; + + mockRepository.findOne.mockResolvedValue(mockBillingPlan); + + const result = await billingPlanService.getBillingPlan( + "6b792203-dc65-475c-8733-2d018b9e3c7c", + ); + + expect(result).toEqual(mockBillingPlan); + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { id: "6b792203-dc65-475c-8733-2d018b9e3c7c" }, + }); + }); + + it("should throw ResourceNotFound when given an invalid ID", async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + billingPlanService.getBillingPlan("invalid-id"), + ).rejects.toThrow(ResourceNotFound); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { id: "invalid-id" }, + }); + }); + }); +}); diff --git a/src/test/blog.spec.ts b/src/test/blog.spec.ts index 6231b9b1..a06a6b7d 100644 --- a/src/test/blog.spec.ts +++ b/src/test/blog.spec.ts @@ -4,6 +4,7 @@ import AppDataSource from "../data-source"; import { Category, Tag, User } from "../models"; import { Blog } from "../models/blog"; import { BlogService } from "../services"; +import { Forbidden, ResourceNotFound } from "../middleware"; jest.mock("../data-source", () => ({ __esModule: true, @@ -27,8 +28,8 @@ describe("BlogService", () => { blogRepositoryMock = { delete: jest.fn(), save: jest.fn(), - // Add other methods if needed - } as any; // Casting to any to match the mocked repository + findOne: jest.fn(), + } as any; tagRepositoryMock = { findOne: jest.fn(), create: jest.fn(), @@ -49,7 +50,6 @@ describe("BlogService", () => { if (entity === Category) return categoryRepositoryMock; }); - // Initialize the BlogService after setting up the mock blogService = new BlogService(); }); @@ -62,7 +62,7 @@ describe("BlogService", () => { const id = "some-id"; const deleteResult: DeleteResult = { affected: 1, - raw: [], // Provide an empty array or appropriate mock value + raw: [], }; blogRepositoryMock.delete.mockResolvedValue(deleteResult); @@ -77,7 +77,7 @@ describe("BlogService", () => { const id = "non-existing-id"; const deleteResult: DeleteResult = { affected: 0, - raw: [], // Provide an empty array or appropriate mock value + raw: [], }; blogRepositoryMock.delete.mockResolvedValue(deleteResult); @@ -171,4 +171,207 @@ describe("BlogService", () => { expect(categoryRepositoryMock.save).toHaveBeenCalledTimes(2); }); }); + + describe("updateBlog", () => { + it("should update a blog post with new data, tags, and categories", async () => { + const blogId = "blog-123"; + const userId = "user-456"; + const payload = { + title: "Updated Blog Title", + content: "Updated Blog Content", + image_url: "updated-image.jpg", + tags: "tag1,tag2", + categories: "category1,category2", + }; + + const mockUser = { id: userId, name: "User Name" } as User; + const existingBlog = { + id: blogId, + title: "Old Title", + content: "Old Content", + image_url: "old-image.jpg", + author: { id: "user-456", name: "User Name" }, + tags: [], + categories: [], + comments: [], + likes: 0, + } as unknown as Blog; + + const tag1 = { id: "tag-1", name: "tag1" } as unknown as Tag; + const tag2 = { id: "tag-2", name: "tag2" } as unknown as Tag; + const category1 = { + id: "category-1", + name: "category1", + } as unknown as Category; + const category2 = { + id: "category-2", + name: "category2", + } as unknown as Category; + + blogRepositoryMock.findOne.mockResolvedValue(existingBlog); + userRepositoryMock.findOne.mockResolvedValue(mockUser); + tagRepositoryMock.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + categoryRepositoryMock.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + tagRepositoryMock.create + .mockReturnValueOnce(tag1) + .mockReturnValueOnce(tag2); + categoryRepositoryMock.create + .mockReturnValueOnce(category1) + .mockReturnValueOnce(category2); + tagRepositoryMock.save + .mockResolvedValueOnce(tag1) + .mockResolvedValueOnce(tag2); + categoryRepositoryMock.save + .mockResolvedValueOnce(category1) + .mockResolvedValueOnce(category2); + blogRepositoryMock.save.mockResolvedValue({ + ...existingBlog, + ...payload, + tags: [tag1, tag2], + categories: [category1, category2], + }); + + const result = await blogService.updateBlog(blogId, payload, userId); + + expect(blogRepositoryMock.findOne).toHaveBeenCalledWith({ + where: { id: blogId }, + relations: ["author", "tags", "categories"], + }); + expect(userRepositoryMock.findOne).toHaveBeenCalledWith({ + where: { id: userId }, + }); + expect(tagRepositoryMock.findOne).toHaveBeenCalledTimes(2); + expect(categoryRepositoryMock.findOne).toHaveBeenCalledTimes(2); + expect(blogRepositoryMock.save).toHaveBeenCalledWith( + expect.objectContaining({ + ...existingBlog, + ...payload, + tags: [tag1, tag2], + categories: [category1, category2], + author: mockUser, + }), + ); + + expect(result).toEqual({ + blog_id: blogId, + title: payload.title, + content: payload.content, + tags: [tag1, tag2], + categories: [category1, category2], + image_urls: payload.image_url, + author: mockUser.name, + }); + }); + + it("should throw Forbidden if the user is not authorized to update the blog post", async () => { + const blogId = "blog-123"; + const userId = "user-789"; + const payload = { + title: "Updated Blog Title", + content: "Updated Blog Content", + image_url: "updated-image.jpg", + tags: "tag1,tag2", + categories: "category1,category2", + }; + + const existingBlog = { + id: blogId, + title: "Old Title", + content: "Old Content", + image_url: "old-image.jpg", + author: { id: "user-456" }, + tags: [], + categories: [], + comments: [], + likes: 0, + } as unknown as Blog; + + blogRepositoryMock.findOne.mockResolvedValue(existingBlog); + + await expect( + blogService.updateBlog(blogId, payload, userId), + ).rejects.toThrow(Forbidden); + }); + + it("should throw ResourceNotFound if the blog post does not exist", async () => { + const blogId = "non-existent-blog"; + const userId = "user-456"; + const payload = {}; + + blogRepositoryMock.findOne.mockResolvedValue(null); + + await expect( + blogService.updateBlog(blogId, payload, userId), + ).rejects.toThrow(ResourceNotFound); + }); + + it("should create new tags and categories if they do not exist", async () => { + const blogId = "blog-123"; + const userId = "user-456"; + const payload = { + title: "Updated Blog Title", + content: "Updated Blog Content", + image_url: "updated-image.jpg", + tags: "newTag1,newTag2", + categories: "newCategory1,newCategory2", + }; + + const mockUser = { id: userId, name: "User Name" } as User; + const existingBlog = { + id: blogId, + tags: [], + categories: [], + author: { id: userId }, + } as unknown as Blog; + const newTag1 = { id: "new-tag-1", name: "newTag1" } as unknown as Tag; + const newTag2 = { id: "new-tag-2", name: "newTag2" } as unknown as Tag; + const newCategory1 = { + id: "new-category-1", + name: "newCategory1", + } as unknown as Category; + const newCategory2 = { + id: "new-category-2", + name: "newCategory2", + } as unknown as Category; + + blogRepositoryMock.findOne.mockResolvedValue(existingBlog); + userRepositoryMock.findOne.mockResolvedValue(mockUser); + tagRepositoryMock.findOne.mockResolvedValue(null); + tagRepositoryMock.create + .mockReturnValueOnce(newTag1) + .mockReturnValueOnce(newTag2); + categoryRepositoryMock.findOne.mockResolvedValue(null); + categoryRepositoryMock.create + .mockReturnValueOnce(newCategory1) + .mockReturnValueOnce(newCategory2); + tagRepositoryMock.save + .mockResolvedValueOnce(newTag1) + .mockResolvedValueOnce(newTag2); + categoryRepositoryMock.save + .mockResolvedValueOnce(newCategory1) + .mockResolvedValueOnce(newCategory2); + blogRepositoryMock.save.mockResolvedValue({ + ...existingBlog, + ...payload, + tags: [newTag1, newTag2], + categories: [newCategory1, newCategory2], + author: mockUser, + }); + + const result = await blogService.updateBlog(blogId, payload, userId); + + expect(tagRepositoryMock.save).toHaveBeenCalledWith(newTag1); + expect(tagRepositoryMock.save).toHaveBeenCalledWith(newTag2); + expect(categoryRepositoryMock.save).toHaveBeenCalledWith(newCategory1); + expect(categoryRepositoryMock.save).toHaveBeenCalledWith(newCategory2); + expect(result.tags).toEqual(expect.arrayContaining([newTag1, newTag2])); + expect(result.categories).toEqual( + expect.arrayContaining([newCategory1, newCategory2]), + ); + }); + }); }); diff --git a/src/test/jobService.spec.ts b/src/test/jobService.spec.ts new file mode 100644 index 00000000..a1b3cd1b --- /dev/null +++ b/src/test/jobService.spec.ts @@ -0,0 +1,56 @@ +import { JobService } from "../services/job.service"; +import { Repository } from "typeorm"; +import { Job } from "../models/job"; +import AppDataSource from "../data-source"; +import { BadRequest } from "../middleware"; + +jest.mock("../data-source"); + +describe("JobService", () => { + let jobService: JobService; + let jobRepository: jest.Mocked>; + + beforeEach(() => { + jobRepository = { + findOne: jest.fn(), + } as any; + + AppDataSource.getRepository = jest.fn().mockImplementation((model) => { + if (model === Job) { + return jobRepository; + } + throw new Error("Unknown model"); + }); + + jobService = new JobService(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getJobById", () => { + it("should return job details for a valid ID", async () => { + const jobId = "1"; + const jobDetails = { + id: jobId, + title: "Software Engineer", + description: "Job description here...", + user_id: "21", + location: "Remote", + salary: "60000", + job_type: "Developer", + company_name: "Company Name", + } as Job; + + jobRepository.findOne.mockResolvedValue(jobDetails); + + const result = await jobService.getById(jobId); + + expect(jobRepository.findOne).toHaveBeenCalledWith({ + where: { id: jobId }, + }); + expect(result).toEqual(jobDetails); + }); + }); +}); diff --git a/src/test/jobs.spec.ts b/src/test/jobs.spec.ts new file mode 100644 index 00000000..e33491d0 --- /dev/null +++ b/src/test/jobs.spec.ts @@ -0,0 +1,66 @@ +import { Request, Response } from "express"; +import { JobController } from "../controllers/jobController"; +import { JobService } from "../services/job.service"; + +// Mock the JobService module +jest.mock("../services/job.service"); + +describe("JobController", () => { + let jobController: JobController; + let jobService: JobService; + let req: Partial; + let res: Partial; + + beforeEach(() => { + jobService = new JobService(); + jobController = new JobController(); + jobController["jobService"] = jobService; // Inject the mocked service + req = {}; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("getAllJobs", () => { + it("should return an array of jobs with a 200 status code", async () => { + // Arrange + const mockJobs = [ + { id: 1, title: "Software Developer" }, + { id: 2, title: "Data Scientist" }, + ]; + (jobService.getAllJobs as jest.Mock).mockResolvedValue(mockJobs); + + // Act + await jobController.getAllJobs(req as Request, res as Response); + + // Assert + expect(jobService.getAllJobs).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + message: "Jobs retrieved successfully", + billing: mockJobs, + }); + }); + + it("should return a 500 status code with an error message if jobService throws an error", async () => { + // Arrange + const errorMessage = "Failed to fetch jobs"; + (jobService.getAllJobs as jest.Mock).mockRejectedValue( + new Error(errorMessage), + ); + + // Act + await jobController.getAllJobs(req as Request, res as Response); + + // Assert + expect(jobService.getAllJobs).toHaveBeenCalledTimes(1); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ message: errorMessage }); + }); + }); +}); diff --git a/src/test/notification.spec.ts b/src/test/notification.spec.ts new file mode 100644 index 00000000..475a060d --- /dev/null +++ b/src/test/notification.spec.ts @@ -0,0 +1,47 @@ +import { Repository } from "typeorm"; +import { NotificationsService } from "../services"; +import { Notification } from "../models"; +import { mock, MockProxy } from "jest-mock-extended"; + +describe("NotificationsService", () => { + let notificationsService: NotificationsService; + let notificationRepository: MockProxy>; + + beforeEach(() => { + notificationRepository = mock>(); + notificationsService = new NotificationsService(); + (notificationsService as any).notificationRepository = + notificationRepository; + }); + + describe("getNotificationsForUser", () => { + it("should return the correct notification counts and list of notifications", async () => { + const userId = "some-user-id"; + const mockNotifications = [ + { id: "1", isRead: false, createdAt: new Date(), user: { id: userId } }, + { id: "2", isRead: true, createdAt: new Date(), user: { id: userId } }, + { id: "3", isRead: false, createdAt: new Date(), user: { id: userId } }, + ] as any; + + notificationRepository.find.mockResolvedValue(mockNotifications); + + const result = await notificationsService.getNotificationsForUser(userId); + + expect(result.totalNotificationCount).toBe(3); + expect(result.totalUnreadNotificationCount).toBe(2); + expect(result.notifications).toEqual(mockNotifications); + }); + + it("should return empty counts and list if no notifications are found", async () => { + const userId = "some-user-id"; + + notificationRepository.find.mockResolvedValue([]); + + const result = await notificationsService.getNotificationsForUser(userId); + + expect(result.totalNotificationCount).toBe(0); + expect(result.totalUnreadNotificationCount).toBe(0); + expect(result.notifications).toEqual([]); + }); + }); +}); diff --git a/src/test/squeeze.spect.ts b/src/test/squeeze.spect.ts index bfeb90c7..88ab7480 100644 --- a/src/test/squeeze.spect.ts +++ b/src/test/squeeze.spect.ts @@ -1,6 +1,10 @@ +//@ts-nocheck import express from "express"; import request from "supertest"; import { Router } from "express"; +import { SqueezeService } from "../services"; +import { Squeeze } from "../models"; +import { ResourceNotFound } from "../middleware"; const mockSqueezeService = { getSqueezeById: jest.fn(), diff --git a/src/types/index.d.ts b/src/types/index.d.ts index f835ad81..647e0c02 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -57,12 +57,22 @@ export interface ICreateOrganisation { state: string; } +export interface ICreateOrgRole { + name: string; + description: string; +} + export interface IOrganisationService { createOrganisation( payload: ICreateOrganisation, userId: string, ): Promise; removeUser(org_id: string, user_id: string): Promise; + + createOrganisationRole( + payload: ICreateOrgRole, + org_id: string, + ): Promise; } declare module "express-serve-static-core" { @@ -108,3 +118,18 @@ export type UpdateUserRecordOption = { updatePayload: Partial; identifierOption: UserIdentifierOptionsType; }; + +export interface IBillingPlanService { + createBillingPlan(planData: Partial): Promise; +} + +export type GroupedApi = { + api_group: string; + is_operational: API_STATUS; + collection: { + api_name: string; + is_operational: string; + details: string; + last_checked: Date; + }[]; +}; diff --git a/src/utils/request-body-validator.ts b/src/utils/request-body-validator.ts index 36aee7e8..da038cf2 100644 --- a/src/utils/request-body-validator.ts +++ b/src/utils/request-body-validator.ts @@ -10,6 +10,7 @@ const createBlogSchema = z.object({ image_url: z.string(), tags: z.string().optional(), categories: z.string().optional(), + publish_date: z.string().optional(), }); export { createBlogSchema, emailSchema }; diff --git a/src/utils/sendJsonResponse.ts b/src/utils/sendJsonResponse.ts new file mode 100644 index 00000000..5b8e92da --- /dev/null +++ b/src/utils/sendJsonResponse.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Response } from "express"; + +/** + * Sends a JSON response with a standard structure. + * + * @param res - The Express response object. + * @param statusCode - The HTTP status code to send. + * @param message - The message to include in the response. + * @param data - The data to include in the response. Can be any type. + * @param accessToken - Optional access token to include in the response. + */ +const sendJsonResponse = ( + res: Response, + statusCode: number, + message: string, + data?: any, + accessToken?: string, +) => { + const responsePayload: any = { + status: "success", + message, + status_code: statusCode, + data, + }; + + if (accessToken) { + responsePayload.access_token = accessToken; + } + + res.status(statusCode).json(responsePayload); +}; + +export { sendJsonResponse }; diff --git a/yarn.lock b/yarn.lock index 22dd3b1c..4872ce1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1381,7 +1381,7 @@ array-ify@^1.0.0: resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" integrity sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng== -asap@^2.0.0: +asap@^2.0.0, asap@~2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== @@ -4824,6 +4824,14 @@ pause@0.0.1: resolved "https://registry.yarnpkg.com/pause/-/pause-0.0.1.tgz#1d408b3fdb76923b9543d96fb4c9dfd535d9cb5d" integrity sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg== +paystack@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/paystack/-/paystack-2.0.1.tgz#14a854be4a9e29ecaeaea39f6ee34bedb18c3879" + integrity sha512-reVONV7ZUMln/iWeM60n0BbogF3/zFWmUrqbKYVNzEAv+p9TcWDCHfNZ2mBGXzIXhyTsNXWwf4wNcXe28btAHw== + dependencies: + promise "^7.1.1" + request "^2.79.0" + pdfkit@^0.15.0: version "0.15.0" resolved "https://registry.yarnpkg.com/pdfkit/-/pdfkit-0.15.0.tgz#7152f1bfa500c37d25b5f8cd4850db09a8108941" @@ -5034,6 +5042,13 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== +promise@^7.1.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" + integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== + dependencies: + asap "~2.0.3" + prompts@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" @@ -5211,7 +5226,7 @@ regexp.prototype.flags@^1.5.1: es-errors "^1.3.0" set-function-name "^2.0.1" -request@~2.88.2: +request@^2.79.0, request@~2.88.2: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==