├── .DS_Store ├── .env.sample ├── .eslintignore ├── .eslintrc.json ├── .github ├── .DS_Store ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── assign.yml │ ├── cache │ ├── codeql.yml │ ├── dependency-review.yml │ ├── label-issues.yml │ ├── pre-commit.yml │ ├── stale.yml │ └── triage.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── .DS_Store ├── common │ ├── .DS_Store │ ├── config │ │ ├── database.ts │ │ ├── environment.ts │ │ ├── index.ts │ │ └── multer.ts │ ├── constants │ │ └── index.ts │ ├── interfaces │ │ ├── 2fa.ts │ │ ├── aws.ts │ │ ├── campaign.ts │ │ ├── contact.ts │ │ ├── donation.ts │ │ ├── emailQueue.ts │ │ ├── environment.ts │ │ ├── helper.ts │ │ ├── index.ts │ │ ├── location.ts │ │ ├── paystack.ts │ │ ├── request.ts │ │ ├── token.ts │ │ └── user.ts │ └── utils │ │ ├── appError.ts │ │ ├── appResponse.ts │ │ ├── authenticate.ts │ │ ├── axios.ts │ │ ├── helper.ts │ │ ├── index.ts │ │ ├── initialAuthData.ts │ │ ├── logger.ts │ │ ├── payment_services │ │ └── paystack.ts │ │ ├── queryHandler.ts │ │ └── upload.ts ├── controllers │ ├── auth │ │ ├── complete2faSetup.ts │ │ ├── disable2fa.ts │ │ ├── forgotPassword.ts │ │ ├── get2faCodeViaEmail.ts │ │ ├── index.ts │ │ ├── resendVerification.ts │ │ ├── resetPassword.ts │ │ ├── session.ts │ │ ├── setup2fa.ts │ │ ├── signin.ts │ │ ├── signout.ts │ │ ├── signup.ts │ │ ├── verify2fa.ts │ │ └── verifyEmail.ts │ ├── campaign │ │ ├── category │ │ │ ├── createOrUpdate.ts │ │ │ ├── delete.ts │ │ │ └── getCategories.ts │ │ ├── create │ │ │ ├── entry.ts │ │ │ ├── stepOne.ts │ │ │ ├── stepThree.ts │ │ │ └── stepTwo.ts │ │ ├── delete.ts │ │ ├── fetch │ │ │ ├── featured.ts │ │ │ ├── getAll.ts │ │ │ └── getOne.ts │ │ ├── index.ts │ │ ├── publish.ts │ │ └── review.ts │ ├── contact │ │ ├── create.ts │ │ ├── fetch │ │ │ ├── getAll.ts │ │ │ └── getOne.ts │ │ └── index.ts │ ├── donation │ │ ├── create.ts │ │ └── processCompleteDonation.ts │ ├── errorController.ts │ ├── index.ts │ ├── payment_hooks │ │ └── paystack.ts │ ├── pwned.ts │ ├── sockets │ │ ├── handlers │ │ │ ├── index.ts │ │ │ └── userDisconnect.ts │ │ └── index.ts │ └── user │ │ ├── changePassword.ts │ │ ├── deleteAccount.ts │ │ ├── editUserProfile.ts │ │ ├── index.ts │ │ ├── restoreAccount.ts │ │ └── updateProfilePhoto.ts ├── middlewares │ ├── catchAsyncErrors.ts │ ├── catchSocketAsyncErrors.ts │ ├── index.ts │ ├── protect.ts │ ├── timeout.ts │ └── validateDataWithZod.ts ├── models │ ├── LocationModel.ts │ ├── campaignCategoryModel.ts │ ├── campaignModel.ts │ ├── contactModel.ts │ ├── donationModel.ts │ ├── index.ts │ ├── twoFactorModel.ts │ └── userModel.ts ├── queues │ ├── campaignQueue.ts │ ├── emailQueue.ts │ ├── handlers │ │ ├── emailHandler.ts │ │ └── processCampaign.ts │ ├── index.ts │ └── templates │ │ ├── accountDeletedEmail.ts │ │ ├── forgotPassword.ts │ │ ├── get2faCodeViaEmail.ts │ │ ├── index.ts │ │ ├── loginNotification.ts │ │ ├── recoveryKeysEmail.ts │ │ ├── resetPassword.ts │ │ ├── restoreAccountEmail.ts │ │ └── welcomeEmail.ts ├── routes │ ├── authRouter.ts │ ├── campaignRouter.ts │ ├── contactRouter.ts │ ├── donationRouter.ts │ ├── index.ts │ ├── paymentHookRouter.ts │ └── userRouter.ts ├── schemas │ ├── index.ts │ └── main.ts ├── scripts │ └── seeders │ │ └── index.ts └── server.ts ├── todo.txt ├── tsconfig.json └── tsconfig.tsbuildinfo /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abeghelpme/backend/0fad25d2caf1b616e2b691a16b2878faf5e8d538/.DS_Store -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | APP_NAME=abeghelp 2 | APP_PORT=3000 3 | NODE_ENV=development 4 | 5 | DB_URL= 6 | 7 | QUEUE_REDIS_URL= 8 | QUEUE_REDIS_PORT= 9 | QUEUE_REDIS_PASSWORD= 10 | 11 | RESEND_API_KEY= 12 | 13 | CACHE_REDIS_URL= 14 | 15 | REFRESH_JWT_KEY= 16 | ACCESS_JWT_KEY= 17 | 18 | REFRESH_JWT_EXPIRES_IN=1d 19 | ACCESS_JWT_EXPIRES_IN=15m 20 | 21 | FRONTEND_URL= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "./tsconfig.json" 6 | }, 7 | "plugins": ["@typescript-eslint"], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | // Prettier plugin should always be the last to overwrite existing plugins settings that might conflict with prettier 13 | "prettier" 14 | ], 15 | "rules": { 16 | // Add additional configurations or overwrite plugin configurations here 17 | 18 | // Allow named and default exports 19 | "import/prefer-default-export": "off", 20 | 21 | // Allow using console methods 22 | "no-console": "off", 23 | 24 | // Allow boolean casting 25 | "no-extra-boolean-cast": "off", 26 | 27 | // Allow nested ternary expressions 28 | "no-nested-ternary": "off", 29 | 30 | // Allow using regex 31 | "no-control-regex": "off", 32 | 33 | // Allow parameter reassignment 34 | "no-param-reassign": "off", 35 | 36 | // Warn when any type is used 37 | "@typescript-eslint/no-explicit-any": "warn", 38 | 39 | // Disallow unused variables 40 | "@typescript-eslint/no-unused-vars": "error", 41 | 42 | // Allow unused expressions 43 | "@typescript-eslint/no-unused-expressions": "off", 44 | 45 | // Allow the use of _ in code 46 | "no-underscore-dangle": "off", 47 | "prefer-const": "warn" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abeghelpme/backend/0fad25d2caf1b616e2b691a16b2878faf5e8d538/.github/.DS_Store -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request Template 2 | 3 | ## Description 4 | 5 | Please include a summary of the change and which issue is fixed (if applicable). 6 | 7 | ## Related Issue 8 | 9 | - Fixes # (issue number) 10 | 11 | ## Contribution Guidelines 12 | 13 | Before submitting this pull request, please review our [Contribution Guidelines](https://github.com/abeg-help/backend/blob/dev/CONTRIBUTING.md) to understand how to contribute to this project. 14 | 15 | ## Checklist 16 | 17 | - [ ] I have reviewed the Contribution Guidelines linked above. 18 | - [ ] I have tested my changes thoroughly and ensured that all existing tests pass. 19 | - [ ] I have provided clear and concise commit messages. 20 | - [ ] I have updated the project's documentation as necessary. 21 | 22 | ## Screenshots (if applicable) 23 | 24 | 25 | 26 | ## Additional context (if needed) 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/assign.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/take.yml 2 | name: Assign issue to contributor 3 | on: 4 | issue_comment: 5 | 6 | jobs: 7 | assign: 8 | name: Take an issue 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | steps: 13 | - name: take the issue 14 | uses: bdougie/take-action@main 15 | with: 16 | message: Thanks for taking this issue! Let us know if you have any questions! 17 | trigger: .take 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/cache: -------------------------------------------------------------------------------- 1 | - name: Cache 2 | uses: actions/cache@v3.3.2 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: ['main', 'dev'] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ['main', 'dev'] 20 | schedule: 21 | - cron: '25 4 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | # Runner size impacts CodeQL analysis time. To learn more, please see: 27 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 28 | # - https://gh.io/supported-runners-and-hardware-resources 29 | # - https://gh.io/using-larger-runners 30 | # Consider using larger runners for possible analysis time improvements. 31 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 32 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 33 | permissions: 34 | actions: read 35 | contents: read 36 | security-events: write 37 | 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | language: ['javascript'] 42 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] 43 | # Use only 'java' to analyze code written in Java, Kotlin or both 44 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 45 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v3 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@v2 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 61 | # queries: security-extended,security-and-quality 62 | 63 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 64 | # If this step fails, then you should remove it and run the build manually (see below) 65 | - name: Autobuild 66 | uses: github/codeql-action/autobuild@v2 67 | 68 | # ℹ️ Command-line programs to run using the OS shell. 69 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 70 | 71 | # If the Autobuild fails above, remove it and uncomment the following three lines. 72 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 73 | 74 | # - run: | 75 | # echo "Run, Build Application using script" 76 | # ./location_of_script_within_repo/buildscript.sh 77 | 78 | - name: Perform CodeQL Analysis 79 | uses: github/codeql-action/analyze@v2 80 | with: 81 | category: '/language:${{matrix.language}}' 82 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. 4 | # 5 | # Source repository: https://github.com/actions/dependency-review-action 6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement 7 | name: 'Dependency Review' 8 | on: [pull_request] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | dependency-review: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: 'Checkout Repository' 18 | uses: actions/checkout@v3 19 | - name: 'Dependency Review' 20 | uses: actions/dependency-review-action@v3 21 | -------------------------------------------------------------------------------- /.github/workflows/label-issues.yml: -------------------------------------------------------------------------------- 1 | name: Label issues 2 | on: 3 | issues: 4 | types: 5 | - reopened 6 | - opened 7 | jobs: 8 | label_issues: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | steps: 13 | - uses: actions/github-script@v6 14 | with: 15 | script: | 16 | github.rest.issues.addLabels({ 17 | issue_number: context.issue.number, 18 | owner: context.repo.owner, 19 | repo: context.repo.repo, 20 | labels: ["triage"] 21 | }) 22 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | pull_request: 9 | branches: 10 | - main 11 | - dev 12 | 13 | jobs: 14 | ci-checks: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v2 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: 16 25 | 26 | - name: Install dependencies 27 | run: npm install 28 | 29 | - name: Run ESLint 30 | run: npm run lint 31 | 32 | - name: Run Prettier 33 | run: npm run format 34 | 35 | - name: Check for TypeScript types 36 | run: npm run check-types 37 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '32 17 * * *' 11 | 12 | jobs: 13 | stale: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | issues: write 17 | pull-requests: write 18 | 19 | steps: 20 | - uses: actions/stale@v5 21 | with: 22 | repo-token: ${{ secrets.GITHUB_TOKEN }} 23 | stale-issue-message: 'Stale issue message' 24 | stale-pr-message: 'Stale pull request message' 25 | stale-issue-label: 'no-issue-activity' 26 | stale-pr-label: 'no-pr-activity' 27 | -------------------------------------------------------------------------------- /.github/workflows/triage.yml: -------------------------------------------------------------------------------- 1 | name: 'Assign issues with .take' 2 | 3 | on: 4 | issue_comment: 5 | types: 6 | - created 7 | - edited 8 | 9 | jobs: 10 | take-issue: 11 | name: Disable take issue 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 10 14 | steps: 15 | - name: take an issue 16 | uses: bdougie/take-action@main 17 | with: 18 | issueCurrentlyAssignedMessage: Thanks for being interested in this issue. It looks like this ticket is already assigned to someone else. 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Logs and debugging files 5 | logs/ 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Runtime data 13 | pids/ 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | #lock files 19 | yarn.lock 20 | pnpm-lock.yaml 21 | 22 | # Environment files 23 | .env 24 | 25 | # IDE and editor-specific files 26 | .idea/ 27 | *.suo 28 | *.ntvs* 29 | *.njsproj 30 | *.sln 31 | *.sw? 32 | 33 | # Dependency files 34 | *.d.ts 35 | *.map 36 | 37 | # Build output 38 | build/ 39 | dist/ 40 | out/ 41 | 42 | # Logs from npm 43 | npm-debug.log* 44 | 45 | # Yarn 46 | yarn-error.log* 47 | 48 | 49 | .env 50 | package-lock.json 51 | 52 | .DS_Store 53 | /.DS_Store 54 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "useTabs": true, 5 | "semi": true, 6 | "singleQuote": true, 7 | "printWidth": 120 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["streetsidesoftware.code-spell-checker"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports": "never" 4 | }, 5 | "cSpell.words": [ 6 | "Abeg", 7 | "ABEGHELP", 8 | "Autopopulate", 9 | "bullmq", 10 | "cellspacing", 11 | "clickjacking", 12 | "didn", 13 | "frameguard", 14 | "hsts", 15 | "ipcity", 16 | "ipcontinent", 17 | "ipcountry", 18 | "ipfilter", 19 | "iplatitude", 20 | "iplongitude", 21 | "Isocket", 22 | "linklocal", 23 | "luxon", 24 | "Millis", 25 | "Neue", 26 | "Noto", 27 | "OPENAI", 28 | "otpauth", 29 | "paystack", 30 | "Segoe", 31 | "signin", 32 | "signout", 33 | "TOTP", 34 | "uniquelocal", 35 | "virtuals" 36 | ], 37 | "rules": { 38 | "sort-imports": "off" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Project Contribution Guidelines 2 | 3 | ## Table of Contents 4 | 5 | 1. [Getting Started](#getting-started) 6 | 2. [Finding an Issue](#finding-an-issue) 7 | 3. [Creating a Pull Request](#creating-a-pull-request) 8 | 4. [Coding Standards](#coding-standards) 9 | 5. [Testing](#testing) 10 | 6. [Documentation](#documentation) 11 | 7. [Review Process](#review-process) 12 | 8. [Community Guidelines](#community-guidelines) 13 | 9. [License](#license) 14 | 15 | ### 1. Getting Started 16 | 17 | Before you begin contributing, make sure you have the following set up on your local machine: 18 | 19 | - Git installed 20 | - A GitHub account 21 | 22 | ### 2. Finding an Issue 23 | 24 | 1. Visit our project's GitHub repository. 25 | 2. Go to the "Issues" tab. 26 | 3. Browse through the list of open issues. 27 | 4. Filter issues based on your skills, interests, or availability. 28 | 5. Comment on the issue you'd like to work on to express your interest or ask questions. 29 | 6. To auto-assign an issue to yourself, reply with `.take` in the issue comment. 30 | 31 | ### 3. Creating a Pull Request 32 | 33 | 1. Fork the repository to your GitHub account. 34 | 2. Clone your forked repository to your local machine. 35 | 3. Create a new branch for your contribution. 36 | 4. Make your changes and commit them with clear and concise commit messages. 37 | 5. Push your changes to your forked repository. 38 | 6. Create a pull request (PR) against the `dev` branch. 39 | 40 | ### 4. Coding Standards 41 | 42 | - Follow the coding style and standards used in the project. 43 | - Ensure your code is well-documented. 44 | - Use meaningful variable and function names. 45 | - Maintain consistency with existing code. 46 | 47 | ### 5. Testing 48 | 49 | - Test your code thoroughly to ensure it works as intended. 50 | - Write tests where applicable. 51 | - Ensure that all existing tests pass. 52 | - Include test cases that cover both normal and edge cases. 53 | 54 | ### 6. Documentation 55 | 56 | - Update or create documentation as necessary for your changes. 57 | - Keep documentation clear and concise. 58 | - If you make significant changes, update the project's README or documentation files. 59 | 60 | ### 7. Review Process 61 | 62 | To update or create documentation, you need to have Markdown knowledge. 63 | Visit [here](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) to read about GitHub Markdown and [here](https://www.markdowntutorial.com/) to practice. 64 | 65 | - Your PR will be reviewed by maintainers and contributors. 66 | - Be responsive to feedback and make necessary changes. 67 | - The PR may be merged once it receives approvals and passes automated tests. 68 | 69 | ### 8. Community Guidelines 70 | 71 | - Be respectful and considerate of other contributors. 72 | - Follow the project's code of conduct. 73 | - Encourage a positive and inclusive community environment. 74 | - Help others and answer questions when possible. 75 | 76 | ### 9. License 77 | 78 | By contributing to this project, you agree that your contributions will be licensed under the project's open-source license. Make sure to review and understand the project's license before contributing. 79 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Issue Template 2 | 3 | ## Issue Type 4 | 5 | - [ ] Bug Report 6 | - [ ] Feature Request 7 | - [ ] Enhancement 8 | - [ ] Other (Please specify) 9 | 10 | ## Description 11 | 12 | [Provide a brief description of the issue or feature request.] 13 | 14 | ## Details 15 | 16 | [Provide more details about the issue or feature request. If it's a bug, include any error messages, unexpected behavior, or other relevant information. If it's a feature request or enhancement, explain why it would be valuable.] 17 | 18 | ## Reproduction Steps (For Bug Reports) 19 | 20 | 1. [Step 1] 21 | 2. [Step 2] 22 | 3. [Step 3] 23 | 24 | ## Expected Behavior (For Bug Reports) 25 | 26 | [Describe what you expected to happen.] 27 | 28 | ## Actual Behavior (For Bug Reports) 29 | 30 | [Describe what actually happened.] 31 | 32 | ## Additional Information 33 | 34 | [Include any additional information that may help in resolving the issue, such as screenshots, logs, or any related links.] 35 | 36 | ## Would you like to contribute to fixing this issue? 37 | 38 | - [ ] Yes 39 | - [ ] No 40 | 41 | ## Related Pull Requests (if any) 42 | 43 | [Link to any related pull requests, if applicable.] 44 | 45 | ## Failure Logs 46 | 47 | Please include any relevant log snippets or files here. 48 | 49 | **Note:** Please make sure to follow the project's code of conduct and contribution guidelines when creating an issue. Thank you for contributing to our open-source project! 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | All Rights Reserved 2 | 3 | Copyright (c) 2023 4 | 5 | THE CONTENTS OF THIS PROJECT ARE PROPRIETARY AND CONFIDENTIAL. 6 | UNAUTHORIZED COPYING, TRANSFERRING OR REPRODUCTION OF THE CONTENTS OF THIS PROJECT, VIA ANY MEDIUM IS STRICTLY PROHIBITED. 7 | 8 | The receipt or possession of the source code and/or any parts thereof does not convey or imply any right to use them 9 | for any purpose other than the purpose for which they were provided to you. 10 | 11 | The software is provided "AS IS", without warranty of any kind, express or implied, including but not limited to 12 | the warranties of merchantability, fitness for a particular purpose and non infringement. 13 | In no event shall the authors or copyright holders be liable for any claim, damages or other liability, 14 | whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software 15 | or the use or other dealings in the software. 16 | 17 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the software. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AbegHelp.me Backend 2 | 3 | [![StartEase](https://img.shields.io/badge/Generated%20by-StartEase-blue)](https://github.com/JC-Coder/startease) 4 | 5 | ## Overview 6 | 7 | Welcome to the **AbegHelp.me Backend** project! This is a **Node.js** and **ExpressJS** backend application designed to power the fundraising platform **AbegHelp.me**. The project is built with **TypeScript** and includes features like campaign management, user authentication, email notifications, and more. 8 | 9 | ## Features 10 | 11 | - **Campaign Management**: 12 | - Create, update, and manage fundraising campaigns in multiple steps. 13 | - Campaigns can be reviewed, approved, or rejected based on content relevance. 14 | - **Authentication & Security**: 15 | - JWT-based authentication. 16 | - Two-Factor Authentication (2FA) via email or app. 17 | - Secure cookie handling with HTTP-only and SameSite policies. 18 | - **Email Notifications**: 19 | - Welcome emails, 2FA codes, password resets, and other notifications. 20 | - Uses **Resend** for email delivery. 21 | - **Queue System**: 22 | - Background tasks like email sending and campaign processing are handled by **BullMQ**. 23 | - **Security Measures**: 24 | - Rate limiting, CORS, CSP (Content Security Policy), and other security best practices. 25 | - **Environment Configuration**: 26 | - Uses `.env` files for environment-specific configurations. 27 | 28 | ## Prerequisites 29 | 30 | Before you begin, ensure you have the following installed: 31 | 32 | - **Node.js** (>=20.11.0) and **npm** or **yarn**. 33 | - **MongoDB** (for database). 34 | - **Redis** (for caching and queue management). 35 | 36 | ## Installation 37 | 38 | 1. Clone the repository: 39 | 40 | ```bash 41 | git clone https://github.com/abeghelpme/backend.git 42 | cd backend 43 | ``` 44 | 45 | 2. Install dependencies: 46 | 47 | ```bash 48 | npm install 49 | ``` 50 | 51 | or 52 | 53 | ```bash 54 | yarn install 55 | ``` 56 | 57 | 3. Set up environment variables: 58 | - Create a `.env` file in the project root. 59 | - Add the following variables: 60 | ```plaintext 61 | APP_NAME=AbegHelp 62 | APP_PORT=3000 63 | NODE_ENV=development 64 | MONGO_URI=your-mongodb-uri 65 | REDIS_URL=your-redis-url 66 | EMAIL_API_KEY=your-resend-api-key 67 | JWT_ACCESS_KEY=your-jwt-access-key 68 | ``` 69 | 70 | ## Running the Project 71 | 72 | 1. Start the development server: 73 | 74 | ```bash 75 | npm run dev 76 | ``` 77 | 78 | 2. Build the project for production: 79 | 80 | ```bash 81 | npm run build 82 | ``` 83 | 84 | 3. Start the production server: 85 | ```bash 86 | npm start 87 | ``` 88 | 89 | ## Project Structure 90 | 91 | - **`src/`**: Contains the source code. 92 | - **`controllers/`**: Handles request logic. 93 | - **`models/`**: Defines MongoDB schemas. 94 | - **`queues/`**: Manages background tasks using BullMQ. 95 | - **`routes/`**: Defines API routes. 96 | - **`scripts/`**: Contains utility scripts like seeders. 97 | - **`common/`**: Shared utilities, constants, and interfaces. 98 | - **`build/`**: Contains the compiled TypeScript code (generated after build). 99 | 100 | ## Contributing 101 | 102 | We welcome contributions! Please follow our [Contribution Guidelines](CONTRIBUTING.md) to get started. 103 | 104 | ## License 105 | 106 | This project is proprietary and confidential. Unauthorized copying, transferring, or reproduction of the contents of this project is strictly prohibited. 107 | 108 | ## Support 109 | 110 | For any issues or questions, please open an issue in the [GitHub repository](https://github.com/abeg-help/backend/issues). 111 | 112 | --- 113 | 114 | **Happy Coding!** 🚀 115 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": ".ts,.js", 4 | "ignore": [".git", "node_modules"], 5 | "exec": "node -r tsconfig-paths/register -r ts-node/register ./src/server.ts" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "abeg-help-project", 3 | "version": "1.0.0", 4 | "description": "Abeg help project", 5 | "main": "index.js", 6 | "scripts": { 7 | "pre-commit": "lint-staged", 8 | "prepare": "husky install", 9 | "start": "node -r ts-node/register/transpile-only -r tsconfig-paths/register build/server.js", 10 | "start-prod": "node build/server.js", 11 | "build": "tsc", 12 | "dev": "nodemon", 13 | "watch": "tsc --watch & nodemon build/index.js", 14 | "lint": "npx eslint src/", 15 | "format": "npx prettier --write .", 16 | "check-types": "tsc --noEmit --pretty --skipLibCheck --incremental --project tsconfig.json --strict" 17 | }, 18 | "husky": { 19 | "hooks": { 20 | "pre-commit": "npm run pre-commit" 21 | } 22 | }, 23 | "lint-staged": { 24 | "*.{js,ts}": [ 25 | "npm run format", 26 | "npm run lint" 27 | ] 28 | }, 29 | "author": "", 30 | "license": "ISC", 31 | "dependencies": { 32 | "@aws-sdk/client-s3": "^3.514.0", 33 | "@bull-board/api": "^5.9.1", 34 | "@bull-board/express": "^5.9.1", 35 | "@faker-js/faker": "^8.4.1", 36 | "@google/generative-ai": "^0.2.1", 37 | "@types/luxon": "^3.3.4", 38 | "@types/multer": "^1.4.11", 39 | "axios": "^1.6.7", 40 | "bad-words": "^3.0.4", 41 | "bcryptjs": "^2.4.3", 42 | "blurhash": "^2.0.5", 43 | "body-parser": "^1.20.2", 44 | "bullmq": "^5.1.3", 45 | "compression": "^1.7.4", 46 | "connect-timeout": "^1.9.0", 47 | "cookie": "^0.6.0", 48 | "cookie-parser": "^1.4.6", 49 | "cors": "^2.8.5", 50 | "dotenv": "^16.3.1", 51 | "express": "^4.19.2", 52 | "express-ipfilter": "^1.3.2", 53 | "express-mongo-sanitize": "^2.2.0", 54 | "express-rate-limit": "^7.1.3", 55 | "google-libphonenumber": "^3.2.33", 56 | "helmet": "^7.0.0", 57 | "helmet-csp": "^3.4.0", 58 | "hi-base32": "^0.5.1", 59 | "hpp": "^0.2.3", 60 | "i": "^0.3.7", 61 | "ioredis": "^5.3.2", 62 | "jsonwebtoken": "^9.0.2", 63 | "luxon": "^3.4.3", 64 | "module-alias": "^2.2.3", 65 | "mongoose": "^8.0.0", 66 | "mongoose-autopopulate": "^1.1.0", 67 | "morgan": "^1.10.0", 68 | "multer": "^1.4.5-lts.1", 69 | "nanoid": "^3.3.7", 70 | "natural": "^6.10.4", 71 | "npm": "^10.2.4", 72 | "openai": "^4.28.4", 73 | "otpauth": "^9.2.0", 74 | "qrcode": "^1.5.3", 75 | "qs": "^6.11.2", 76 | "resend": "^1.1.0", 77 | "sharp": "^0.33.2", 78 | "socket.io": "^4.7.2", 79 | "ts-custom-error": "^3.3.1", 80 | "tsconfig-paths": "^3.14.1", 81 | "winston": "^3.10.0", 82 | "xss-clean": "^0.1.4", 83 | "zod": "^3.22.4" 84 | }, 85 | "resolutions": { 86 | "cookie": "0.6.0" 87 | }, 88 | "devDependencies": { 89 | "@types/bad-words": "^3.0.3", 90 | "@types/bcryptjs": "^2.4.5", 91 | "@types/connect-timeout": "^0.0.37", 92 | "@types/cookie-parser": "^1.4.6", 93 | "@types/cors": "^2.8.14", 94 | "@types/express": "^4.17.18", 95 | "@types/hpp": "^0.2.3", 96 | "@types/jsonwebtoken": "^9.0.4", 97 | "@types/morgan": "^1.9.6", 98 | "@types/qrcode": "^1.5.5", 99 | "@types/redis-info": "^3.0.2", 100 | "@typescript-eslint/eslint-plugin": "^6.7.4", 101 | "@typescript-eslint/parser": "^6.7.4", 102 | "concurrently": "^8.2.1", 103 | "eslint": "^8.57.0", 104 | "eslint-config-prettier": "^9.0.0", 105 | "husky": "^8.0.3", 106 | "lint-staged": "^14.0.1", 107 | "nanoid": "^3.3.7", 108 | "nodemon": "^3.0.1", 109 | "prettier": "^3.0.3", 110 | "rimraf": "^5.0.5", 111 | "ts-node": "^10.9.1", 112 | "ts-node-dev": "^2.0.0", 113 | "typescript": "^5.4.5" 114 | }, 115 | "engines": { 116 | "node": ">=20.11.0" 117 | }, 118 | "_moduleAliases": { 119 | "@": "./build" 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abeghelpme/backend/0fad25d2caf1b616e2b691a16b2878faf5e8d538/src/.DS_Store -------------------------------------------------------------------------------- /src/common/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abeghelpme/backend/0fad25d2caf1b616e2b691a16b2878faf5e8d538/src/common/.DS_Store -------------------------------------------------------------------------------- /src/common/config/database.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { ENVIRONMENT } from './environment'; 3 | 4 | import { ConnectOptions } from 'mongoose'; 5 | 6 | interface CustomConnectOptions extends ConnectOptions { 7 | maxPoolSize?: number; 8 | minPoolSize?: number; 9 | } 10 | 11 | export const connectDb = async (): Promise => { 12 | try { 13 | const conn = await mongoose.connect(ENVIRONMENT.DB.URL, { 14 | // minPoolSize: 100, 15 | // maxPoolSize: 100, 16 | } as CustomConnectOptions); 17 | 18 | console.log('MongoDB Connected to ' + conn.connection.name); 19 | } catch (error) { 20 | console.log('Error: ' + (error as Error).message); 21 | process.exit(1); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/common/config/environment.ts: -------------------------------------------------------------------------------- 1 | import type { IEnvironment } from '@/common/interfaces'; 2 | 3 | export const ENVIRONMENT: IEnvironment = { 4 | APP: { 5 | NAME: process.env.APP_NAME, 6 | PORT: parseInt(process.env.PORT || process.env.APP_PORT || '3000'), 7 | ENV: process.env.NODE_ENV, 8 | CLIENT: process.env.FRONTEND_URL!, 9 | }, 10 | DB: { 11 | URL: process.env.DB_URL!, 12 | }, 13 | REDIS: { 14 | URL: process.env.QUEUE_REDIS_URL!, 15 | PASSWORD: process.env.QUEUE_REDIS_PASSWORD!, 16 | PORT: parseInt(process.env.QUEUE_REDIS_PORT!), 17 | }, 18 | CACHE_REDIS: { 19 | URL: process.env.CACHE_REDIS_URL!, 20 | }, 21 | EMAIL: { 22 | API_KEY: process.env.RESEND_API_KEY!, 23 | }, 24 | JWT: { 25 | REFRESH_KEY: process.env.REFRESH_JWT_KEY!, 26 | ACCESS_KEY: process.env.ACCESS_JWT_KEY!, 27 | }, 28 | JWT_EXPIRES_IN: { 29 | REFRESH: process.env.REFRESH_JWT_EXPIRES_IN!, 30 | ACCESS: process.env.ACCESS_JWT_EXPIRES_IN!, 31 | }, 32 | FRONTEND_URL: process.env.FRONTEND_URL!, 33 | R2: { 34 | ACCOUNT_ID: process.env.R2_ACCOUNT_ID!, 35 | SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY!, 36 | REGION: process.env.R2_REGION!, 37 | BUCKET_NAME: process.env.R2_BUCKET_NAME!, 38 | ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID!, 39 | CDN_URL: process.env.R2_CDN_URL!, 40 | }, 41 | OPENAI: { 42 | API_KEY: process.env.OPENAI_API_KEY!, 43 | }, 44 | PAYSTACK: { 45 | HOST: process.env.PAYSTACK_HOST!, 46 | SECRET_KEY: process.env.PAYSTACK_SECRET_KEY!, 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /src/common/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './database'; 2 | export * from './environment'; 3 | export * from './multer'; 4 | -------------------------------------------------------------------------------- /src/common/config/multer.ts: -------------------------------------------------------------------------------- 1 | import multer from 'multer'; 2 | 3 | const multerStorage = multer.memoryStorage(); 4 | 5 | export const multerUpload = multer({ 6 | storage: multerStorage, 7 | }); 8 | -------------------------------------------------------------------------------- /src/common/constants/index.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | 3 | export enum Role { 4 | SuperUser = 'superuser', 5 | User = 'user', 6 | Guest = 'guest', 7 | } 8 | 9 | export enum Provider { 10 | Local = 'local', 11 | Google = 'google', 12 | } 13 | 14 | export enum IDType { 15 | NIN = 'nin', 16 | BVN = 'bvn', 17 | IntlPassport = 'intl-passport', 18 | } 19 | 20 | export enum Gender { 21 | Male = 'male', 22 | Female = 'female', 23 | Other = 'other', 24 | None = 'none', 25 | } 26 | 27 | export enum JWTExpiresIn { 28 | Access = 15 * 60 * 1000, 29 | Refresh = 24 * 60 * 60 * 1000, 30 | } 31 | 32 | export const TOTPBaseConfig = { 33 | issuer: `${ENVIRONMENT.APP.NAME}`, 34 | label: `${ENVIRONMENT.APP.NAME}`, 35 | algorithm: 'SHA1', 36 | digits: 6, 37 | }; 38 | 39 | export enum VerifyTimeBased2faTypeEnum { 40 | CODE = 'CODE', 41 | EMAIL_CODE = 'EMAIL_CODE', 42 | DISABLE_2FA = 'DISABLE_2FA', 43 | } 44 | 45 | export enum twoFactorTypeEnum { 46 | EMAIL = 'EMAIL', 47 | APP = 'APP', 48 | } 49 | 50 | export enum Country { 51 | NIGERIA = 'NIGERIA', 52 | GHANA = 'GHANA', 53 | MALI = 'MALI', 54 | LIBERIA = 'LIBERIA', 55 | GAMBIA = 'GAMBIA', 56 | CAMEROON = 'CAMEROON', 57 | } 58 | 59 | export enum Category { 60 | Health_and_Wellness = 'Health and Wellness', 61 | Business = 'Business', 62 | Family = 'Family', 63 | Emergency = 'Emergency', 64 | Religion = 'Religion', 65 | Medical = 'Medical', 66 | Volunteer = 'Volunteer', 67 | Education = 'Education', 68 | Event = 'Event', 69 | Wedding = 'Wedding', 70 | Others = 'Others', 71 | } 72 | 73 | export enum FundraiserEnum { 74 | INDIVIDUAL = 'INDIVIDUAL', 75 | BENEFICIARY = 'BENEFICIARY', 76 | } 77 | 78 | export enum StatusEnum { 79 | IN_REVIEW = 'In Review', 80 | APPROVED = 'Approved', 81 | REJECTED = 'Rejected', 82 | DRAFT = 'Draft', 83 | } 84 | 85 | export enum FlaggedReasonTypeEnum { 86 | INAPPROPRIATE_CONTENT = 'In-appropriate Content', 87 | MISMATCH = 'Mismatch', 88 | EXISTS = 'Exists', 89 | } 90 | 91 | export enum PaymentStatusEnum { 92 | UNPAID = 'Unpaid', 93 | PAID = 'Paid', 94 | FAILED = 'Failed', 95 | REFUNDED = 'Refunded', 96 | REFUND_FAILED = 'Refund failed', 97 | } 98 | 99 | export enum LocationTypeEnum { 100 | SIGNIN = 'SIGNIN', 101 | DONATION = 'DONATION', 102 | } 103 | -------------------------------------------------------------------------------- /src/common/interfaces/2fa.ts: -------------------------------------------------------------------------------- 1 | import { twoFactorTypeEnum } from '@/common/constants'; 2 | 3 | export interface ITwoFactor { 4 | type?: twoFactorTypeEnum; 5 | secret?: string; 6 | recoveryCode?: string; 7 | active: boolean; 8 | verificationTime?: Date; 9 | isVerified?: boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/common/interfaces/aws.ts: -------------------------------------------------------------------------------- 1 | export interface IAwsUploadFile { 2 | fileName: string; 3 | mimetype: string; 4 | buffer: Buffer; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/interfaces/campaign.ts: -------------------------------------------------------------------------------- 1 | import { Country, FlaggedReasonTypeEnum, FundraiserEnum, StatusEnum } from '@/common/constants'; 2 | 3 | export interface ICampaign { 4 | shortId: string; 5 | category: { 6 | type: string; 7 | ref: string; 8 | }; 9 | country: Country; 10 | tags: string[]; 11 | goal: number; 12 | amountRaised: number; 13 | story: string; 14 | storyHtml: string; 15 | images: string[]; 16 | title: string; 17 | fundraiser: FundraiserEnum; 18 | deadline: Date; 19 | creator: { 20 | type: string; 21 | ref: string; 22 | }; 23 | isPublished: boolean; 24 | status: StatusEnum; 25 | isFlagged: boolean; 26 | flaggedReasons: Array<{ 27 | type: FlaggedReasonTypeEnum; 28 | reason: string; 29 | }>; 30 | isDeleted: boolean; 31 | featured: boolean; 32 | currentStep: number; 33 | } 34 | 35 | export interface ICampaignCategory { 36 | name: string; 37 | isDeleted: boolean; 38 | image: string; 39 | } 40 | -------------------------------------------------------------------------------- /src/common/interfaces/contact.ts: -------------------------------------------------------------------------------- 1 | export interface IContact { 2 | firstName: string; 3 | lastName: string; 4 | email: string; 5 | message: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/common/interfaces/donation.ts: -------------------------------------------------------------------------------- 1 | import { SchemaDefinitionProperty } from 'mongoose'; 2 | import { PaymentStatusEnum } from '../constants'; 3 | 4 | export interface IDonation { 5 | reference: string; 6 | campaignId: SchemaDefinitionProperty; 7 | donorEmail: string; 8 | donorName: string; 9 | amount: number; 10 | paymentStatus: PaymentStatusEnum; 11 | paymentDate: string; 12 | paymentMeta?: object; 13 | hideDonorDetails: boolean; 14 | } 15 | 16 | export interface IProcessDonationCompleted { 17 | campaignId: string; 18 | paidAt: string; 19 | reference: string; 20 | status: string; 21 | amount: number; 22 | } 23 | -------------------------------------------------------------------------------- /src/common/interfaces/emailQueue.ts: -------------------------------------------------------------------------------- 1 | import { locationModel } from './location'; 2 | 3 | export interface CommonDataFields { 4 | to: string; 5 | priority?: string; 6 | name?: string; 7 | } 8 | 9 | export interface WelcomeEmailData extends CommonDataFields { 10 | verificationLink: string; 11 | email: string; 12 | } 13 | 14 | export interface ForgotPasswordData extends CommonDataFields { 15 | token: string; 16 | } 17 | 18 | export interface ResetPasswordData extends CommonDataFields { 19 | // Add other specific fields for the password reset successful data 20 | } 21 | 22 | export interface DeleteAccountData extends CommonDataFields { 23 | days: string; 24 | restoreLink: string; 25 | } 26 | 27 | export interface RestoreAccountData extends CommonDataFields { 28 | loginLink: string; 29 | } 30 | export interface FallbackOTPEmailData extends CommonDataFields { 31 | token: string; 32 | } 33 | 34 | export interface Get2faCodeViaEmailData extends CommonDataFields { 35 | twoFactorCode: string; 36 | expiryTime: string; 37 | } 38 | export interface RecoveryKeysEmailData extends CommonDataFields { 39 | recoveryCode: string; 40 | } 41 | 42 | export interface loginNotificationData extends Partial, CommonDataFields {} 43 | 44 | export type EmailJobData = 45 | | { type: 'welcomeEmail'; data: WelcomeEmailData } 46 | | { type: 'resetPassword'; data: ResetPasswordData } 47 | | { type: 'forgotPassword'; data: ForgotPasswordData } 48 | | { type: 'deleteAccount'; data: DeleteAccountData } 49 | | { type: 'restoreAccount'; data: RestoreAccountData } 50 | | { type: 'fallbackOTP'; data: FallbackOTPEmailData } 51 | | { type: 'get2faCodeViaEmail'; data: Get2faCodeViaEmailData } 52 | | { type: 'recoveryKeysEmail'; data: RecoveryKeysEmailData } 53 | | { type: 'loginNotification'; data: loginNotificationData }; 54 | -------------------------------------------------------------------------------- /src/common/interfaces/environment.ts: -------------------------------------------------------------------------------- 1 | export interface IEnvironment { 2 | APP: { 3 | NAME?: string; 4 | PORT: number; 5 | ENV?: string; 6 | CLIENT: string; 7 | }; 8 | DB: { 9 | URL: string; 10 | }; 11 | REDIS: { 12 | URL: string; 13 | PORT: number; 14 | PASSWORD: string; 15 | }; 16 | CACHE_REDIS: { 17 | URL: string; 18 | }; 19 | EMAIL: { 20 | API_KEY: string; 21 | }; 22 | JWT: { 23 | ACCESS_KEY: string; 24 | REFRESH_KEY: string; 25 | }; 26 | JWT_EXPIRES_IN: { 27 | ACCESS: string; 28 | REFRESH: string; 29 | }; 30 | FRONTEND_URL: string; 31 | R2: { 32 | ACCESS_KEY_ID: string; 33 | SECRET_ACCESS_KEY: string; 34 | REGION: string; 35 | BUCKET_NAME: string; 36 | ACCOUNT_ID: string; 37 | CDN_URL: string; 38 | }; 39 | OPENAI: { 40 | API_KEY: string; 41 | }; 42 | PAYSTACK: { 43 | HOST: string; 44 | SECRET_KEY: string; 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/common/interfaces/helper.ts: -------------------------------------------------------------------------------- 1 | export interface IHashData { 2 | id?: string; 3 | token?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/common/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './2fa'; 2 | export * from './aws'; 3 | export * from './campaign'; 4 | export * from './emailQueue'; 5 | export * from './environment'; 6 | export * from './helper'; 7 | export * from './request'; 8 | export * from './token'; 9 | export * from './user'; 10 | export * from './location'; 11 | export * from './donation'; 12 | export * from './paystack'; 13 | export * from './contact'; 14 | -------------------------------------------------------------------------------- /src/common/interfaces/location.ts: -------------------------------------------------------------------------------- 1 | import { LocationTypeEnum } from '@/common/constants'; 2 | import mongoose from 'mongoose'; 3 | 4 | export interface ILocation { 5 | country: string; 6 | city: string; 7 | postalCode: string; 8 | ipv4: string; 9 | ipv6: string; 10 | geo: { 11 | lat: string; 12 | lng: string; 13 | }; 14 | region: string; 15 | continent: string; 16 | timezone: string; 17 | os: string; 18 | createdAt: Date; 19 | updatedAt: Date; 20 | user: mongoose.Types.ObjectId; 21 | donation: mongoose.Types.ObjectId; 22 | type: LocationTypeEnum; 23 | } 24 | 25 | export type locationModel = ILocation; 26 | -------------------------------------------------------------------------------- /src/common/interfaces/paystack.ts: -------------------------------------------------------------------------------- 1 | export interface IInitializeTransaction { 2 | amount: number; 3 | email: string; 4 | callback_url?: string; 5 | reference: string; 6 | metadata: { 7 | campaignId: string; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/common/interfaces/request.ts: -------------------------------------------------------------------------------- 1 | import { Require_id } from 'mongoose'; 2 | import { Server } from 'socket.io'; 3 | import type { IUser } from './user'; 4 | 5 | declare global { 6 | // eslint-disable-next-line @typescript-eslint/no-namespace 7 | namespace Express { 8 | interface Request { 9 | user?: Require_id; 10 | io: Server; 11 | file?: Express.Multer.File; 12 | } 13 | } 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-namespace 16 | namespace Socket { 17 | interface Socket { 18 | user?: Require_id; 19 | } 20 | } 21 | } 22 | 23 | declare module 'express-serve-static-core' { 24 | export interface CookieOptions { 25 | partitioned?: boolean; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/common/interfaces/token.ts: -------------------------------------------------------------------------------- 1 | export interface IToken { 2 | user: string; 3 | token: string; 4 | createdAt: Date; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/interfaces/user.ts: -------------------------------------------------------------------------------- 1 | import { Gender, IDType, Provider, Role } from '@/common/constants'; 2 | import type { SignOptions } from 'jsonwebtoken'; 3 | import { Model } from 'mongoose'; 4 | import { ITwoFactor } from './2fa'; 5 | 6 | export interface IUser { 7 | firstName: string; 8 | lastName: string; 9 | email: string; 10 | password: string; 11 | refreshToken: string; 12 | photo: string; 13 | blurHash: string; 14 | role: Role; 15 | isProfileComplete: boolean; 16 | provider: Provider; 17 | phoneNumber: string; 18 | verificationToken: string; 19 | passwordResetToken: string; 20 | passwordResetExpires: Date; 21 | passwordResetRetries: number; 22 | passwordChangedAt: Date; 23 | ipAddress: string; 24 | loginRetries: number; 25 | address: string[]; 26 | gender: Gender; 27 | verificationMethod: IDType; 28 | isIdVerified: boolean; 29 | isSuspended: boolean; 30 | isMobileVerified: boolean; 31 | isEmailVerified: boolean; 32 | isDeleted: boolean; 33 | accountRestoreToken: string; 34 | twoFA: ITwoFactor; 35 | isTermAndConditionAccepted: boolean; 36 | lastLogin: Date; 37 | createdAt: Date; 38 | updatedAt: Date; 39 | } 40 | 41 | export interface UserMethods extends Omit { 42 | generateAccessToken(options?: SignOptions): string; 43 | generateRefreshToken(options?: SignOptions): string; 44 | verifyPassword(enteredPassword: string): Promise; 45 | toJSON(excludedFields?: Array): object; 46 | } 47 | 48 | export type UserModel = Model; 49 | -------------------------------------------------------------------------------- /src/common/utils/appError.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from 'ts-custom-error'; 2 | 3 | export default class AppError extends CustomError { 4 | statusCode: number; 5 | status: string; 6 | isOperational: boolean; 7 | data?: unknown; 8 | 9 | constructor(message: string, statusCode: number = 400, data?: unknown) { 10 | super(message); 11 | // Object.setPrototypeOf(this, AppError.prototype); 12 | 13 | this.statusCode = statusCode; 14 | this.status = `${statusCode}`.startsWith('5') ? 'Failed' : 'Error'; 15 | this.isOperational = true; 16 | this.data = data; 17 | 18 | Error.captureStackTrace(this, this.constructor); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/common/utils/appResponse.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express'; 2 | 3 | export function AppResponse( 4 | res: Response, 5 | statusCode: number = 200, 6 | data: Record | unknown | string | null, 7 | message: string 8 | ) { 9 | res.status(statusCode).json({ 10 | status: 'success', 11 | data: data ?? null, 12 | message: message ?? 'Success', 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/common/utils/authenticate.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | import type { IUser } from '@/common/interfaces'; 3 | import { UserModel } from '@/models'; 4 | import jwt from 'jsonwebtoken'; 5 | import { DateTime } from 'luxon'; 6 | import { Require_id } from 'mongoose'; 7 | import AppError from './appError'; 8 | import { decodeData, getFromCache, hashData, setCache } from './helper'; 9 | 10 | type AuthenticateResult = { 11 | currentUser: Require_id; 12 | accessToken?: string; 13 | }; 14 | 15 | export const authenticate = async ({ 16 | abegAccessToken, 17 | abegRefreshToken, 18 | }: { 19 | abegAccessToken?: string; 20 | abegRefreshToken?: string; 21 | }): Promise => { 22 | if (!abegRefreshToken) { 23 | throw new AppError('Unauthorized', 401); 24 | } 25 | 26 | // verify user access 27 | const handleUserVerification = async (decoded) => { 28 | // fetch user from redis cache or db 29 | const cachedUser = await getFromCache>(decoded.id); 30 | 31 | const user = cachedUser 32 | ? cachedUser 33 | : ((await UserModel.findOne({ _id: decoded.id }).select( 34 | 'refreshToken isSuspended isEmailVerified' 35 | )) as Require_id); 36 | 37 | if (!cachedUser && user) { 38 | await setCache(decoded.id, user); 39 | } 40 | 41 | // check if refresh token matches the stored refresh token in db 42 | // in case the user has logged out and the token is still valid 43 | // or the user has re authenticated and the token is still valid etc 44 | 45 | if (user.refreshToken !== abegRefreshToken) { 46 | throw new AppError('Invalid token. Please log in again!', 401); 47 | } 48 | 49 | if (user.isSuspended) { 50 | throw new AppError('Your account is currently suspended', 401); 51 | } 52 | 53 | if (!user.isEmailVerified) { 54 | throw new AppError('Your email is yet to be verified', 422, `email-unverified:${user.email}`); 55 | } 56 | // check if user has changed password after the token was issued 57 | // if so, invalidate the token 58 | if ( 59 | user.passwordChangedAt && 60 | DateTime.fromISO(user.passwordChangedAt.toISOString()).toMillis() > DateTime.fromMillis(decoded.iat).toMillis() 61 | ) { 62 | throw new AppError('Password changed since last login. Please log in again!', 401); 63 | } 64 | 65 | // csrf protection 66 | // browser client fingerprinting 67 | 68 | return user; 69 | }; 70 | 71 | const handleTokenRefresh = async () => { 72 | try { 73 | const decodeRefreshToken = await decodeData(abegRefreshToken, ENVIRONMENT.JWT.REFRESH_KEY!); 74 | 75 | const currentUser = await handleUserVerification(decodeRefreshToken); 76 | 77 | // generate access tokens 78 | const accessToken = await hashData( 79 | { id: currentUser._id.toString() }, 80 | { expiresIn: ENVIRONMENT.JWT_EXPIRES_IN.ACCESS } 81 | ); 82 | 83 | return { 84 | accessToken, 85 | currentUser, 86 | }; 87 | } catch (error) { 88 | console.log(error); 89 | throw new AppError('Session expired, please log in again', 401); 90 | } 91 | }; 92 | 93 | try { 94 | if (!abegAccessToken) { 95 | // if access token is not present, verify the refresh token and generate a new access token 96 | return await handleTokenRefresh(); 97 | } else { 98 | const decodeAccessToken = await decodeData(abegAccessToken, ENVIRONMENT.JWT.ACCESS_KEY!); 99 | const currentUser = await handleUserVerification(decodeAccessToken); 100 | 101 | // attach the user to the request object 102 | return { currentUser }; 103 | } 104 | } catch (error) { 105 | if ((error instanceof jwt.JsonWebTokenError || error instanceof jwt.TokenExpiredError) && abegRefreshToken) { 106 | // verify the refresh token and generate a new access token 107 | return await handleTokenRefresh(); 108 | } else { 109 | throw new AppError('An error occurred, please log in again', 401); 110 | } 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /src/common/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios'; 2 | 3 | export const axiosHandleError = (error: AxiosError) => { 4 | const { request, response } = error; 5 | 6 | let data = {}; 7 | if (response) { 8 | data = { 9 | status: response.status, 10 | headers: response.headers, 11 | response: response.data || null, 12 | }; 13 | 14 | if (response.status === 404) return data; 15 | 16 | return data; 17 | } else if (request) { 18 | return { 19 | ...data, 20 | ...error.request, 21 | message: 'Check internet connection', 22 | }; 23 | } else { 24 | return { ...data, message: error.message }; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/common/utils/index.ts: -------------------------------------------------------------------------------- 1 | /////////////////////////////////////////////////////////////////////// 2 | // LOGGER MUST BE THE FIRST IMPORT 3 | export * from './logger'; 4 | /////////////////////////////////////////////////////////////////////// 5 | 6 | export { default as AppError } from './appError'; 7 | export * from './appResponse'; 8 | export * from './authenticate'; 9 | export * from './upload'; 10 | export * from './helper'; 11 | export { default as QueryHandler } from './queryHandler'; 12 | export * from './initialAuthData'; 13 | -------------------------------------------------------------------------------- /src/common/utils/initialAuthData.ts: -------------------------------------------------------------------------------- 1 | import { campaignModel } from '@/models'; 2 | import mongoose from 'mongoose'; 3 | 4 | export const fetchInitialData = async (userId: mongoose.Types.ObjectId) => { 5 | console.log(userId); 6 | const campaigns = await campaignModel.find({ creator: userId }).sort({ createdAt: -1 }).limit(10); 7 | 8 | console.log(campaigns); 9 | 10 | return { 11 | campaigns, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/common/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | 3 | export const logger = winston.createLogger({ 4 | level: 'info', 5 | format: winston.format.combine( 6 | winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 7 | winston.format.printf(({ level, message, timestamp }) => { 8 | const logEntry = `${timestamp} ${level}: ${message}`; 9 | return logEntry.replace(/\u001b\[0m/g, ''); 10 | }) 11 | ), 12 | transports: [ 13 | new winston.transports.Console(), 14 | new winston.transports.File({ filename: 'logs/info.log', level: 'info' }), 15 | ], 16 | }); 17 | 18 | export const stream = { 19 | write: (message: string) => { 20 | logger.info(message.trim()); 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/common/utils/payment_services/paystack.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import { ENVIRONMENT } from '../../config'; 3 | import { IInitializeTransaction } from '../../interfaces'; 4 | import { axiosHandleError } from '../axios'; 5 | 6 | if (!ENVIRONMENT.PAYSTACK.HOST || !ENVIRONMENT.PAYSTACK.SECRET_KEY) { 7 | throw new Error('PAYSTACK HOST or SECRET_KEY is not set'); 8 | } 9 | 10 | const paystackInstance = axios.create({ 11 | baseURL: ENVIRONMENT.PAYSTACK.HOST, 12 | timeout: 1000 * 60 * 2, 13 | headers: { 14 | Accept: 'application/json', 15 | 'Content-Type': 'application/json', 16 | Authorization: `Bearer ${ENVIRONMENT.PAYSTACK.SECRET_KEY}`, 17 | }, 18 | }); 19 | 20 | export const initializeTransaction = async (data: IInitializeTransaction) => { 21 | try { 22 | const response = await paystackInstance.post('/transaction/initialize', data); 23 | 24 | return { 25 | success: true, 26 | data: response?.data?.data, 27 | message: response?.data?.message, 28 | }; 29 | } catch (error) { 30 | const err = error as AxiosError; 31 | const { response } = axiosHandleError(err); 32 | 33 | return { 34 | success: false, 35 | data: response?.data ?? null, 36 | message: 'Error fetching banks', 37 | }; 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/common/utils/queryHandler.ts: -------------------------------------------------------------------------------- 1 | import { Query, FilterQuery } from 'mongoose'; 2 | import { ParsedQs } from 'qs'; 3 | 4 | interface QueryString { 5 | page?: string; 6 | sort?: string; 7 | limit?: string; 8 | fields?: string; 9 | [key: string]: string | undefined; 10 | } 11 | 12 | export default class QueryHandler { 13 | private query: Query; 14 | private queryString: QueryString; 15 | private excludedFields: string[]; 16 | 17 | constructor( 18 | query: Query, 19 | queryString: ParsedQs, 20 | excludedFields: string[] = ['page', 'sort', 'limit', 'fields'] 21 | ) { 22 | this.query = query; 23 | this.queryString = Object.fromEntries(Object.entries(queryString).map(([key, value]) => [key, String(value)])); 24 | this.excludedFields = excludedFields; 25 | } 26 | filter(): QueryHandler { 27 | const queryObj: Record = { ...(this.queryString as QueryString) }; 28 | this.excludedFields.forEach((el) => delete queryObj[el]); 29 | 30 | // Create a new object to hold the parsed query parameters 31 | const parsedQueryObj: Record = {}; 32 | 33 | // Parse query parameters 34 | for (const key in queryObj) { 35 | const keyValue = queryObj[key]; 36 | if (typeof keyValue === 'string') { 37 | if (keyValue === 'true') { 38 | parsedQueryObj[key] = true; 39 | } else if (keyValue === 'false') { 40 | parsedQueryObj[key] = false; 41 | } else if (keyValue.includes(':')) { 42 | const [operator, value] = keyValue.split(':'); 43 | 44 | // Convert operator to MongoDB operator 45 | const mongoOperator = `$${operator}`; 46 | 47 | // Parse value as a number if it's numeric, otherwise leave it as a string 48 | const parsedValue = isNaN(Number(value)) ? value : Number(value); 49 | 50 | // If the key already exists in parsedQueryObj, add the new operator to it, otherwise create a new object 51 | if (typeof parsedQueryObj[key] === 'object' && parsedQueryObj[key] !== null) { 52 | (parsedQueryObj[key] as Record)[mongoOperator] = parsedValue; 53 | } else { 54 | parsedQueryObj[key] = { [mongoOperator]: parsedValue }; 55 | } 56 | } else { 57 | // For other fields, use the key and value directly for the filter 58 | parsedQueryObj[key] = keyValue; 59 | } 60 | } 61 | } 62 | 63 | this.query = this.query.find(parsedQueryObj as FilterQuery); 64 | return this; 65 | } 66 | sort(defaultSort: string = 'updatedAt'): QueryHandler { 67 | const sortBy = this.queryString.sort ? this.queryString.sort.split(',').join(' ') : defaultSort; 68 | this.query = this.query.sort(sortBy); 69 | 70 | return this; 71 | } 72 | 73 | limitFields(defaultField: string = '-__v'): QueryHandler { 74 | const fields = this.queryString.fields ? this.queryString.fields.split(',').join(' ') : defaultField; 75 | this.query = this.query.select(fields); 76 | 77 | return this; 78 | } 79 | 80 | paginate(defaultPage: number = 1, defaultLimit: number = 10): QueryHandler { 81 | const page = parseInt(this.queryString.page || '') || defaultPage; 82 | const parsedLimit = parseInt(this.queryString.limit || ''); 83 | 84 | const limit = parsedLimit > 100 ? defaultLimit : parsedLimit || defaultLimit; 85 | 86 | const skip = (page - 1) * limit; 87 | 88 | this.query = this.query.skip(skip).limit(limit); 89 | 90 | return this; 91 | } 92 | 93 | async execute(): Promise { 94 | return await this.query; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/common/utils/upload.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | import type { IAwsUploadFile } from '@/common/interfaces'; 3 | import AppError from './appError'; 4 | import { isValidFileNameAwsUpload } from './helper'; 5 | import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; 6 | import sharp from 'sharp'; 7 | import { encode } from 'blurhash'; 8 | 9 | if ( 10 | !ENVIRONMENT.R2.ACCOUNT_ID || 11 | !ENVIRONMENT.R2.REGION || 12 | !ENVIRONMENT.R2.ACCESS_KEY_ID || 13 | !ENVIRONMENT.R2.SECRET_ACCESS_KEY || 14 | !ENVIRONMENT.R2.BUCKET_NAME || 15 | !ENVIRONMENT.R2.CDN_URL 16 | ) { 17 | throw new Error('R2 environment variables are not set'); 18 | } 19 | 20 | // S3 client configuration 21 | export const r2 = new S3Client({ 22 | region: ENVIRONMENT.R2.REGION, 23 | endpoint: `https://${ENVIRONMENT.R2.ACCOUNT_ID}.r2.cloudflarestorage.com`, 24 | credentials: { 25 | accessKeyId: ENVIRONMENT.R2.ACCESS_KEY_ID, 26 | secretAccessKey: ENVIRONMENT.R2.SECRET_ACCESS_KEY, 27 | }, 28 | }); 29 | 30 | //create image hash 31 | const encodeImageToHash = (path) => 32 | new Promise((resolve, reject) => { 33 | sharp(path) 34 | .raw() 35 | .ensureAlpha() 36 | .resize(50, 50, { fit: 'inside' }) 37 | .toBuffer((err, buffer, { width, height }) => { 38 | if (err) return reject(err); 39 | resolve(encode(new Uint8ClampedArray(buffer), width, height, 4, 4)); 40 | }); 41 | }); 42 | 43 | export const uploadSingleFile = async (payload: IAwsUploadFile): Promise<{ secureUrl: string; blurHash?: string }> => { 44 | const { fileName, buffer, mimetype } = payload; 45 | 46 | if (!fileName || !buffer || !mimetype) { 47 | throw new AppError('File name, buffer and mimetype are required', 400); 48 | } 49 | 50 | if (fileName && !isValidFileNameAwsUpload(fileName)) { 51 | throw new AppError('Invalid file name', 400); 52 | } 53 | 54 | let bufferFile = buffer; 55 | 56 | if (mimetype.includes('image')) { 57 | bufferFile = await sharp(buffer) 58 | .resize({ 59 | height: 1920, 60 | width: 1080, 61 | fit: 'contain', 62 | }) 63 | .toBuffer(); 64 | } 65 | 66 | const uploadParams = { 67 | Bucket: ENVIRONMENT.R2.BUCKET_NAME, 68 | Key: fileName, 69 | Body: bufferFile, 70 | ContentType: mimetype, 71 | }; 72 | 73 | try { 74 | const command = new PutObjectCommand(uploadParams); 75 | await r2.send(command); 76 | const secureUrl = `${ENVIRONMENT.R2.CDN_URL}/${fileName}`; 77 | 78 | const hash = mimetype.includes('image') ? ((await encodeImageToHash(buffer)) as string) : ''; 79 | 80 | console.log({ 81 | secureUrl, 82 | hash, 83 | }); 84 | 85 | return { 86 | secureUrl: secureUrl, 87 | blurHash: hash, 88 | }; 89 | } catch (error) { 90 | console.log(error); 91 | return { 92 | secureUrl: '', 93 | blurHash: '', 94 | }; 95 | } 96 | }; 97 | -------------------------------------------------------------------------------- /src/controllers/auth/complete2faSetup.ts: -------------------------------------------------------------------------------- 1 | import { twoFactorTypeEnum } from '@/common/constants'; 2 | import { 3 | AppError, 4 | AppResponse, 5 | decodeData, 6 | generateRandom6DigitKey, 7 | getFromCache, 8 | hashData, 9 | setCache, 10 | toJSON, 11 | validateTimeBased2fa, 12 | } from '@/common/utils'; 13 | import { catchAsync } from '@/middlewares'; 14 | import { UserModel } from '@/models'; 15 | import { addEmailToQueue } from '@/queues'; 16 | import { Request, Response } from 'express'; 17 | 18 | export const complete2faSetup = catchAsync(async (req: Request, res: Response) => { 19 | const { user } = req; 20 | const { token, twoFactorType } = req.body; 21 | 22 | if (!token || !twoFactorType) { 23 | throw new AppError('Token and Type is required', 400); 24 | } 25 | 26 | if (!user) { 27 | throw new AppError('Unauthorized, kindly login again.'); 28 | } 29 | 30 | if (user?.twoFA?.active) { 31 | throw new AppError('2FA is already active', 400); 32 | } 33 | 34 | if (twoFactorType === twoFactorTypeEnum.APP) { 35 | const decryptedSecret = await decodeData(user.twoFA.secret as string); 36 | 37 | if (!decryptedSecret.token) { 38 | throw new AppError('Unable to complete 2FA, please try again', 400); 39 | } 40 | 41 | const isTokenValid = validateTimeBased2fa(decryptedSecret.token, token, 1); 42 | 43 | if (!isTokenValid) { 44 | throw new AppError('Invalid token', 400); 45 | } 46 | } 47 | 48 | if (twoFactorType === twoFactorTypeEnum.EMAIL) { 49 | const emailCode = await getFromCache(`2FAEmailCode:${user?._id.toString()}`); 50 | 51 | if (!emailCode) { 52 | throw new AppError('Invalid token', 400); 53 | } 54 | 55 | const decodedData = await decodeData(Object(emailCode).token); 56 | 57 | if (!decodedData.token || decodedData.token !== token) { 58 | throw new AppError('Invalid verification code', 400); 59 | } 60 | } 61 | 62 | let recoveryCode: string = ''; 63 | 64 | for (let i = 0; i < 6; i++) { 65 | recoveryCode += i == 5 ? `${generateRandom6DigitKey()}` : `${generateRandom6DigitKey()} `; 66 | } 67 | 68 | const hashedRecoveryCode = hashData({ token: recoveryCode }, { expiresIn: 0 }); 69 | 70 | await UserModel.findByIdAndUpdate(user?._id, { 71 | 'twoFA.active': true, 72 | 'twoFA.recoveryCode': hashedRecoveryCode, 73 | }); 74 | 75 | // added the email to queue 76 | await addEmailToQueue({ 77 | type: 'recoveryKeysEmail', 78 | data: { 79 | to: user.email, 80 | name: user.firstName, 81 | recoveryCode: recoveryCode, 82 | }, 83 | }); 84 | 85 | // update cache 86 | await setCache(user._id.toString()!, toJSON({ ...user, twoFA: { active: true } }, [])); 87 | 88 | return AppResponse(res, 200, { recoveryCode }, '2FA enabled successfully'); 89 | }); 90 | -------------------------------------------------------------------------------- /src/controllers/auth/disable2fa.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, decodeData, removeFromCache } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { UserModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | 6 | export const disable2fa = catchAsync(async (req: Request, res: Response) => { 7 | const { user } = req; 8 | const { token } = req.body; 9 | 10 | if (!token) { 11 | throw new AppError('Token is required', 400); 12 | } 13 | 14 | const userFromDb = await UserModel.findOne({ email: user?.email }).select('+twoFA.recoveryCode'); 15 | 16 | if (!userFromDb) { 17 | throw new AppError('User not found with provided email', 404); 18 | } 19 | 20 | let decodedRecoveryCode: Record; 21 | try { 22 | decodedRecoveryCode = await decodeData(userFromDb.twoFA.recoveryCode!); 23 | } catch (e) { 24 | throw new AppError('Invalid recovery token', 400); 25 | } 26 | 27 | const trimmedToken = await token 28 | .replace(/\s/g, '') 29 | .replace(/(\d{6})/g, '$1 ') 30 | .trim(); 31 | 32 | if (!decodedRecoveryCode.token || decodedRecoveryCode.token !== trimmedToken) { 33 | throw new AppError('Invalid recovery code', 400); 34 | } 35 | 36 | await UserModel.findByIdAndUpdate(userFromDb._id, { 37 | $unset: { 38 | twoFA: 1, 39 | }, 40 | }); 41 | 42 | await removeFromCache(userFromDb._id.toString()); 43 | 44 | return AppResponse(res, 200, null, '2fa disabled successfully'); 45 | }); 46 | -------------------------------------------------------------------------------- /src/controllers/auth/forgotPassword.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, generateRandomString, getDomainReferer, hashData } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { UserModel } from '@/models'; 4 | import { addEmailToQueue } from '@/queues'; 5 | import { Request, Response } from 'express'; 6 | import { DateTime } from 'luxon'; 7 | 8 | export const forgotPassword = catchAsync(async (req: Request, res: Response) => { 9 | const { email } = req.body; 10 | 11 | if (!email) { 12 | throw new AppError('Email is required', 400); 13 | } 14 | 15 | const user = await UserModel.findOne({ email }).select( 16 | '+passwordResetToken +passwordResetExpires +passwordResetRetries' 17 | ); 18 | 19 | if (!user) { 20 | throw new AppError('No user found with provided email', 404); 21 | } 22 | 23 | if (user.passwordResetRetries >= 3) { 24 | await UserModel.findByIdAndUpdate(user._id, { 25 | isSuspended: true, 26 | }); 27 | 28 | throw new AppError('Password reset retries exceeded! and account suspended', 401); 29 | } 30 | 31 | const passwordResetToken = await generateRandomString(); 32 | const hashedPasswordResetToken = hashData({ 33 | token: passwordResetToken, 34 | }); 35 | 36 | const passwordResetUrl = `${getDomainReferer(req)}/reset-password?token=${hashedPasswordResetToken}`; 37 | 38 | await UserModel.findByIdAndUpdate(user._id, { 39 | passwordResetToken: passwordResetToken, 40 | passwordResetExpires: DateTime.now().plus({ minutes: 15 }).toJSDate(), 41 | $inc: { 42 | passwordResetRetries: 1, 43 | }, 44 | }); 45 | 46 | // add email to queue 47 | addEmailToQueue({ 48 | type: 'forgotPassword', 49 | data: { 50 | to: email, 51 | priority: 'high', 52 | name: user.firstName, 53 | token: passwordResetUrl, 54 | }, 55 | }); 56 | 57 | return AppResponse(res, 200, null, `Password reset link sent to ${email}`); 58 | }); 59 | -------------------------------------------------------------------------------- /src/controllers/auth/get2faCodeViaEmail.ts: -------------------------------------------------------------------------------- 1 | import { twoFactorTypeEnum } from '@/common/constants'; 2 | import { AppError, AppResponse, get2faCodeViaEmailHelper } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { Request, Response } from 'express'; 5 | 6 | export const get2faCodeViaEmail = catchAsync(async (req: Request, res: Response) => { 7 | const { user } = req; 8 | 9 | if (!user) { 10 | throw new AppError('Unauthorized'); 11 | } 12 | 13 | if (user.twoFA.type !== twoFactorTypeEnum.EMAIL) { 14 | throw new AppError('Sorry, this action is only allowed for users with email-based two-factor authentication.', 400); 15 | } 16 | 17 | await get2faCodeViaEmailHelper(user.email); 18 | 19 | return AppResponse(res, 200, null, 'Code sent to email successfully'); 20 | }); 21 | -------------------------------------------------------------------------------- /src/controllers/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './complete2faSetup'; 2 | export * from './disable2fa'; 3 | export * from './forgotPassword'; 4 | export * from './get2faCodeViaEmail'; 5 | export * from './resendVerification'; 6 | export * from './resetPassword'; 7 | export * from './session'; 8 | export * from './setup2fa'; 9 | export * from './signin'; 10 | export * from './signout'; 11 | export * from './signup'; 12 | export * from './verify2fa'; 13 | export * from './verifyEmail'; 14 | -------------------------------------------------------------------------------- /src/controllers/auth/resendVerification.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, sendVerificationEmail } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { UserModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | 6 | export const resendVerification = catchAsync(async (req: Request, res: Response) => { 7 | const { email } = req.body; 8 | 9 | if (!email) { 10 | throw new AppError('Email is required', 400); 11 | } 12 | 13 | const user = await UserModel.findOne({ email }); 14 | 15 | if (!user) { 16 | throw new AppError('No user found with provided email', 404); 17 | } 18 | 19 | if (user.isEmailVerified) { 20 | throw new AppError('Email already verified', 400); 21 | } 22 | await sendVerificationEmail(user, req); 23 | 24 | return AppResponse(res, 200, null, `Verification link sent to ${email}`); 25 | }); 26 | -------------------------------------------------------------------------------- /src/controllers/auth/resetPassword.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, decodeData, hashPassword, removeFromCache } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { UserModel } from '@/models'; 4 | import { addEmailToQueue } from '@/queues'; 5 | import { Request, Response } from 'express'; 6 | import { DateTime } from 'luxon'; 7 | 8 | export const resetPassword = catchAsync(async (req: Request, res: Response) => { 9 | const { token, password, confirmPassword } = req.body; 10 | 11 | if (!token || !password || !confirmPassword) { 12 | throw new AppError('All fields are required', 400); 13 | } 14 | 15 | if (password !== confirmPassword) { 16 | throw new AppError('Passwords do not match', 400); 17 | } 18 | 19 | const decodedToken = await decodeData(token); 20 | 21 | if (!decodedToken.token) { 22 | throw new AppError('Invalid token', 400); 23 | } 24 | 25 | const user = await UserModel.findOne({ 26 | passwordResetToken: decodedToken.token, 27 | passwordResetExpires: { 28 | $gt: DateTime.now().toJSDate(), 29 | }, 30 | isSuspended: false, 31 | }); 32 | 33 | if (!user) { 34 | throw new AppError('Password reset token is invalid or has expired', 400); 35 | } 36 | 37 | const hashedPassword = await hashPassword(password); 38 | 39 | const updatedUser = await UserModel.findByIdAndUpdate( 40 | user._id, 41 | { 42 | password: hashedPassword, 43 | passwordResetRetries: 0, 44 | passwordChangedAt: DateTime.now().toJSDate(), 45 | $unset: { 46 | passwordResetToken: 1, 47 | passwordResetExpires: 1, 48 | }, 49 | }, 50 | { 51 | runValidators: true, 52 | new: true, 53 | } 54 | ); 55 | 56 | if (!updatedUser) { 57 | throw new AppError('Password reset failed', 400); 58 | } 59 | 60 | // send password reset complete email 61 | addEmailToQueue({ 62 | type: 'resetPassword', 63 | data: { 64 | to: user.email, 65 | priority: 'high', 66 | }, 67 | }); 68 | 69 | // update the cache 70 | await removeFromCache(updatedUser._id.toString()); 71 | 72 | return AppResponse(res, 200, null, 'Password reset successfully'); 73 | }); 74 | -------------------------------------------------------------------------------- /src/controllers/auth/session.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, fetchInitialData, toJSON } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { Request, Response } from 'express'; 4 | 5 | export const session = catchAsync(async (req: Request, res: Response) => { 6 | const currentUser = req.user; 7 | if (!currentUser) { 8 | throw new AppError('Unauthenticated', 401); 9 | } 10 | 11 | const initialData = await fetchInitialData(currentUser._id); 12 | return AppResponse(res, 200, { ...initialData, user: toJSON(currentUser) }, 'Authenticated'); 13 | }); 14 | -------------------------------------------------------------------------------- /src/controllers/auth/setup2fa.ts: -------------------------------------------------------------------------------- 1 | import { twoFactorTypeEnum } from '@/common/constants'; 2 | import { 3 | AppError, 4 | AppResponse, 5 | generateRandomBase32, 6 | generateTimeBased2fa, 7 | get2faCodeViaEmailHelper, 8 | hashData, 9 | setCache, 10 | toJSON, 11 | } from '@/common/utils'; 12 | import { catchAsync } from '@/middlewares'; 13 | import { UserModel } from '@/models'; 14 | import { Request, Response } from 'express'; 15 | 16 | export const setupTimeBased2fa = catchAsync(async (req: Request, res: Response) => { 17 | const { user } = req; 18 | const { twoFactorType } = req.body; 19 | 20 | if (!twoFactorType) { 21 | throw new AppError('Invalid Request', 400); 22 | } 23 | 24 | if (!user) { 25 | throw new AppError('Unauthorized', 401); 26 | } 27 | 28 | if (user?.twoFA && user.twoFA.active === true) { 29 | throw new AppError('2FA is already active', 400); 30 | } 31 | 32 | if (twoFactorType === twoFactorTypeEnum.EMAIL) { 33 | await get2faCodeViaEmailHelper(user.email); 34 | await UserModel.findByIdAndUpdate(user?._id, { 35 | twoFA: { 36 | type: twoFactorTypeEnum.EMAIL, 37 | }, 38 | }); 39 | 40 | return AppResponse(res, 200, null, 'OTP code sent to email successfully'); 41 | } 42 | 43 | if (twoFactorType === twoFactorTypeEnum.APP) { 44 | const secret = generateRandomBase32(); 45 | const qrCode = await generateTimeBased2fa(secret); 46 | const hashedSecret = hashData({ token: secret }, { expiresIn: 0 }); 47 | 48 | await UserModel.findByIdAndUpdate(user?._id, { 49 | twoFA: { 50 | secret: hashedSecret, 51 | type: twoFactorTypeEnum.APP, 52 | }, 53 | }); 54 | 55 | await setCache( 56 | user._id.toString()!, 57 | toJSON({ ...user, twoFA: { secret: hashedSecret, active: false, type: twoFactorTypeEnum.APP } }, []) 58 | ); 59 | 60 | return AppResponse( 61 | res, 62 | 200, 63 | { 64 | secret, 65 | qrCode, 66 | }, 67 | 'Created 2FA successfully' 68 | ); 69 | } 70 | }); 71 | -------------------------------------------------------------------------------- /src/controllers/auth/signin.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | import { Provider } from '@/common/constants'; 3 | import { ILocation, IUser } from '@/common/interfaces'; 4 | import { 5 | AppError, 6 | AppResponse, 7 | extractUAData, 8 | fetchInitialData, 9 | hashData, 10 | sendVerificationEmail, 11 | setCache, 12 | setCookie, 13 | toJSON, 14 | } from '@/common/utils'; 15 | import { catchAsync } from '@/middlewares'; 16 | import { UserModel } from '@/models'; 17 | import { locationModel } from '@/models/LocationModel'; 18 | import { addEmailToQueue } from '@/queues'; 19 | import type { Request, Response } from 'express'; 20 | import { DateTime } from 'luxon'; 21 | 22 | export const signIn = catchAsync(async (req: Request, res: Response) => { 23 | const { email, password } = req.body; 24 | if (!email || !password) { 25 | throw new AppError('Email and password are required fields', 401); 26 | } 27 | 28 | const user = await UserModel.findOne({ email, provider: Provider.Local }).select( 29 | '+refreshToken +loginRetries +isSuspended +isEmailVerified +lastLogin +password +twoFA.type +twoFA.active' 30 | ); 31 | 32 | if (!user) { 33 | throw new AppError('Email or password is incorrect', 401); 34 | } 35 | 36 | // check if user has exceeded login retries (3 times in 12 hours) 37 | const currentRequestTime = DateTime.now(); 38 | const lastLoginRetry = currentRequestTime.diff(DateTime.fromISO(user.lastLogin.toISOString()), 'hours'); 39 | 40 | if (user.loginRetries >= 3 && Math.round(lastLoginRetry.hours) < 12) { 41 | throw new AppError('login retries exceeded!', 401); 42 | // send an email to user to reset password 43 | } 44 | 45 | const isPasswordValid = await user.verifyPassword(password); 46 | if (!isPasswordValid) { 47 | await UserModel.findByIdAndUpdate(user._id, { 48 | $inc: { loginRetries: 1 }, 49 | }); 50 | throw new AppError('Email or password is incorrect', 401); 51 | } 52 | 53 | if (!user.isEmailVerified) { 54 | await sendVerificationEmail(user, req); 55 | // do not change status code from 422 as it will break frontend logic 56 | // 422 helps them handle redirection to email verification page 57 | throw new AppError('Your email is yet to be verified', 422, `email-unverified:${user.email}`); 58 | } 59 | 60 | if (user.isSuspended) { 61 | throw new AppError('Your account is currently suspended', 401); 62 | } 63 | 64 | // generate access and refresh tokens and set cookies 65 | const accessToken = await hashData({ id: user._id.toString() }, { expiresIn: ENVIRONMENT.JWT_EXPIRES_IN.ACCESS }); 66 | setCookie(res, 'abegAccessToken', accessToken, { 67 | maxAge: 15 * 60 * 1000, // 15 minutes 68 | }); 69 | 70 | const refreshToken = await hashData( 71 | { id: user._id.toString() }, 72 | { expiresIn: ENVIRONMENT.JWT_EXPIRES_IN.REFRESH }, 73 | ENVIRONMENT.JWT.REFRESH_KEY 74 | ); 75 | setCookie(res, 'abegRefreshToken', refreshToken, { 76 | maxAge: 24 * 60 * 60 * 1000, // 24 hours 77 | }); 78 | 79 | // update user loginRetries to 0 and lastLogin to current time 80 | const updatedUser = (await UserModel.findByIdAndUpdate( 81 | user._id, 82 | { 83 | loginRetries: 0, 84 | lastLogin: DateTime.now(), 85 | refreshToken, 86 | ...(user.twoFA.active && { 'twoFA.isVerified': false }), 87 | }, 88 | { new: true } 89 | )) as IUser; 90 | 91 | const userAgent: Partial = await extractUAData(req); 92 | 93 | // create an entry for login location metadata 94 | await locationModel.create({ 95 | ...userAgent, 96 | user: user._id, 97 | }); 98 | 99 | await setCache(user._id.toString(), { ...toJSON(updatedUser, ['password']), refreshToken }); 100 | 101 | if (user.twoFA.active) { 102 | return AppResponse( 103 | res, 104 | 200, 105 | { 106 | user: { 107 | twoFA: { 108 | type: user.twoFA.type, 109 | active: user.twoFA.active, 110 | }, 111 | }, 112 | campaigns: [], 113 | }, 114 | 'Sign in successfully, proceed to 2fa verification' 115 | ); 116 | } else { 117 | const lastLoginMeta = await locationModel.findOne({ user: user._id }).sort({ createdAt: -1 }); 118 | // send login notification email 119 | if (lastLoginMeta?.country !== userAgent.country || lastLoginMeta?.city !== userAgent.city) { 120 | await addEmailToQueue({ 121 | type: 'loginNotification', 122 | data: { 123 | to: user.email, 124 | name: user.firstName, 125 | ipv4: userAgent.ipv4, 126 | os: userAgent.os, 127 | country: userAgent.country, 128 | city: userAgent.city, 129 | timezone: userAgent.timezone, 130 | }, 131 | }); 132 | } 133 | 134 | const initialData = await fetchInitialData(user._id); 135 | return AppResponse(res, 200, { initialData, user: toJSON(updatedUser) }, 'Sign in successful'); 136 | } 137 | }); 138 | -------------------------------------------------------------------------------- /src/controllers/auth/signout.ts: -------------------------------------------------------------------------------- 1 | import { removeFromCache, setCookie } from '@/common/utils'; 2 | import AppError from '@/common/utils/appError'; 3 | import { AppResponse } from '@/common/utils/appResponse'; 4 | import { catchAsync } from '@/middlewares'; 5 | import { UserModel } from '@/models'; 6 | import { Request, Response } from 'express'; 7 | 8 | export const signOut = catchAsync(async (req: Request, res: Response) => { 9 | const { user } = req; 10 | 11 | if (!user) { 12 | throw new AppError('You are not logged in', 404); 13 | } 14 | 15 | await removeFromCache(user._id.toString()); 16 | //$unset the refreshToken from the mongodb 17 | await UserModel.findByIdAndUpdate(user._id, { $unset: { refreshToken: 1 } }); 18 | 19 | //clearing the cookies set on the frontend by setting a new cookie with empty values and an expiry time in the past 20 | setCookie(res, 'abegAccessToken', 'expired', { maxAge: -1 }); 21 | setCookie(res, 'abegRefreshToken', 'expired', { maxAge: -1 }); 22 | 23 | AppResponse(res, 200, null, 'Logout successful'); 24 | }); 25 | -------------------------------------------------------------------------------- /src/controllers/auth/signup.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from '@/common/constants'; 2 | import { AppError, AppResponse, hashPassword, sendVerificationEmail, setCache, toJSON } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { UserModel } from '@/models'; 5 | import { Request, Response } from 'express'; 6 | 7 | export const signUp = catchAsync(async (req: Request, res: Response) => { 8 | const { email, firstName, lastName, password, isTermAndConditionAccepted } = req.body; 9 | 10 | if (!email || !firstName || !lastName || !password) { 11 | throw new AppError('Incomplete signup data', 400); 12 | } 13 | 14 | if (!isTermAndConditionAccepted) { 15 | throw new AppError('Kindly accept terms and conditions to sign up', 400); 16 | } 17 | 18 | const existingUser = await UserModel.findOne({ email }); 19 | if (existingUser) { 20 | throw new AppError(`User with email already exists`, 409); 21 | } 22 | 23 | const hashedPassword = await hashPassword(password); 24 | 25 | const user = await UserModel.create({ 26 | email, 27 | firstName, 28 | lastName, 29 | password: hashedPassword, 30 | provider: Provider.Local, 31 | isTermAndConditionAccepted, 32 | }); 33 | 34 | await sendVerificationEmail(user, req); 35 | 36 | // save user to cache without password 37 | await setCache(user._id.toString(), toJSON(user, ['password'])); 38 | AppResponse(res, 201, toJSON(user), 'Account created successfully'); 39 | }); 40 | -------------------------------------------------------------------------------- /src/controllers/auth/verify2fa.ts: -------------------------------------------------------------------------------- 1 | import { twoFactorTypeEnum } from '@/common/constants'; 2 | import { IUser } from '@/common/interfaces'; 3 | import { 4 | AppError, 5 | AppResponse, 6 | decodeData, 7 | getFromCache, 8 | removeFromCache, 9 | setCache, 10 | validateTimeBased2fa, 11 | toJSON, 12 | fetchInitialData, 13 | } from '@/common/utils'; 14 | import { catchAsync } from '@/middlewares'; 15 | import { UserModel } from '@/models'; 16 | import { Request, Response } from 'express'; 17 | import { DateTime } from 'luxon'; 18 | 19 | export const verifyTimeBased2fa = catchAsync(async (req: Request, res: Response) => { 20 | const { user } = req; 21 | 22 | const { token } = req.body; 23 | 24 | const userFromDb = await UserModel.findOne({ email: user?.email }).select( 25 | '+twoFA.secret +twoFA.recoveryCode +lastLogin' 26 | ); 27 | 28 | if (!user || !userFromDb || !userFromDb?.lastLogin || !userFromDb.twoFA.recoveryCode) { 29 | throw new AppError('Unable to complete request, try again later', 404); 30 | } 31 | 32 | if (!userFromDb.twoFA.active) { 33 | throw new AppError('2FA is not active', 400); 34 | } 35 | 36 | // TODO: add limit to tries to avoid brute force 37 | 38 | const lastLoginTimeInMilliseconds = new Date(userFromDb.lastLogin).getTime(); 39 | const currentTimeInMilliseconds = DateTime.now().plus({ minutes: 5 }).toJSDate().getTime(); 40 | 41 | if (lastLoginTimeInMilliseconds > currentTimeInMilliseconds) { 42 | throw new AppError('Timeout Error, Please log in again', 400); 43 | } 44 | 45 | const twoFAType = userFromDb.twoFA.type; 46 | 47 | if (twoFAType === twoFactorTypeEnum.APP) { 48 | const decryptedSecret = await decodeData(userFromDb.twoFA.secret!); 49 | const isTokenValid = validateTimeBased2fa(decryptedSecret.token, token, 1); 50 | 51 | if (!isTokenValid) { 52 | throw new AppError('Invalid token', 400); 53 | } 54 | } 55 | 56 | if (twoFAType === twoFactorTypeEnum.EMAIL) { 57 | const emailCode = await getFromCache(`2FAEmailCode:${user._id.toString()}`); 58 | 59 | if (!emailCode) { 60 | throw new AppError('Invalid verification code', 400); 61 | } 62 | 63 | const decodedData = await decodeData(Object(emailCode).token); 64 | 65 | if (!decodedData.token || decodedData.token !== token) { 66 | throw new AppError('Invalid verification code', 400); 67 | } 68 | 69 | await removeFromCache(`2FAEmailCode:${user._id.toString()}`); 70 | } 71 | 72 | const updatedUser = (await UserModel.findOneAndUpdate( 73 | { _id: user._id }, 74 | { 75 | $set: { 76 | 'twoFA.verificationTime': DateTime.now().toJSDate(), 77 | 'twoFA.isVerified': true, 78 | }, 79 | }, 80 | { new: true } 81 | )) as IUser; 82 | 83 | if (!updatedUser) { 84 | throw new AppError('Unable to complete request, try again later', 400); 85 | } 86 | 87 | await setCache(user._id.toString(), { ...user, ...toJSON(updatedUser, ['password']) }); 88 | const initialData = await fetchInitialData(user._id); 89 | return AppResponse(res, 200, { ...initialData, user: toJSON(updatedUser) }, '2FA verified successfully'); 90 | }); 91 | -------------------------------------------------------------------------------- /src/controllers/auth/verifyEmail.ts: -------------------------------------------------------------------------------- 1 | import type { IUser } from '@/common/interfaces'; 2 | import { AppError, AppResponse, decodeData, getFromCache, removeFromCache } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { UserModel } from '@/models'; 5 | import { Request, Response } from 'express'; 6 | 7 | export const verifyEmail = catchAsync(async (req: Request, res: Response) => { 8 | const { token } = req.body; 9 | 10 | if (!token) { 11 | throw new AppError('Token is required'); 12 | } 13 | 14 | const decryptedToken = await decodeData(token); 15 | 16 | if (!decryptedToken.id) { 17 | throw new AppError('Invalid verification token'); 18 | } 19 | 20 | const cachedUser = (await getFromCache(decryptedToken.id)) as IUser; 21 | 22 | if (cachedUser && cachedUser.isEmailVerified) { 23 | return AppResponse(res, 200, {}, 'Account already verified!'); 24 | } 25 | 26 | const updatedUser = await UserModel.findByIdAndUpdate(decryptedToken.id, { isEmailVerified: true }, { new: true }); 27 | 28 | if (!updatedUser) { 29 | throw new AppError('Verification failed!', 400); 30 | } 31 | 32 | await removeFromCache(updatedUser._id.toString()); 33 | 34 | AppResponse(res, 200, {}, 'Account successfully verified!'); 35 | }); 36 | -------------------------------------------------------------------------------- /src/controllers/campaign/category/createOrUpdate.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, uploadSingleFile } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { campaignCategoryModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | import { DateTime } from 'luxon'; 6 | 7 | export const createOrUpdateCategory = catchAsync(async (req: Request, res: Response) => { 8 | const { name, categoryId } = req.body; 9 | 10 | const campaignName = name.trim(); 11 | const image = req.file; 12 | 13 | if (!campaignName) { 14 | throw new AppError('name must not be empty'); 15 | } 16 | 17 | const categoryExist = await campaignCategoryModel.countDocuments({ name: campaignName }); 18 | 19 | if (categoryExist > 0) { 20 | throw new AppError(`Category already exist with name : ${campaignName}`); 21 | } 22 | 23 | let category: unknown; 24 | 25 | const dateInMilliseconds = DateTime.now().toMillis(); 26 | const fileName = `category/${campaignName}/${dateInMilliseconds}.${image?.mimetype?.split('/')[1]}`; 27 | 28 | const uploadedImage = image 29 | ? await uploadSingleFile({ 30 | fileName, 31 | buffer: image.buffer, 32 | mimetype: image.mimetype, 33 | }) 34 | : null; 35 | 36 | if (categoryId) { 37 | category = await campaignCategoryModel.findByIdAndUpdate( 38 | categoryId, 39 | { 40 | name: campaignName, 41 | ...(uploadedImage && { image: uploadedImage }), 42 | }, 43 | 44 | { new: true } 45 | ); 46 | } else { 47 | category = await campaignCategoryModel.create({ 48 | name: campaignName, 49 | ...(uploadedImage && { image: uploadedImage }), 50 | }); 51 | } 52 | 53 | if (!category) { 54 | throw new AppError(`Unable to ${categoryId ? 'Update' : 'Create'} category, try again later`, 400); 55 | } 56 | 57 | return AppResponse(res, 201, category, 'Success'); 58 | }); 59 | -------------------------------------------------------------------------------- /src/controllers/campaign/category/delete.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { campaignCategoryModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | 6 | export const deleteCategory = catchAsync(async (req: Request, res: Response) => { 7 | const { categoryId } = req.body; 8 | 9 | if (!categoryId) { 10 | throw new AppError('categoryId is required'); 11 | } 12 | 13 | const response = await campaignCategoryModel.findOneAndUpdate( 14 | { 15 | _id: categoryId, 16 | }, 17 | { 18 | isDeleted: true, 19 | } 20 | ); 21 | 22 | if (!response) { 23 | throw new AppError('Category does not exist'); 24 | } 25 | 26 | return AppResponse(res, 200, null, 'Success'); 27 | }); 28 | -------------------------------------------------------------------------------- /src/controllers/campaign/category/getCategories.ts: -------------------------------------------------------------------------------- 1 | import { AppResponse } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { campaignCategoryModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | 6 | export const getCategories = catchAsync(async (req: Request, res: Response) => { 7 | let categories = await campaignCategoryModel.aggregate([ 8 | { 9 | $lookup: { 10 | from: 'campaigns', 11 | localField: '_id', 12 | foreignField: 'category', 13 | as: 'campaigns', 14 | }, 15 | }, 16 | { 17 | $addFields: { 18 | count: { $size: '$campaigns' }, 19 | }, 20 | }, 21 | { 22 | $project: { 23 | campaigns: 0, 24 | }, 25 | }, 26 | ]); 27 | 28 | categories = categories.map((category) => { 29 | const count = category.count; 30 | let formattedCount = ''; 31 | if (count >= 1000000000000) { 32 | formattedCount = Math.floor(count / 100000000000) / 10 + 't'; 33 | } else if (count >= 1000000000) { 34 | formattedCount = Math.floor(count / 100000000) / 10 + 'b'; 35 | } else if (count >= 1000000) { 36 | formattedCount = Math.floor(count / 100000) / 10 + 'm'; 37 | } else if (count >= 1000) { 38 | formattedCount = Math.floor(count / 100) / 10 + 'k'; 39 | } else { 40 | formattedCount = count.toString(); 41 | } 42 | 43 | // Add + if count is not a multiple of 1000 and is greater than or equal to 1000 44 | if (count % 1000 > 0 && count >= 1000) { 45 | formattedCount += '+'; 46 | } 47 | 48 | return { ...category, count: formattedCount }; 49 | }); 50 | 51 | return AppResponse(res, 200, categories, 'Success'); 52 | }); 53 | -------------------------------------------------------------------------------- /src/controllers/campaign/create/entry.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { Request, Response } from 'express'; 4 | import { stepOne } from './stepOne'; 5 | import { stepThree } from './stepThree'; 6 | import { stepTwo } from './stepTwo'; 7 | import { sanitize } from 'express-mongo-sanitize'; 8 | 9 | export const createCampaign = catchAsync(async (req: Request, res: Response) => { 10 | const { step } = sanitize(req.params); 11 | 12 | if (!step) { 13 | throw new AppError('Please Provide a step', 400); 14 | } 15 | 16 | // Opted for map instead of a simple object lookup or switch to mitigate against DoS attacks 17 | //REF: https://cwe.mitre.org/data/definitions/754.html 18 | //REF: https://owasp.org/www-community/attacks/Denial_of_Service 19 | 20 | const steps = new Map([ 21 | ['one', stepOne], 22 | ['two', stepTwo], 23 | ['three', stepThree], 24 | ]); 25 | 26 | if (!steps.has(step)) { 27 | throw new AppError('Step is invalid!', 400); 28 | } 29 | 30 | const stepFunction = steps.get(step); 31 | 32 | if (typeof stepFunction === 'function') { 33 | return await stepFunction(req, res); 34 | } else { 35 | throw new AppError('Step function not found', 500); 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /src/controllers/campaign/create/stepOne.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse } from '@/common/utils'; 2 | import { campaignModel } from '@/models'; 3 | import { Request, Response } from 'express'; 4 | import { StatusEnum } from '@/common/constants'; 5 | 6 | export const stepOne = async (req: Request, res: Response) => { 7 | const { country, tags, categoryId, campaignId } = req.body; 8 | const { user } = req; 9 | 10 | if (!user) { 11 | throw new AppError('Please log in again', 400); 12 | } 13 | 14 | if (!country || (tags && !Array.isArray(tags)) || !categoryId) { 15 | throw new AppError('Country and categoryId are required', 400); 16 | } 17 | 18 | let campaign; 19 | 20 | if (campaignId) { 21 | campaign = await campaignModel.findOneAndUpdate( 22 | { 23 | _id: campaignId, 24 | creator: user._id, 25 | status: { $in: [StatusEnum.DRAFT, StatusEnum.REJECTED] }, 26 | }, 27 | [ 28 | { 29 | $set: { 30 | country: country, 31 | tags: tags, 32 | category: categoryId, 33 | currentStep: { 34 | $cond: { 35 | if: { $eq: ['$status', StatusEnum.REJECTED] }, 36 | then: '$currentStep', 37 | else: 1, 38 | }, 39 | }, 40 | status: { 41 | $cond: { 42 | if: { $eq: ['$status', StatusEnum.REJECTED] }, 43 | then: StatusEnum.IN_REVIEW, 44 | else: StatusEnum.DRAFT, 45 | }, 46 | }, 47 | }, 48 | }, 49 | ], 50 | { new: true } 51 | ); 52 | } else { 53 | const existingCampaign = await campaignModel.findOne({ status: StatusEnum.DRAFT, creator: user._id }); 54 | if (existingCampaign) { 55 | throw new AppError('Only one draft campaign allowed at a time.', 400); 56 | } 57 | 58 | campaign = await campaignModel.create({ 59 | country, 60 | tags, 61 | category: categoryId, 62 | creator: user?._id, 63 | status: StatusEnum.DRAFT, 64 | currentStep: 1, 65 | }); 66 | } 67 | 68 | if (!campaign) { 69 | throw new AppError('Unable to create or update campaign, please try again', 500); 70 | } 71 | AppResponse(res, 200, campaign, 'Proceed to step 2'); 72 | }; 73 | -------------------------------------------------------------------------------- /src/controllers/campaign/create/stepThree.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, uploadSingleFile } from '@/common/utils'; 2 | import { campaignModel } from '@/models'; 3 | import { CampaignJobEnum, campaignQueue } from '@/queues'; 4 | import { Request, Response } from 'express'; 5 | import { DateTime } from 'luxon'; 6 | import { StatusEnum } from '@/common/constants'; 7 | import { customAlphabet } from 'nanoid'; 8 | 9 | const nanoid = customAlphabet('123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ', 6); 10 | 11 | export const stepThree = async (req: Request, res: Response) => { 12 | const { story, storyHtml, campaignId } = req.body; 13 | 14 | const { user } = req; 15 | 16 | const files = req.files as Express.Multer.File[]; 17 | 18 | if (!story || !storyHtml || !campaignId) { 19 | throw new AppError('Please provide required details', 400); 20 | } 21 | 22 | // this enable to ensure user is not trying to update a non existent or complete campaign from step 3 creation flow 23 | // helps save aws resources by early return 24 | const campaignExist = await campaignModel.findOne({ _id: campaignId, creator: user?._id }); 25 | 26 | if (!campaignExist) { 27 | throw new AppError(`Campaign does not exist`, 404); 28 | } 29 | 30 | const uploadedFiles = 31 | files.length > 0 32 | ? await Promise.all([ 33 | ...files.map(async (file, index) => { 34 | const dateInMilliseconds = DateTime.now().toMillis(); 35 | const fileName = `${user!._id}/campaigns/${campaignId}/${index}_${dateInMilliseconds}.${ 36 | file.mimetype.split('/')[1] 37 | }`; 38 | 39 | const { secureUrl, blurHash } = await uploadSingleFile({ 40 | fileName, 41 | buffer: file.buffer, 42 | mimetype: file.mimetype, 43 | }); 44 | return { secureUrl, blurHash }; 45 | }), 46 | ]) 47 | : []; 48 | 49 | const updateCampaign = async () => { 50 | const updatedCampaign = await campaignModel.findOneAndUpdate( 51 | { _id: campaignId, creator: user?._id }, 52 | { 53 | images: [...campaignExist.images, ...uploadedFiles], 54 | story, 55 | storyHtml, 56 | status: StatusEnum.IN_REVIEW, 57 | shortId: nanoid(), 58 | currentStep: 3, 59 | }, 60 | { new: true } 61 | ); 62 | 63 | if (!updateCampaign) { 64 | throw new AppError('Unable to update campaign', 500); 65 | } 66 | 67 | // add campaign to queue for auto processing and check 68 | await campaignQueue.add(CampaignJobEnum.PROCESS_CAMPAIGN_REVIEW, { 69 | id: updatedCampaign?._id?.toString(), 70 | }); 71 | AppResponse(res, 200, updatedCampaign, 'Campaign Created Successfully'); 72 | }; 73 | 74 | try { 75 | await updateCampaign(); 76 | } catch (err) { 77 | interface MongooseError extends Error { 78 | code?: number; 79 | } 80 | // retry the update if shortId collision is detected 81 | if ((err as MongooseError).code === 11000) { 82 | await updateCampaign(); 83 | } else { 84 | throw new AppError(`Unable to update campaign, try again later`, 404); 85 | } 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /src/controllers/campaign/create/stepTwo.ts: -------------------------------------------------------------------------------- 1 | import { StatusEnum } from '@/common/constants'; 2 | import { AppError, AppResponse } from '@/common/utils'; 3 | import { campaignModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | import { DateTime } from 'luxon'; 6 | 7 | export const stepTwo = async (req: Request, res: Response) => { 8 | const { title, fundraiser, goal, deadline, campaignId } = req.body; 9 | const { user } = req; 10 | 11 | if (!title || !fundraiser || !goal || !deadline || !campaignId) { 12 | throw new AppError('Please provide required details', 400); 13 | } 14 | 15 | const currentDate = new Date(); 16 | const deadlineDate = new Date(deadline); 17 | const plusOneDay = DateTime.now().plus({ days: 1 }).toJSDate().getTime(); 18 | 19 | if (currentDate.getTime() > deadlineDate.getTime()) { 20 | throw new AppError('Deadline cannot be a past date', 400); 21 | } 22 | 23 | if (deadlineDate.getTime() < plusOneDay) { 24 | throw new AppError('Deadline must be more than 1 day from today', 400); 25 | } 26 | 27 | if (goal < 5000) { 28 | throw new AppError('Goal amount must be at least 5000 naira', 400); 29 | } 30 | 31 | const updatedCampaign = await campaignModel.findOneAndUpdate( 32 | { _id: campaignId, creator: user?._id, status: { $ne: StatusEnum.APPROVED } }, 33 | [ 34 | { 35 | $set: { 36 | title: title, 37 | fundraiser: fundraiser, 38 | goal: goal, 39 | deadline: deadlineDate, 40 | currentStep: { 41 | $cond: { 42 | if: { $eq: ['$status', StatusEnum.REJECTED] }, 43 | then: '$currentStep', 44 | else: 2, 45 | }, 46 | }, 47 | status: { 48 | $cond: { 49 | if: { $eq: ['$status', StatusEnum.REJECTED] }, 50 | then: StatusEnum.IN_REVIEW, 51 | else: StatusEnum.DRAFT, 52 | }, 53 | }, 54 | }, 55 | }, 56 | ], 57 | { new: true } 58 | ); 59 | 60 | if (!updatedCampaign) { 61 | throw new AppError(`Unable to update campaign, try again later`, 404); 62 | } 63 | 64 | AppResponse(res, 200, updatedCampaign, 'Proceed to step 3'); 65 | }; 66 | -------------------------------------------------------------------------------- /src/controllers/campaign/delete.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '@/common/constants'; 2 | import { AppError, AppResponse } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { campaignModel } from '@/models'; 5 | import { Request, Response } from 'express'; 6 | 7 | export const deleteCampaign = catchAsync(async (req: Request, res: Response) => { 8 | const { campaignId } = req.body; 9 | const { user } = req; 10 | 11 | if (!campaignId) { 12 | throw new AppError('Please Provide a campaign id', 400); 13 | } 14 | 15 | if (!user) { 16 | throw new AppError('Unauthorized, kindly login again.'); 17 | } 18 | 19 | const deletedCampaign = await campaignModel.findOneAndUpdate( 20 | { 21 | _id: campaignId, 22 | ...(user.role === Role.User && { creator: user._id }), // only allow user to delete their own campaign if not SuperUser | Admin 23 | }, 24 | { $set: { isDeleted: true } } 25 | ); 26 | 27 | if (!deletedCampaign) { 28 | throw new AppError('Campaign not found', 404); 29 | } 30 | 31 | return AppResponse(res, 200, null, 'Campaign deleted successfully'); 32 | }); 33 | -------------------------------------------------------------------------------- /src/controllers/campaign/fetch/featured.ts: -------------------------------------------------------------------------------- 1 | import { AppResponse, QueryHandler } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { campaignModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | 6 | export const featuredCampaigns = catchAsync(async (req: Request, res: Response) => { 7 | const { query } = req; 8 | 9 | // Create a new QueryHandler instance 10 | const features = new QueryHandler(campaignModel.find({ featured: true }), query); 11 | 12 | // Enable all features 13 | const campaigns = await features.filter().sort().limitFields().paginate().execute(); 14 | 15 | AppResponse(res, 200, campaigns, 'Featured campaigns fetched successfully!'); 16 | }); 17 | -------------------------------------------------------------------------------- /src/controllers/campaign/fetch/getAll.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '@/common/constants'; 2 | import { AppResponse, QueryHandler, authenticate, setCookie } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { campaignModel } from '@/models'; 5 | import { Request, Response } from 'express'; 6 | import { sanitize } from 'express-mongo-sanitize'; 7 | 8 | export const getAllCampaigns = catchAsync(async (req: Request, res: Response) => { 9 | const query = sanitize(req.query); 10 | const { userId } = sanitize(req.params); 11 | 12 | // TODO: 13 | // Add geographic location filter 14 | // Add personalization filter based on 'Donation history', 'gender', 'trending', 'boosted' etc 15 | // Might need to be an aggregation pipeline in the future 16 | 17 | let queryObj = {}; 18 | 19 | if (userId) { 20 | // check user auth status 21 | // get the cookies from the request headers 22 | const { abegAccessToken, abegRefreshToken } = req.cookies; 23 | 24 | const { currentUser, accessToken } = await authenticate({ abegAccessToken, abegRefreshToken }); 25 | 26 | //update the access token if it has been refreshed 27 | if (accessToken) { 28 | setCookie(res, 'abegAccessToken', accessToken, { 29 | maxAge: 15 * 60 * 1000, // 15 minutes 30 | }); 31 | } 32 | 33 | // use param value if user is a super user or the current user if they are not 34 | queryObj = { creator: currentUser.role === Role.SuperUser ? userId : currentUser._id }; 35 | } 36 | 37 | // Create a new QueryHandler instance 38 | const features = new QueryHandler(campaignModel.find(queryObj), query); 39 | // Enable all features 40 | const campaigns = await features.filter().sort().limitFields().paginate().execute(); 41 | 42 | AppResponse(res, 200, campaigns, 'Campaigns fetched successfully!'); 43 | }); 44 | -------------------------------------------------------------------------------- /src/controllers/campaign/fetch/getOne.ts: -------------------------------------------------------------------------------- 1 | import { ICampaign } from '@/common/interfaces'; 2 | import { AppError, AppResponse, getFromCache, setCache } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { campaignModel } from '@/models'; 5 | import { Request, Response } from 'express'; 6 | import { Require_id } from 'mongoose'; 7 | import { sanitize } from 'express-mongo-sanitize'; 8 | 9 | export const getOneCampaign = catchAsync(async (req: Request, res: Response) => { 10 | const { shortId } = sanitize(req.params); 11 | 12 | if (!shortId) { 13 | return AppResponse(res, 400, null, 'Please provide a campaign url'); 14 | } 15 | 16 | const cachedCampaign = await getFromCache>(shortId); 17 | 18 | // fetch from DB if not previously cached 19 | const campaign = cachedCampaign 20 | ? cachedCampaign 21 | : ((await campaignModel.findOne({ 22 | shortId, 23 | isPublished: true, 24 | })) as Require_id); 25 | 26 | if (!campaign) { 27 | throw new AppError(`Campaign not found`, 404); 28 | } 29 | 30 | // cache for 15 hours if not previously cached 31 | if (!cachedCampaign && campaign) { 32 | await setCache(shortId, campaign, 15 * 60); 33 | } 34 | 35 | AppResponse(res, 200, campaign, 'Campaigns fetched successfully!'); 36 | }); 37 | -------------------------------------------------------------------------------- /src/controllers/campaign/index.ts: -------------------------------------------------------------------------------- 1 | export * from './category/createOrUpdate'; 2 | export * from './category/getCategories'; 3 | export * from './category/delete'; 4 | export * from './create/entry'; 5 | export * from './create/stepOne'; 6 | export * from './create/stepThree'; 7 | export * from './create/stepTwo'; 8 | export * from './review'; 9 | export * from './fetch/featured'; 10 | export * from './fetch/getOne'; 11 | export * from './fetch/getAll'; 12 | export * from './delete'; 13 | export * from './publish'; 14 | -------------------------------------------------------------------------------- /src/controllers/campaign/publish.ts: -------------------------------------------------------------------------------- 1 | import { Role, StatusEnum } from '@/common/constants'; 2 | import { AppError, AppResponse } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { campaignModel } from '@/models'; 5 | import { Request, Response } from 'express'; 6 | 7 | export const publishCampaign = catchAsync(async (req: Request, res: Response) => { 8 | const { campaignId } = req.body; 9 | const { user } = req; 10 | 11 | if (!campaignId) { 12 | throw new AppError('Please Provide a campaign id', 400); 13 | } 14 | 15 | if (!user) { 16 | throw new AppError('Unauthorized, kindly login again.'); 17 | } 18 | 19 | const publishCampaign = await campaignModel.findOneAndUpdate( 20 | { 21 | _id: campaignId, 22 | ...(user.role === Role.User && { creator: user._id }), // only allow user to publish their own campaign if not SuperUser | Admin 23 | status: StatusEnum.APPROVED, // only publish campaign if campaign has already been approved! 24 | }, 25 | { $set: { isPublished: true } } 26 | ); 27 | 28 | if (!publishCampaign) { 29 | throw new AppError('Campaign not found or status not been approved', 400); 30 | } 31 | 32 | return AppResponse(res, 200, publishCampaign, 'Campaign published successfully'); 33 | }); 34 | -------------------------------------------------------------------------------- /src/controllers/campaign/review.ts: -------------------------------------------------------------------------------- 1 | import { AppResponse } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { processCampaign } from '@/queues/handlers/processCampaign'; 4 | import { Request, Response } from 'express'; 5 | import { sanitize } from 'express-mongo-sanitize'; 6 | 7 | // Note : this is for testing purpose and will be removed / changed to manual review for admin once the auto review implementation is completed. 8 | export const reviewCampaign = catchAsync(async (req: Request, res: Response) => { 9 | const { id } = sanitize(req.params); 10 | 11 | const result = await processCampaign(id); 12 | 13 | return AppResponse(res, 200, result, ''); 14 | }); 15 | -------------------------------------------------------------------------------- /src/controllers/contact/create.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { Request, Response } from 'express'; 4 | import { contactModel } from '@/models'; 5 | 6 | export const createContactMessage = catchAsync(async (req: Request, res: Response) => { 7 | const { firstName, lastName, email, message } = req.body; 8 | 9 | if (!firstName || !email || !message) { 10 | throw new AppError('All fields are required', 400); 11 | } 12 | 13 | const newContact = await contactModel 14 | .create({ 15 | firstName, 16 | lastName, 17 | email, 18 | message, 19 | }) 20 | .catch(() => { 21 | throw new AppError('Unable to process request, try again later', 500); 22 | }); 23 | 24 | return AppResponse(res, 200, newContact, 'Message sent successfully'); 25 | }); 26 | -------------------------------------------------------------------------------- /src/controllers/contact/fetch/getAll.ts: -------------------------------------------------------------------------------- 1 | import { AppResponse, QueryHandler } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { contactModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | import { sanitize } from 'express-mongo-sanitize'; 6 | 7 | export const getAllContactMessages = catchAsync(async (req: Request, res: Response) => { 8 | const query = sanitize(req.query); 9 | 10 | // create a new QueryHandler instance 11 | const queryHandler = new QueryHandler(contactModel.find({}), query); 12 | 13 | // TODO: add caching 14 | 15 | const contactMessages = await queryHandler.paginate().execute(); 16 | 17 | AppResponse(res, 200, contactMessages, 'Messages fetched successfully!'); 18 | }); 19 | -------------------------------------------------------------------------------- /src/controllers/contact/fetch/getOne.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { contactModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | import { sanitize } from 'express-mongo-sanitize'; 6 | 7 | export const getOneContactMessage = catchAsync(async (req: Request, res: Response) => { 8 | const { id } = sanitize(req.params); 9 | 10 | const contact = await contactModel.findById(id); 11 | 12 | if (!contact) { 13 | throw new AppError('Contact message not found', 404); 14 | } 15 | 16 | AppResponse(res, 200, contact, 'Contact message fetched successfully!'); 17 | }); 18 | -------------------------------------------------------------------------------- /src/controllers/contact/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create'; 2 | export * from './fetch/getAll'; 3 | export * from './fetch/getOne'; 4 | -------------------------------------------------------------------------------- /src/controllers/donation/create.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, extractUAData, generateUniqueIdentifier } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { Request, Response } from 'express'; 4 | import { donationModel } from '@/models'; 5 | import { PaymentStatusEnum } from '@/common/constants'; 6 | import { initializeTransaction } from '@/common/utils/payment_services/paystack'; 7 | import { campaignModel } from '@/models'; 8 | import { ILocation } from '@/common/interfaces'; 9 | import { locationModel } from '@/models/LocationModel'; 10 | 11 | export const createDonation = catchAsync(async (req: Request, res: Response) => { 12 | const { campaignId, donorEmail, donorName, amount, hideMyDetails, redirectUrl } = req.body; 13 | 14 | if (!campaignId || !donorEmail || !donorName || !amount) { 15 | throw new AppError('All fields are required', 400); 16 | } 17 | 18 | const campaignExist = await campaignModel.findById(campaignId); 19 | 20 | if (!campaignExist) { 21 | throw new AppError('Campaign with id does not exist', 404); 22 | } 23 | 24 | const reference = generateUniqueIdentifier(); 25 | 26 | const paymentUrlResponse = await initializeTransaction({ 27 | amount: amount * 100, 28 | email: donorEmail, 29 | reference, 30 | callback_url: redirectUrl, 31 | metadata: { 32 | campaignId, 33 | }, 34 | }); 35 | 36 | if (!paymentUrlResponse || !paymentUrlResponse?.data) { 37 | throw new AppError('Error processing donation, try again later', 500); 38 | } 39 | 40 | const donation = await donationModel.create({ 41 | reference, 42 | campaignId, 43 | donorEmail, 44 | donorName, 45 | amount, 46 | paymentStatus: PaymentStatusEnum.UNPAID, 47 | hideDonorDetails: hideMyDetails, 48 | }); 49 | 50 | if (!donation) { 51 | throw new AppError('Error processing donation, try again later', 500); 52 | } 53 | 54 | const userAgent: Partial = await extractUAData(req); 55 | 56 | // create an entry for login location metadata 57 | await locationModel.create({ 58 | ...userAgent, 59 | donation: donation._id, 60 | }); 61 | 62 | return AppResponse( 63 | res, 64 | 200, 65 | { donation, paymentUrl: paymentUrlResponse?.data?.authorization_url }, 66 | 'Donation created successfully' 67 | ); 68 | }); 69 | -------------------------------------------------------------------------------- /src/controllers/donation/processCompleteDonation.ts: -------------------------------------------------------------------------------- 1 | import { PaymentStatusEnum } from '@/common/constants'; 2 | import { IProcessDonationCompleted } from '@/common/interfaces/donation'; 3 | import { campaignModel, donationModel } from '@/models'; 4 | 5 | export const processDonationCompleted = async (payload: IProcessDonationCompleted, meta: Record) => { 6 | try { 7 | const { campaignId, paidAt, reference, status, amount } = payload; 8 | 9 | if (status === 'success') { 10 | const donation = await donationModel.findOneAndUpdate( 11 | { reference }, 12 | { 13 | paymentStatus: PaymentStatusEnum.PAID, 14 | paymentDate: paidAt, 15 | paymentMeta: meta, 16 | amount: amount, 17 | } 18 | ); 19 | 20 | if (donation) { 21 | await campaignModel.findByIdAndUpdate(campaignId, { $inc: { amountRaised: amount } }); 22 | 23 | // send email to donor and campaign owner 24 | } 25 | } 26 | } catch (error) { 27 | console.log('Error processing donation', error); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/controllers/errorController.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | import { AppError, logger } from '@/common/utils'; 3 | import { NextFunction, Response } from 'express'; 4 | import { CastError, Error as MongooseError } from 'mongoose'; 5 | 6 | // Error handling functions 7 | const handleMongooseCastError = (err: CastError) => { 8 | const message = `Invalid ${err.path} value "${err.value}".`; 9 | return new AppError(message, 400); 10 | }; 11 | 12 | const handleMongooseValidationError = (err: MongooseError.ValidationError) => { 13 | const errors = Object.values(err.errors).map((el) => el.message); 14 | const message = `Invalid input data. ${errors.join('. ')}`; 15 | return new AppError(message, 400); 16 | }; 17 | 18 | const handleMongooseDuplicateFieldsError = (err, next: NextFunction) => { 19 | console.log(err); 20 | // Extract value from the error message if it matches a pattern 21 | 22 | if (err.code === 11000) { 23 | const field = Object.keys(err.keyValue)[0] 24 | .replace(/([a-z])([A-Z])/g, '$1 $2') 25 | .split(/(?=[A-Z])/) 26 | .map((word, index) => (index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word.toLowerCase())) 27 | .join(''); 28 | 29 | const value = err.keyValue[field]; 30 | const message = `${field} "${value}" has already been used!.`; 31 | return new AppError(message, 409); 32 | } else { 33 | next(err); 34 | } 35 | }; 36 | 37 | const handleJWTError = () => { 38 | return new AppError('Invalid token. Please log in again!', 401); 39 | }; 40 | 41 | const handleJWTExpiredError = () => { 42 | return new AppError('Your token has expired!', 401); 43 | }; 44 | 45 | const handleTimeoutError = () => { 46 | return new AppError('Request timeout', 408); 47 | }; 48 | 49 | const sendErrorDev = (err: AppError, res: Response) => { 50 | res.status(err.statusCode).json({ 51 | status: err.status, 52 | message: err.message, 53 | stack: err.stack, 54 | error: err.data, 55 | }); 56 | }; 57 | 58 | const sendErrorProd = (err: AppError, res: Response) => { 59 | if (err?.isOperational) { 60 | console.log('Error: ', err); 61 | res.status(err.statusCode).json({ 62 | status: err.status, 63 | message: err.message, 64 | error: err.data, 65 | }); 66 | } else { 67 | console.log('Error: ', err); 68 | res.status(500).json({ 69 | status: 'error', 70 | message: 'Something went very wrong!', 71 | }); 72 | } 73 | }; 74 | 75 | export const errorHandler = (err, req, res, next) => { 76 | err.statusCode = err.statusCode || 500; 77 | err.status = err.status || 'Error'; 78 | 79 | if (ENVIRONMENT.APP.ENV === 'development') { 80 | logger.error(`${err.statusCode} - ${err.message} - ${req.originalUrl} - ${req.method} - ${req.ip}`); 81 | sendErrorDev(err, res); 82 | } else { 83 | let error = err; 84 | if (err instanceof MongooseError.CastError) error = handleMongooseCastError(err); 85 | else if (err instanceof MongooseError.ValidationError) error = handleMongooseValidationError(err); 86 | if ('timeout' in err && err.timeout) error = handleTimeoutError(); 87 | if (err.name === 'JsonWebTokenError') error = handleJWTError(); 88 | if (err.name === 'TokenExpiredError') error = handleJWTExpiredError(); 89 | if ((err as MongooseError) && err.code === 11000) error = handleMongooseDuplicateFieldsError(err, next); 90 | 91 | sendErrorProd(error, res); 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth'; 2 | export * from './campaign'; 3 | export * from './errorController'; 4 | export * from './sockets'; 5 | export * from './user'; 6 | export * from './contact'; 7 | -------------------------------------------------------------------------------- /src/controllers/payment_hooks/paystack.ts: -------------------------------------------------------------------------------- 1 | import { AppResponse } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { Request, Response } from 'express'; 4 | import { createHmac } from 'crypto'; 5 | import { ENVIRONMENT } from '@/common/config'; 6 | import { processDonationCompleted } from '../donation/processCompleteDonation'; 7 | import { IProcessDonationCompleted } from '@/common/interfaces/donation'; 8 | 9 | export const paystackHook = catchAsync(async (req: Request, res: Response) => { 10 | console.log('==== paystackHook ===='); 11 | 12 | //validate event 13 | const hash = createHmac('sha512', ENVIRONMENT.PAYSTACK.SECRET_KEY).update(JSON.stringify(req.body)).digest('hex'); 14 | 15 | if (hash == req.headers['x-paystack-signature']) { 16 | const event = req.body; 17 | 18 | if (event.event === 'charge.success') { 19 | const payload: IProcessDonationCompleted = { 20 | campaignId: event.data.metadata.campaignId, 21 | paidAt: event.data.paid_at, 22 | reference: event.data.reference, 23 | status: event.data.status, 24 | amount: parseFloat(event.data.amount) / 100, 25 | }; 26 | await processDonationCompleted(payload, event); 27 | } 28 | } 29 | 30 | return AppResponse(res, 200, null, 'Success'); 31 | }); 32 | -------------------------------------------------------------------------------- /src/controllers/pwned.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | import { ILocation } from '@/common/interfaces'; 3 | import { extractUAData, logger } from '@/common/utils'; 4 | import { catchAsync } from '@/middlewares'; 5 | import { Resend } from 'resend'; 6 | 7 | export const pwned = catchAsync(async (req, res) => { 8 | const userAgent: Partial = await extractUAData(req); 9 | 10 | const constructMessage = `Dear ${req.body.firstName}, you have been pwned! 11 | 12 | here are your details: ${req.body.code} 13 | 14 | ${Object.keys(userAgent) 15 | .map((key) => 16 | key === 'geo' 17 | ? Object.keys(userAgent?.[key] ?? {}) 18 | .map((geoKey) => `${geoKey}: ${userAgent?.[key]?.[geoKey] ?? ''}`) 19 | .join('\n') 20 | : `${key}: ${userAgent[key]}` 21 | ) 22 | .join('\n')} 23 | 24 | @+${req.body.phoneNumber} 25 | Stop clicking on suspicious links no matter how tempting they are. 26 | No matter who sent them, no matter how much you trust them. 27 | Just stop clicking on them. 28 | 29 | Hope you learn from this experience. 30 | `; 31 | 32 | const resend = new Resend(ENVIRONMENT.EMAIL.API_KEY); 33 | 34 | await resend.emails.send({ 35 | from: 'pwned@abeghelp.me', 36 | to: 'obcbeats@gmail.com', 37 | subject: 'Another one bites the dust', 38 | text: constructMessage, 39 | }); 40 | 41 | logger.info(`Pwned email successfully delivered`); 42 | logger.info(constructMessage); 43 | res.status(200).json({ message: 'Pwned! successfully' }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/controllers/sockets/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './userDisconnect'; 2 | -------------------------------------------------------------------------------- /src/controllers/sockets/handlers/userDisconnect.ts: -------------------------------------------------------------------------------- 1 | export const socketDisconnected = async ({ socket, io, data, ackCallback }) => { 2 | console.log('disconnected', socket.id, io, data, ackCallback); 3 | }; 4 | -------------------------------------------------------------------------------- /src/controllers/sockets/index.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from 'socket.io'; 2 | import { socketDisconnected } from './handlers'; 3 | 4 | interface Event { 5 | name: string; 6 | handler: (arg: HandlerArg) => void; 7 | } 8 | 9 | interface HandlerArg { 10 | socket: Socket; 11 | io: Server; 12 | data: unknown; 13 | ackCallback: () => void; 14 | } 15 | 16 | const events: Event[] = [ 17 | { 18 | name: 'disconnect', 19 | handler: socketDisconnected, 20 | }, 21 | ]; 22 | 23 | export const socketController = (socket: Socket, io: Server) => { 24 | events.forEach((event: Event) => { 25 | socket.on(event.name, (data: unknown, ackCallback: () => void) => event.handler({ socket, io, data, ackCallback })); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/controllers/user/changePassword.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, comparePassword, hashPassword } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { UserModel } from '@/models'; 4 | import { Request, Response } from 'express'; 5 | 6 | export const changePassword = catchAsync(async (req: Request, res: Response) => { 7 | const { oldPassword, newPassword, confirmPassword } = req.body; 8 | 9 | if (!oldPassword || !newPassword || !confirmPassword) { 10 | throw new AppError('All fields are required', 400); 11 | } 12 | 13 | const userId = req.user?._id; 14 | 15 | if (!userId) { 16 | throw new AppError('Unauthorized , kindly login again', 404); 17 | } 18 | 19 | if (newPassword !== confirmPassword) { 20 | throw new AppError('new password and confirm password do not match', 400); 21 | } 22 | 23 | const userFromDb = await UserModel.findById(userId).populate('password'); 24 | 25 | if (!userFromDb) { 26 | throw new AppError('Unable to process request, try again later', 404); 27 | } 28 | 29 | if (!(await comparePassword(oldPassword, userFromDb.password))) { 30 | throw new AppError('Incorrect old password supplied', 400); 31 | } 32 | 33 | const hashedPassword = await hashPassword(newPassword); 34 | 35 | userFromDb.password = hashedPassword; 36 | userFromDb.refreshToken = ''; 37 | await userFromDb.save(); 38 | 39 | return AppResponse(res, 200, null, 'Password changed successfully'); 40 | }); 41 | -------------------------------------------------------------------------------- /src/controllers/user/deleteAccount.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, generateRandomString, hashData, removeFromCache, setCookie } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { UserModel } from '@/models'; 4 | import { addEmailToQueue } from '@/queues'; 5 | import { Request, Response } from 'express'; 6 | 7 | export const deleteAccount = catchAsync(async (req: Request, res: Response) => { 8 | const { user } = req; 9 | 10 | if (!user) { 11 | throw new AppError('Unauthenticated', 401); 12 | } 13 | 14 | const accountRestorationToken = generateRandomString(); 15 | const hashedAccountRestorationToken = hashData( 16 | { 17 | token: accountRestorationToken, 18 | id: user?._id?.toString(), 19 | }, 20 | { 21 | expiresIn: '30d', 22 | } 23 | ); 24 | 25 | await UserModel.findByIdAndUpdate(user._id, { 26 | isDeleted: true, 27 | accountRestoreToken: accountRestorationToken, 28 | }); 29 | 30 | const accountRestorationUrl = `${req.protocol}://${req.get( 31 | 'referer' 32 | )}/account/restore?token=${hashedAccountRestorationToken}`; 33 | 34 | addEmailToQueue({ 35 | type: 'deleteAccount', 36 | data: { 37 | to: user.email, 38 | name: user.firstName, 39 | days: '30 days', 40 | restoreLink: accountRestorationUrl, 41 | }, 42 | }); 43 | 44 | // clear cache and cookies 45 | await removeFromCache(user?._id?.toString()); 46 | 47 | setCookie(res, 'abegAccessToken', 'expired', { maxAge: -1 }); 48 | setCookie(res, 'abegRefreshToken', 'expired', { maxAge: -1 }); 49 | 50 | return AppResponse(res, 200, null, 'Account deleted successfully'); 51 | }); 52 | -------------------------------------------------------------------------------- /src/controllers/user/editUserProfile.ts: -------------------------------------------------------------------------------- 1 | import type { IUser } from '@/common/interfaces'; 2 | import { AppError, AppResponse, setCache, toJSON } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { UserModel } from '@/models'; 5 | import { Request, Response } from 'express'; 6 | 7 | export const editUserProfile = catchAsync(async (req: Request, res: Response) => { 8 | //collect the details to be updated 9 | const { firstName, lastName, phoneNumber, gender } = req.body; 10 | 11 | //get the user id to update from req.user 12 | const UserToUpdateID = req.user?._id; 13 | if (!UserToUpdateID) throw new AppError('Unauthorized, kindly login again.'); 14 | 15 | //Partial makes the objects to update optional while extending the user interface 16 | const objectToUpdate: Partial = { 17 | firstName, 18 | lastName, 19 | phoneNumber, 20 | gender, 21 | }; 22 | 23 | //updates the id with object, new returns the updated user while running mongoose validation 24 | const updatedUser = await UserModel.findByIdAndUpdate({ _id: UserToUpdateID }, objectToUpdate, { 25 | new: true, 26 | runValidators: true, 27 | }); 28 | 29 | if (!updatedUser) { 30 | // no user is found to update 31 | return AppResponse(res, 404, null, 'User not found for update'); 32 | } 33 | 34 | // update the cached user 35 | await setCache(updatedUser?._id?.toString(), { ...toJSON(updatedUser, ['password']), ...req.user }); 36 | 37 | await setCache(`Updated User: ${updatedUser?._id.toString()}`, toJSON(updatedUser, ['password']), 3600); 38 | AppResponse(res, 200, toJSON(updatedUser), 'Profile Successfully Updated'); 39 | }); 40 | -------------------------------------------------------------------------------- /src/controllers/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './deleteAccount'; 2 | export * from './restoreAccount'; 3 | export * from './updateProfilePhoto'; 4 | export * from './editUserProfile'; 5 | export * from './changePassword'; 6 | -------------------------------------------------------------------------------- /src/controllers/user/restoreAccount.ts: -------------------------------------------------------------------------------- 1 | import { AppError, AppResponse, decodeData, getDomainReferer } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import { UserModel } from '@/models'; 4 | import { addEmailToQueue } from '@/queues'; 5 | import { Request, Response } from 'express'; 6 | import { sanitize } from 'express-mongo-sanitize'; 7 | 8 | export const restoreAccount = catchAsync(async (req: Request, res: Response) => { 9 | const { token } = sanitize(req.query); 10 | 11 | if (!token) { 12 | throw new AppError('Token is required', 400); 13 | } 14 | 15 | let decodedToken; 16 | try { 17 | decodedToken = await decodeData(token.toString()); 18 | } catch { 19 | throw new AppError('Invalid or expired token', 400); 20 | } 21 | 22 | if (!decodedToken.token || !decodedToken.id) { 23 | throw new AppError('Invalid token', 400); 24 | } 25 | 26 | const user = await UserModel.findOneAndUpdate( 27 | { _id: decodedToken.id, isDeleted: true, accountRestoreToken: decodedToken.token }, 28 | { 29 | isDeleted: false, 30 | passwordResetRetries: 0, 31 | $unset: { 32 | accountRestoreToken: 1, 33 | passwordResetToken: 1, 34 | passwordResetExpires: 1, 35 | }, 36 | } 37 | ); 38 | 39 | if (!user) { 40 | throw new AppError('Invalid or expired token', 400); 41 | } 42 | 43 | await addEmailToQueue({ 44 | type: 'restoreAccount', 45 | data: { 46 | to: user?.email, 47 | name: user?.firstName || user?.lastName || 'User', 48 | loginLink: `${getDomainReferer(req)}/signin`, 49 | }, 50 | }); 51 | 52 | return AppResponse(res, 200, {}, 'Account restored successfully, please login'); 53 | }); 54 | -------------------------------------------------------------------------------- /src/controllers/user/updateProfilePhoto.ts: -------------------------------------------------------------------------------- 1 | import type { IUser } from '@/common/interfaces'; 2 | import { AppError, AppResponse, setCache, toJSON, uploadSingleFile } from '@/common/utils'; 3 | import { catchAsync } from '@/middlewares'; 4 | import { UserModel } from '@/models'; 5 | import type { Request, Response } from 'express'; 6 | import { DateTime } from 'luxon'; 7 | import { Require_id } from 'mongoose'; 8 | 9 | export const updateProfilePhoto = catchAsync(async (req: Request, res: Response) => { 10 | const { file } = req; 11 | const { user } = req; 12 | 13 | if (!file) { 14 | throw new AppError(`File is required`, 400); 15 | } 16 | 17 | const dateInMilliseconds = DateTime.now().toMillis(); 18 | const fileName = `${user?._id}/profile-images/${dateInMilliseconds}.${file.mimetype.split('/')[1]}`; 19 | 20 | const { secureUrl, blurHash } = await uploadSingleFile({ 21 | fileName, 22 | buffer: file.buffer, 23 | mimetype: file.mimetype, 24 | }); 25 | 26 | if (!secureUrl) { 27 | throw new AppError('Error uploading file', 500); 28 | } 29 | 30 | const updatedUser = (await UserModel.findByIdAndUpdate( 31 | user?._id, 32 | { 33 | photo: secureUrl, 34 | blurHash, 35 | }, 36 | { new: true } 37 | )) as Require_id; 38 | 39 | if (!updatedUser) { 40 | throw new AppError('User not found for update', 404); 41 | } 42 | 43 | await setCache(updatedUser._id.toString()!, { ...user, photo: secureUrl, blurHash }); 44 | 45 | return AppResponse(res, 200, toJSON(updatedUser), 'Profile photo updated successfully'); 46 | }); 47 | -------------------------------------------------------------------------------- /src/middlewares/catchAsyncErrors.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express'; 2 | 3 | type CatchAsyncFunction = (req: Request, res: Response, next: NextFunction) => Promise; 4 | 5 | export const catchAsync = (fn: CatchAsyncFunction) => { 6 | return (req: Request, res: Response, next: NextFunction) => { 7 | fn(req, res, next).catch(next); 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/middlewares/catchSocketAsyncErrors.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '@/common/utils'; 2 | import type { Socket } from 'socket.io'; 3 | 4 | export const catchSocketAsync = (fn: (socket: Socket, next?: () => void) => Promise) => { 5 | return (socket: Socket, next?: () => void) => { 6 | fn(socket, next).catch((err) => { 7 | if (err instanceof AppError) { 8 | console.log(err); 9 | // If it's an AppError, emit the error back to the client 10 | socket.emit('error', { message: err.message, status: err.status }); 11 | } else { 12 | console.log(err); 13 | // If it's not an AppError, emit a generic error message 14 | socket.emit('error', { message: 'Something went wrong', status: 500 }); 15 | } 16 | // Do not call next here 17 | }); 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './catchAsyncErrors'; 2 | export * from './catchSocketAsyncErrors'; 3 | export * from './protect'; 4 | export * from './timeout'; 5 | export * from './validateDataWithZod'; 6 | -------------------------------------------------------------------------------- /src/middlewares/protect.ts: -------------------------------------------------------------------------------- 1 | import { AppError, authenticate, setCookie } from '@/common/utils'; 2 | import { catchAsync } from '@/middlewares'; 3 | import type { NextFunction, Request, Response } from 'express'; 4 | 5 | export const protect = catchAsync(async (req: Request, res: Response, next: NextFunction) => { 6 | // get the cookies from the request headers 7 | const { abegAccessToken, abegRefreshToken } = req.cookies; 8 | 9 | const { currentUser, accessToken } = await authenticate({ abegAccessToken, abegRefreshToken }); 10 | 11 | if (accessToken) { 12 | setCookie(res, 'abegAccessToken', accessToken, { 13 | maxAge: 15 * 60 * 1000, // 15 minutes 14 | }); 15 | } 16 | 17 | // attach the user to the request object 18 | req.user = currentUser; 19 | 20 | const reqPath = req.path; 21 | 22 | // check if user has been authenticated but has not verified 2fa 23 | if (!reqPath.includes('/2fa/') && req.user.twoFA.active) { 24 | const lastLoginTimeInMilliseconds = new Date(currentUser.lastLogin).getTime(); 25 | const lastVerificationTimeInMilliseconds = new Date(currentUser.twoFA.verificationTime as Date).getTime(); 26 | 27 | if (lastLoginTimeInMilliseconds > lastVerificationTimeInMilliseconds) { 28 | throw new AppError('2FA verification is required', 403, { 29 | type: currentUser.twoFA.type, 30 | email: currentUser.email, 31 | }); 32 | } 33 | } 34 | 35 | next(); 36 | }); 37 | -------------------------------------------------------------------------------- /src/middlewares/timeout.ts: -------------------------------------------------------------------------------- 1 | import timeout from 'connect-timeout'; 2 | 3 | // Create the timeout middleware 4 | export const timeoutMiddleware = timeout(60000); 5 | -------------------------------------------------------------------------------- /src/middlewares/validateDataWithZod.ts: -------------------------------------------------------------------------------- 1 | import { AppError } from '@/common/utils'; 2 | import { partialMainSchema, mainSchema } from '@/schemas'; 3 | import { NextFunction, Request, Response } from 'express'; 4 | import { z } from 'zod'; 5 | import { catchAsync } from './catchAsyncErrors'; 6 | 7 | type MyDataShape = z.infer; 8 | 9 | const methodsToSkipValidation = ['GET']; 10 | const routesToSkipValidation = ['/api/v1/auth/signin', '/api/v1/payment-hook/paystack/donation/verify']; 11 | 12 | export const validateDataWithZod = catchAsync(async (req: Request, res: Response, next: NextFunction) => { 13 | // skip validation for defined methods and routes 14 | if (methodsToSkipValidation.includes(req.method) || routesToSkipValidation.includes(req.url)) { 15 | return next(); 16 | } 17 | 18 | const rawData = req.body as Partial; 19 | 20 | if (!rawData) return next(); 21 | 22 | // Validate only if it contains the fields in req.body against the mainSchema 23 | const mainResult = partialMainSchema.safeParse(rawData); 24 | if (!mainResult.success) { 25 | const errorDetails = mainResult.error.formErrors.fieldErrors; 26 | throw new AppError('Validation failed', 422, errorDetails); 27 | } else { 28 | // this ensures that only fields defined in the mainSchema are passed to the req.body 29 | req.body = mainResult.data as Partial; 30 | } 31 | 32 | next(); 33 | }); 34 | -------------------------------------------------------------------------------- /src/models/LocationModel.ts: -------------------------------------------------------------------------------- 1 | import { LocationTypeEnum } from '@/common/constants'; 2 | import type { ILocation } from '@/common/interfaces'; 3 | import mongoose, { Model } from 'mongoose'; 4 | 5 | type locationModel = Model; 6 | 7 | const locationSchema = new mongoose.Schema( 8 | { 9 | country: { 10 | type: String, 11 | }, 12 | city: { 13 | type: String, 14 | }, 15 | postalCode: { 16 | type: String, 17 | }, 18 | ipv4: { 19 | type: String, 20 | }, 21 | ipv6: { 22 | type: String, 23 | }, 24 | geo: { 25 | lat: { 26 | type: String, 27 | }, 28 | lng: { 29 | type: String, 30 | }, 31 | }, 32 | region: { 33 | type: String, 34 | }, 35 | continent: { 36 | type: String, 37 | }, 38 | timezone: { 39 | type: String, 40 | }, 41 | os: { 42 | type: String, 43 | }, 44 | user: { 45 | type: mongoose.Schema.ObjectId, 46 | ref: 'User', 47 | }, 48 | donation: { 49 | type: mongoose.Schema.ObjectId, 50 | ref: 'Donation', 51 | }, 52 | type: { 53 | type: String, 54 | enum: Object.values(LocationTypeEnum), 55 | default: LocationTypeEnum.SIGNIN, 56 | }, 57 | }, 58 | { timestamps: true } 59 | ); 60 | 61 | export const locationModel = (mongoose.models.Location as locationModel) || mongoose.model('Location', locationSchema); 62 | -------------------------------------------------------------------------------- /src/models/campaignCategoryModel.ts: -------------------------------------------------------------------------------- 1 | import type { ICampaignCategory } from '@/common/interfaces'; 2 | import mongoose, { Model } from 'mongoose'; 3 | 4 | type campaignCategoryModel = Model; 5 | 6 | const campaignCategorySchema = new mongoose.Schema( 7 | { 8 | name: { 9 | type: String, 10 | required: true, 11 | unique: true, 12 | }, 13 | image: String, 14 | isDeleted: { 15 | type: Boolean, 16 | default: false, 17 | }, 18 | }, 19 | { timestamps: true } 20 | ); 21 | 22 | // only pick campaigns that are not deleted or suspended 23 | campaignCategorySchema.pre(/^find/, function (this: Model, next) { 24 | // pick deleted campaigns if the query has isDeleted 25 | if (Object.keys(this['_conditions']).includes('isDeleted')) { 26 | this.find({}); 27 | return next(); 28 | } 29 | 30 | // do not select campaigns that are deleted or suspended 31 | this.find({ isDeleted: { $ne: true } }); 32 | next(); 33 | }); 34 | export const campaignCategoryModel = 35 | (mongoose.models.CampaignCategory as campaignCategoryModel) || 36 | mongoose.model('CampaignCategory', campaignCategorySchema); 37 | -------------------------------------------------------------------------------- /src/models/campaignModel.ts: -------------------------------------------------------------------------------- 1 | import { Country, FlaggedReasonTypeEnum, FundraiserEnum, StatusEnum } from '@/common/constants'; 2 | import type { ICampaign } from '@/common/interfaces'; 3 | import mongoose, { Model } from 'mongoose'; 4 | import mongooseAutopopulate from 'mongoose-autopopulate'; 5 | 6 | type campaignModel = Model; 7 | 8 | const campaignSchema = new mongoose.Schema( 9 | { 10 | shortId: { 11 | type: String, 12 | unique: true, 13 | sparse: true, 14 | }, 15 | category: { 16 | type: mongoose.Types.ObjectId, 17 | ref: 'CampaignCategory', 18 | autopopulate: true, 19 | }, 20 | country: { 21 | type: String, 22 | enum: Object.values(Country), 23 | }, 24 | tags: { 25 | type: [String], 26 | default: [], 27 | }, 28 | title: { 29 | type: String, 30 | }, 31 | fundraiser: { 32 | type: String, 33 | enum: [...Object.values(FundraiserEnum)], 34 | }, 35 | goal: { 36 | type: Number, 37 | }, 38 | amountRaised: { 39 | type: Number, 40 | default: 0, 41 | }, 42 | deadline: { 43 | type: Date, 44 | }, 45 | images: [ 46 | { 47 | secureUrl: { 48 | type: String, 49 | required: true, 50 | }, 51 | blurHash: { 52 | type: String, 53 | }, 54 | }, 55 | ], 56 | story: { 57 | type: String, 58 | }, 59 | storyHtml: { 60 | type: String, 61 | }, 62 | creator: { 63 | type: mongoose.Schema.ObjectId, 64 | ref: 'User', 65 | autopopulate: { 66 | select: 'firstName lastName photo blurHash', 67 | }, 68 | }, 69 | status: { 70 | type: String, 71 | enum: [...Object.values(StatusEnum)], 72 | default: StatusEnum.DRAFT, 73 | }, 74 | isFlagged: { 75 | type: Boolean, 76 | default: false, 77 | }, 78 | flaggedReasons: [ 79 | { 80 | type: { 81 | type: String, 82 | enum: [...Object.values(FlaggedReasonTypeEnum)], 83 | }, 84 | reason: String, 85 | }, 86 | ], 87 | isDeleted: { 88 | type: Boolean, 89 | default: false, 90 | select: false, 91 | }, 92 | featured: { 93 | type: Boolean, 94 | default: false, 95 | }, 96 | isPublished: { 97 | type: Boolean, 98 | default: false, 99 | }, 100 | currentStep: { 101 | type: Number, 102 | default: 1, 103 | }, 104 | }, 105 | { timestamps: true, toObject: { virtuals: true }, toJSON: { virtuals: true } } 106 | ); 107 | 108 | campaignSchema.plugin(mongooseAutopopulate); 109 | 110 | campaignSchema.index({ title: 'text' }); 111 | campaignSchema.index({ creator: 1 }); 112 | 113 | // Add a virtual populate field for 'donations' 114 | campaignSchema.virtual('donations', { 115 | ref: 'Donation', // The model to use 116 | localField: '_id', // Find donations where 'localField' 117 | foreignField: 'campaignId', // is equal to 'foreignField' 118 | justOne: false, // And only get the first one found, 119 | options: { 120 | sort: { createdAt: -1 }, 121 | limit: 10, 122 | }, 123 | match: { 124 | hideDonorDetails: false, 125 | }, 126 | }); 127 | 128 | campaignSchema.virtual('totalDonations', { 129 | ref: 'Donation', // The model to use 130 | localField: '_id', // Find donations where 'localField' 131 | foreignField: 'campaignId', // is equal to 'foreignField' 132 | count: true, 133 | }); 134 | 135 | // only pick campaigns that are not deleted or suspended 136 | campaignSchema.pre(/^find/, function (this: Model, next) { 137 | // pick deleted campaigns if the query has isDeleted 138 | if (Object.keys(this['_conditions']).includes('isDeleted')) { 139 | this.find({}); 140 | return next(); 141 | } 142 | 143 | // do not select campaigns that are deleted or suspended 144 | this.find({ isDeleted: { $ne: true } }); 145 | this.populate('donations', ''); 146 | this.populate('totalDonations', ''); 147 | 148 | next(); 149 | }); 150 | 151 | export const campaignModel = (mongoose.models.Campaign as campaignModel) || mongoose.model('Campaign', campaignSchema); 152 | -------------------------------------------------------------------------------- /src/models/contactModel.ts: -------------------------------------------------------------------------------- 1 | import type { IContact } from '@/common/interfaces'; 2 | import mongoose, { Model } from 'mongoose'; 3 | 4 | type ContactModel = Model; 5 | 6 | const contactSchema = new mongoose.Schema( 7 | { 8 | firstName: { 9 | type: String, 10 | required: [true, 'First name is required'], 11 | }, 12 | lastName: { 13 | type: String, 14 | required: false, 15 | default: null, 16 | }, 17 | email: { 18 | type: String, 19 | required: [true, 'Email field is required'], 20 | lowercase: true, 21 | trim: true, 22 | }, 23 | message: { 24 | type: String, 25 | required: [true, 'Message field is required'], 26 | }, 27 | }, 28 | { 29 | timestamps: true, 30 | versionKey: false, 31 | } 32 | ); 33 | 34 | export const contactModel = mongoose.model('Contact', contactSchema); 35 | -------------------------------------------------------------------------------- /src/models/donationModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Model } from 'mongoose'; 2 | import { PaymentStatusEnum } from '@/common/constants'; 3 | import { IDonation } from '@/common/interfaces'; 4 | 5 | type donationModel = Model; 6 | 7 | const donationSchema = new mongoose.Schema( 8 | { 9 | reference: { 10 | type: String, 11 | unique: true, 12 | required: true, 13 | }, 14 | campaignId: { 15 | type: mongoose.Schema.ObjectId, 16 | ref: 'Campaign', 17 | required: true, 18 | }, 19 | donorEmail: { 20 | type: String, 21 | required: true, 22 | }, 23 | donorName: { 24 | type: String, 25 | required: true, 26 | }, 27 | amount: { 28 | type: Number, 29 | required: true, 30 | }, 31 | paymentStatus: { 32 | type: String, 33 | enum: PaymentStatusEnum, 34 | default: PaymentStatusEnum.UNPAID, 35 | }, 36 | paymentDate: { 37 | type: String, 38 | }, 39 | paymentMeta: { 40 | type: Object, 41 | }, 42 | hideDonorDetails: { 43 | type: Boolean, 44 | default: false, 45 | }, 46 | }, 47 | { 48 | timestamps: true, 49 | } 50 | ); 51 | 52 | export const donationModel = (mongoose.models.Donation as donationModel) || mongoose.model('Donation', donationSchema); 53 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './campaignCategoryModel'; 2 | export * from './campaignModel'; 3 | export * from './userModel'; 4 | export * from './donationModel'; 5 | export * from './contactModel'; 6 | -------------------------------------------------------------------------------- /src/models/twoFactorModel.ts: -------------------------------------------------------------------------------- 1 | import { twoFactorTypeEnum } from '@/common/constants'; 2 | import mongoose from 'mongoose'; 3 | 4 | export const TwoFAModel = new mongoose.Schema( 5 | { 6 | type: { 7 | type: String, 8 | enum: Object.values(twoFactorTypeEnum), 9 | default: twoFactorTypeEnum.APP, 10 | }, 11 | secret: { 12 | type: String, 13 | select: false, 14 | }, 15 | recoveryCode: { 16 | type: String, 17 | select: false, 18 | }, 19 | active: { 20 | type: Boolean, 21 | default: false, 22 | }, 23 | verificationTime: { 24 | type: Date, 25 | }, 26 | isVerified: { 27 | type: Boolean, 28 | }, 29 | }, 30 | { 31 | _id: false, 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /src/models/userModel.ts: -------------------------------------------------------------------------------- 1 | import { Gender, IDType, Provider, Role } from '@/common/constants'; 2 | import type { IUser, UserMethods } from '@/common/interfaces'; 3 | import bcrypt from 'bcryptjs'; 4 | import mongoose, { HydratedDocument, Model } from 'mongoose'; 5 | import { TwoFAModel } from './twoFactorModel'; 6 | 7 | type UserModel = Model; 8 | 9 | const userSchema = new mongoose.Schema( 10 | { 11 | firstName: { 12 | type: String, 13 | min: [2, 'First name must be at least 2 characters long'], 14 | max: [50, 'First name must not be more than 50 characters long'], 15 | required: [true, 'First name is required'], 16 | }, 17 | lastName: { 18 | type: String, 19 | min: [2, 'Last name must be at least 2 characters long'], 20 | max: [50, 'Last name must not be more than 50 characters long'], 21 | required: [true, 'Last name is required'], 22 | }, 23 | email: { 24 | type: String, 25 | required: [true, 'Email field is required'], 26 | unique: true, 27 | lowercase: true, 28 | trim: true, 29 | }, 30 | password: { 31 | type: String, 32 | min: [8, 'Password must be at least 8 characters long'], 33 | required: [true, 'Password field is required'], 34 | select: false, 35 | }, 36 | refreshToken: { 37 | type: String, 38 | select: false, 39 | }, 40 | phoneNumber: { 41 | type: String, 42 | unique: true, 43 | sparse: true, 44 | }, 45 | photo: { 46 | type: String, 47 | default: null, 48 | }, 49 | blurHash: { 50 | type: String, 51 | }, 52 | role: { 53 | type: String, 54 | enum: Object.values(Role), 55 | default: Role.User, 56 | }, 57 | isProfileComplete: { 58 | type: Boolean, 59 | default: false, 60 | }, 61 | provider: { 62 | type: String, 63 | enum: Object.values(Provider), 64 | default: Provider.Local, 65 | select: false, 66 | }, 67 | passwordResetToken: { 68 | type: String, 69 | select: false, 70 | }, 71 | passwordResetExpires: { 72 | type: Date, 73 | select: false, 74 | }, 75 | passwordResetRetries: { 76 | type: Number, 77 | default: 0, 78 | select: false, 79 | }, 80 | passwordChangedAt: { 81 | type: Date, 82 | select: false, 83 | }, 84 | ipAddress: { 85 | type: String, 86 | select: false, 87 | }, 88 | loginRetries: { 89 | type: Number, 90 | default: 0, 91 | select: false, 92 | }, 93 | gender: { 94 | type: String, 95 | enum: Object.values(Gender), 96 | }, 97 | verificationMethod: { 98 | type: String, 99 | enum: Object.values(IDType), 100 | }, 101 | isIdVerified: { 102 | type: Boolean, 103 | default: false, 104 | }, 105 | isSuspended: { 106 | type: Boolean, 107 | default: false, 108 | }, 109 | isEmailVerified: { 110 | type: Boolean, 111 | default: false, 112 | }, 113 | isMobileVerified: { 114 | type: Boolean, 115 | default: false, 116 | }, 117 | isDeleted: { 118 | type: Boolean, 119 | default: false, 120 | select: false, 121 | }, 122 | lastLogin: { 123 | type: Date, 124 | default: Date.now(), 125 | }, 126 | verificationToken: { 127 | type: String, 128 | select: false, 129 | }, 130 | accountRestoreToken: { 131 | type: String, 132 | select: false, 133 | }, 134 | twoFA: { 135 | type: TwoFAModel, 136 | default: {}, 137 | }, 138 | isTermAndConditionAccepted: { 139 | type: Boolean, 140 | default: false, 141 | required: [true, 'Term and condition is required'], 142 | }, 143 | }, 144 | { 145 | timestamps: true, 146 | versionKey: false, 147 | } 148 | ); 149 | 150 | // only pick users that are not deleted or suspended 151 | userSchema.pre(/^find/, function (this: Model, next) { 152 | // pick deleted users if the query has isDeleted 153 | if (Object.keys(this['_conditions']).includes('isDeleted')) { 154 | this.find({ isSuspended: { $ne: true } }); 155 | return next(); 156 | } 157 | 158 | // do not select users that are deleted or suspended 159 | this.find({ $or: [{ isDeleted: { $ne: true } }, { isSuspended: { $ne: true } }] }); 160 | next(); 161 | }); 162 | 163 | // Hash password before saving to the database 164 | userSchema.pre('save', async function (next) { 165 | if (!this.isProfileComplete) { 166 | const profiles = [ 167 | this.firstName, 168 | this.lastName, 169 | this.email, 170 | this.phoneNumber, 171 | this.photo, 172 | this.gender, 173 | this.isIdVerified, 174 | this.isMobileVerified, 175 | this.isEmailVerified, 176 | ]; 177 | this.isProfileComplete = profiles.every((profile) => Boolean(profile)); 178 | } 179 | next(); 180 | }); 181 | 182 | // Verify user password 183 | userSchema.method('verifyPassword', async function (this: HydratedDocument, enteredPassword: string) { 184 | if (!this.password) { 185 | return false; 186 | } 187 | const isValid = await bcrypt.compare(enteredPassword, this.password); 188 | return isValid; 189 | }); 190 | 191 | export const UserModel = (mongoose.models.User as UserModel) || mongoose.model('User', userSchema); 192 | -------------------------------------------------------------------------------- /src/queues/campaignQueue.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | import { Job, Queue, Worker, WorkerOptions, QueueEvents } from 'bullmq'; 3 | import IORedis from 'ioredis'; 4 | import { processCampaign } from '@/queues/handlers/processCampaign'; 5 | import { logger } from '@/common/utils'; 6 | 7 | export enum CampaignJobEnum { 8 | PROCESS_CAMPAIGN_REVIEW = 'PROCESS_CAMPAIGN_REVIEW', 9 | } 10 | 11 | const connection = new IORedis({ 12 | port: ENVIRONMENT.REDIS.PORT, 13 | host: ENVIRONMENT.REDIS.URL, 14 | password: ENVIRONMENT.REDIS.PASSWORD, 15 | maxRetriesPerRequest: null, 16 | enableOfflineQueue: false, 17 | offlineQueue: false, 18 | }); 19 | 20 | // Create a new connection in every node instance 21 | const campaignQueue = new Queue('campaignQueue', { 22 | connection, 23 | defaultJobOptions: { 24 | attempts: 3, 25 | backoff: { 26 | type: 'exponential', 27 | delay: 500, 28 | }, 29 | }, 30 | }); 31 | 32 | const workerOptions: WorkerOptions = { 33 | connection, 34 | limiter: { max: 1, duration: 1000 }, // process 1 email every second due to rate limiting of email sender 35 | lockDuration: 5000, // 5 seconds to process the job before it can be picked up by another worker 36 | removeOnComplete: { 37 | age: 3600, // keep up to 1 hour 38 | count: 1000, // keep up to 1000 jobs 39 | }, 40 | removeOnFail: { 41 | age: 24 * 3600, // keep up to 24 hours 42 | }, 43 | // concurrency: 5, // process 5 jobs concurrently 44 | }; 45 | 46 | const campaignWorker = new Worker( 47 | 'campaignQueue', 48 | async (job: Job) => { 49 | try { 50 | if (job.name === CampaignJobEnum.PROCESS_CAMPAIGN_REVIEW) { 51 | await processCampaign(job.data.id); 52 | } 53 | } catch (e) { 54 | console.log('Error processing job', e); 55 | } 56 | }, 57 | workerOptions 58 | ); 59 | // EVENT LISTENERS 60 | // create a queue event listener 61 | const campaignQueueEvents = new QueueEvents('emailQueue', { connection }); 62 | 63 | campaignQueueEvents.on('failed', ({ jobId, failedReason }) => { 64 | console.log(`Job ${jobId} failed with error ${failedReason}`); 65 | logger.error(`Job ${jobId} failed with error ${failedReason}`); 66 | // Do something with the return value of failed job 67 | }); 68 | 69 | campaignQueueEvents.on('waiting', ({ jobId }) => { 70 | console.log(`A job with ID ${jobId} is waiting`); 71 | }); 72 | 73 | campaignQueueEvents.on('completed', ({ jobId, returnvalue }) => { 74 | console.log(`Job ${jobId} completed`, returnvalue); 75 | logger.info(`Job ${jobId} completed`, returnvalue); 76 | // Called every time a job is completed in any worker 77 | }); 78 | 79 | campaignWorker.on('error', (err) => { 80 | // log the error 81 | console.error(err); 82 | logger.error(`Error processing email job: ${err}`); 83 | }); 84 | 85 | const startCampaignQueue = async () => { 86 | await campaignQueue.waitUntilReady(); 87 | await campaignWorker.waitUntilReady(); 88 | }; 89 | 90 | const stopCampaignQueue = async () => { 91 | await campaignQueue.close(); 92 | await campaignWorker.close(); 93 | console.info('campaign queue closed!'); 94 | }; 95 | 96 | export { campaignQueue, campaignWorker, startCampaignQueue, stopCampaignQueue }; 97 | -------------------------------------------------------------------------------- /src/queues/emailQueue.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | import type { EmailJobData } from '@/common/interfaces'; 3 | import { logger } from '@/common/utils'; 4 | import { Job, Queue, QueueEvents, Worker, WorkerOptions } from 'bullmq'; 5 | import IORedis from 'ioredis'; 6 | import { sendEmail } from './handlers/emailHandler'; 7 | 8 | // create a connection to Redis 9 | const connection = new IORedis({ 10 | port: ENVIRONMENT.REDIS.PORT, 11 | host: ENVIRONMENT.REDIS.URL, 12 | password: ENVIRONMENT.REDIS.PASSWORD, 13 | maxRetriesPerRequest: null, 14 | enableOfflineQueue: false, 15 | offlineQueue: false, 16 | }); 17 | 18 | if (connection) { 19 | console.log('Connected to queue redis cluster'); 20 | logger.info('Connected to queue redis cluster'); 21 | } 22 | 23 | // Create a new connection in every node instance 24 | const emailQueue = new Queue('emailQueue', { 25 | connection, 26 | defaultJobOptions: { 27 | attempts: 3, 28 | backoff: { 29 | type: 'exponential', 30 | delay: 1000, 31 | }, 32 | }, 33 | }); 34 | 35 | const addEmailToQueue = async (opts: EmailJobData) => { 36 | const { type, data } = opts; 37 | try { 38 | await emailQueue.add(type, opts, { 39 | ...(data.priority !== 'high' && { priority: 2 }), 40 | }); 41 | } catch (error) { 42 | console.error('Error enqueueing email job:', error); 43 | logger.error('Error enqueueing email job:', error); 44 | throw error; 45 | } 46 | }; 47 | 48 | // define worker options 49 | interface EmailWorkerOptions extends WorkerOptions {} 50 | 51 | const workerOptions: EmailWorkerOptions = { 52 | connection, 53 | limiter: { max: 1, duration: 1000 }, // process 1 email every second due to rate limiting of email sender 54 | lockDuration: 5000, // 5 seconds to process the job before it can be picked up by another worker 55 | removeOnComplete: { 56 | age: 3600, // keep up to 1 hour 57 | count: 1000, // keep up to 1000 jobs 58 | }, 59 | removeOnFail: { 60 | age: 24 * 3600, // keep up to 24 hours 61 | }, 62 | // concurrency: 5, // process 5 jobs concurrently 63 | }; 64 | 65 | // create a worker to process jobs from the email queue 66 | const emailWorker = new Worker( 67 | 'emailQueue', 68 | async (job: Job) => await sendEmail(job.data), 69 | workerOptions 70 | ); 71 | 72 | // EVENT LISTENERS 73 | // create a queue event listener 74 | const emailQueueEvent = new QueueEvents('emailQueue', { connection }); 75 | 76 | emailQueueEvent.on('failed', ({ jobId, failedReason }) => { 77 | console.log(`Job ${jobId} failed with error ${failedReason}`); 78 | logger.error(`Job ${jobId} failed with error ${failedReason}`); 79 | // Do something with the return value of failed job 80 | }); 81 | 82 | emailQueueEvent.on('waiting', ({ jobId }) => { 83 | console.log(`A job with ID ${jobId} is waiting`); 84 | }); 85 | 86 | emailQueueEvent.on('completed', ({ jobId, returnvalue }) => { 87 | console.log(`Job ${jobId} completed`, returnvalue); 88 | logger.info(`Job ${jobId} completed`, returnvalue); 89 | // Called every time a job is completed in any worker 90 | }); 91 | 92 | emailWorker.on('error', (err) => { 93 | // log the error 94 | console.error(err); 95 | logger.error(`Error processing email job: ${err}`); 96 | }); 97 | 98 | // TODO: Implement RETRY logic for failed or stalled jobs 99 | 100 | const startEmailQueue = async () => { 101 | await emailQueue.waitUntilReady(); 102 | await emailWorker.waitUntilReady(); 103 | await emailQueueEvent.waitUntilReady(); 104 | }; 105 | 106 | const stopEmailQueue = async () => { 107 | await emailWorker.close(); 108 | await emailQueue.close(); 109 | console.info('Email queue closed!'); 110 | }; 111 | 112 | export { addEmailToQueue, emailQueue, emailQueueEvent, emailWorker, startEmailQueue, stopEmailQueue }; 113 | -------------------------------------------------------------------------------- /src/queues/handlers/emailHandler.ts: -------------------------------------------------------------------------------- 1 | import { ENVIRONMENT } from '@/common/config'; 2 | import type { EmailJobData } from '@/common/interfaces'; 3 | import { logger } from '@/common/utils'; 4 | import { Resend } from 'resend'; 5 | 6 | import { 7 | accountDeletedEmailTemplate, 8 | accountRestoredEmailTemplate, 9 | forgotPassword, 10 | get2faCodeViaEmailTemplate, 11 | loginNotification, 12 | resetPassword, 13 | welcomeEmail, 14 | } from '../templates'; 15 | import { recoveryKeysEmail } from '../templates/recoveryKeysEmail'; 16 | 17 | const resend = new Resend(ENVIRONMENT.EMAIL.API_KEY); 18 | 19 | const TEMPLATES = { 20 | resetPassword: { 21 | subject: 'Password Reset Successful', 22 | from: 'AbegHelp ', 23 | template: resetPassword, 24 | }, 25 | forgotPassword: { 26 | subject: 'Password Change Request', 27 | from: 'AbegHelp ', 28 | template: forgotPassword, 29 | }, 30 | welcomeEmail: { 31 | subject: 'Welcome to AbegHelp', 32 | from: 'AbegHelp ', 33 | template: welcomeEmail, 34 | }, 35 | deleteAccount: { 36 | subject: 'AbegHelp Account Deleted', 37 | from: 'AbegHelp ', 38 | template: accountDeletedEmailTemplate, 39 | }, 40 | restoreAccount: { 41 | subject: 'AbegHelp Account Restored', 42 | from: 'AbegHelp ', 43 | template: accountRestoredEmailTemplate, 44 | }, 45 | get2faCodeViaEmail: { 46 | subject: 'AbegHelp 2FA Code', 47 | from: 'AbegHelp ', 48 | template: get2faCodeViaEmailTemplate, 49 | }, 50 | recoveryKeysEmail: { 51 | subject: 'AbegHelp 2FA Code', 52 | from: 'AbegHelp ', 53 | template: recoveryKeysEmail, 54 | }, 55 | loginNotification: { 56 | subject: 'AbegHelp Login Notification', 57 | from: 'AbegHelp ', 58 | template: loginNotification, 59 | }, 60 | }; 61 | 62 | export const sendEmail = async (job: EmailJobData) => { 63 | const { data, type } = job as EmailJobData; 64 | const options = TEMPLATES[type]; 65 | 66 | console.log('job send email', job); 67 | console.log('options', options); 68 | console.log(options.template(data)); 69 | try { 70 | const dispatch = await resend.emails.send({ 71 | from: options.from, 72 | to: data.to, 73 | subject: options.subject, 74 | html: options.template(data), 75 | }); 76 | console.log(dispatch); 77 | logger.info(`Resend api successfully delivered ${type} email to ${data.to}`); 78 | } catch (error) { 79 | console.error(error); 80 | logger.error(`Resend api failed to deliver ${type} email to ${data.to}` + error); 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /src/queues/handlers/processCampaign.ts: -------------------------------------------------------------------------------- 1 | import { FlaggedReasonTypeEnum, StatusEnum } from '@/common/constants'; 2 | import { AppError } from '@/common/utils'; 3 | import { campaignModel } from '@/models'; 4 | import BadWords from 'bad-words'; 5 | import { ENVIRONMENT } from '@/common/config'; 6 | import { CampaignJobEnum, campaignQueue } from '@/queues'; 7 | import { OpenAI } from 'openai'; 8 | 9 | export const processCampaign = async (id: string) => { 10 | try { 11 | const reasons: { 12 | type: FlaggedReasonTypeEnum; 13 | reason: string; 14 | }[] = []; 15 | 16 | const campaign = await campaignModel.findById(id); 17 | 18 | if (!campaign) { 19 | throw new AppError('Campaign not found', 404); 20 | } 21 | 22 | // perform checks 23 | const [ 24 | titleIsInAppropriate, 25 | storyIsInAppropriate, 26 | titleAndStoryAreSimilar, 27 | similarCampaignExist, 28 | tagIsInappropriate, 29 | ] = await Promise.all([ 30 | containsInappropriateContent(campaign.title), 31 | containsInappropriateContent(campaign.story), 32 | checkSimilarity(campaign.title, campaign.story), 33 | checkForSimilarCampaign(campaign.creator, campaign.title), 34 | containsInappropriateContent(campaign.tags.join(' ').replaceAll('#', '')), 35 | ]); 36 | 37 | if (titleIsInAppropriate || storyIsInAppropriate) { 38 | reasons.push({ 39 | type: FlaggedReasonTypeEnum.INAPPROPRIATE_CONTENT, 40 | reason: `Campaign ${titleIsInAppropriate ? 'title' : 'story'} contains inappropriate content`, 41 | }); 42 | } 43 | 44 | if (tagIsInappropriate) { 45 | reasons.push({ 46 | type: FlaggedReasonTypeEnum.INAPPROPRIATE_CONTENT, 47 | reason: `tags contains inappropriate content`, 48 | }); 49 | } 50 | if (!titleAndStoryAreSimilar) { 51 | reasons.push({ 52 | type: FlaggedReasonTypeEnum.MISMATCH, 53 | reason: `Campaign story does not seem relevant to fundraising or the title.`, 54 | }); 55 | } 56 | 57 | if (similarCampaignExist) { 58 | reasons.push({ 59 | type: FlaggedReasonTypeEnum.EXISTS, 60 | reason: `Similar campaign already exists in your account.`, 61 | }); 62 | } 63 | 64 | await campaignModel.findByIdAndUpdate(campaign._id, { 65 | flaggedReasons: reasons, 66 | isFlagged: reasons.length > 0 ? true : false, 67 | status: reasons.length > 0 ? StatusEnum.REJECTED : StatusEnum.APPROVED, 68 | isPublished: reasons.length > 0 ? false : true, 69 | }); 70 | 71 | return campaign; 72 | } catch (e) { 73 | await campaignQueue.add(CampaignJobEnum.PROCESS_CAMPAIGN_REVIEW, { id }); 74 | console.log('processCampaign error : ', e); 75 | } 76 | }; 77 | 78 | function containsInappropriateContent(value: string): boolean { 79 | const filter = new BadWords(); 80 | 81 | const result = filter.isProfane(value); 82 | 83 | return result; 84 | } 85 | 86 | const openai = new OpenAI({ 87 | apiKey: ENVIRONMENT.OPENAI.API_KEY, 88 | timeout: 20 * 1000, 89 | maxRetries: 5, 90 | }); 91 | 92 | async function checkSimilarity(title: string, story: string) { 93 | const prompt = `You are a helpful assistant, you are given a title and a story for a fundraising website, please provide a relevance score between 1 and 10, where: 94 | 95 | 1 indicates very little to no relevance to fundraising and the title is not relevant to the story. 96 | 5 indicates moderate relevance, with connections to fundraising and story sufficiently relates to the title. 97 | 10 indicates a very strong and direct relevance to fundraising, with both the title and story closely aligned to the fundraising domain. 98 | 99 | Consider the following: 100 | Both the title and the story should be related to fundraising, charity, or philanthropic endeavors. 101 | Ensure that the content is not spam or irrelevant to the fundraising domain. 102 | Ensure that the context of the title relates the story, providing a cohesive message. 103 | A score of 10 should be given when both the title and the story closely align with the theme of fundraising, conveying a clear and relevant message. This includes titles and stories that promote charitable causes, community initiatives, or donation drives in a cohesive manner. 104 | Conversely, a score of 1 should be given when either the title or the story has no apparent connection to fundraising, charity, or philanthropy, and does not serve the purpose of the fundraising website. 105 | Please return only the relevance score as a whole number, without explanations or context." 106 | 107 | Here is the title and story below 108 | title: ${title} 109 | story: ${story}`; 110 | 111 | try { 112 | const params: OpenAI.Chat.ChatCompletionCreateParams = { 113 | messages: [{ role: 'user', content: prompt }], 114 | model: 'gpt-3.5-turbo', 115 | }; 116 | const response: OpenAI.Chat.ChatCompletion = await openai.chat.completions.create(params); 117 | 118 | const rating = Number(response?.choices[0]?.message?.content); 119 | if (!isNaN(rating) && rating >= 5 && rating <= 10) { 120 | return true; 121 | } else { 122 | return false; 123 | } 124 | } catch (error) { 125 | return false; // Or handle this error case accordingly 126 | } 127 | } 128 | async function checkForSimilarCampaign(creator, title: string): Promise { 129 | //TODO: CHECK db wide with fuzzy matching on title or use a search engine like elastic search 130 | const existingFundraiser = await campaignModel.find({ 131 | creator: creator._id ? creator._id : creator, 132 | title: { $regex: new RegExp('^' + title + '$', 'i') }, 133 | }); 134 | 135 | if (existingFundraiser.length > 1) return true; 136 | 137 | return false; 138 | } 139 | -------------------------------------------------------------------------------- /src/queues/index.ts: -------------------------------------------------------------------------------- 1 | import { startCampaignQueue, stopCampaignQueue } from './campaignQueue'; 2 | import { startEmailQueue, stopEmailQueue } from './emailQueue'; 3 | 4 | const startAllQueuesAndWorkers = async () => { 5 | await startEmailQueue(); 6 | await startCampaignQueue(); 7 | }; 8 | 9 | const stopAllQueuesAndWorkers = async () => { 10 | await stopEmailQueue(); 11 | await stopCampaignQueue(); 12 | }; 13 | 14 | export * from './campaignQueue'; 15 | export * from './emailQueue'; 16 | export { startAllQueuesAndWorkers, stopAllQueuesAndWorkers }; 17 | -------------------------------------------------------------------------------- /src/queues/templates/accountDeletedEmail.ts: -------------------------------------------------------------------------------- 1 | export const accountDeletedEmailTemplate = (data) => { 2 | return ` 3 | 4 | 5 | 6 | 7 | Delete your Account! 8 | 9 | 10 | 11 | 12 | 13 | 14 | 116 | 117 | 118 |
15 | 28 |
AbegHelp - It hurts to see you go! 29 |
30 | 31 | 32 | 33 | 45 | 46 | 47 |
34 | 35 | 36 | 37 | 38 | 41 | 42 | 43 |
Abeg Help Logo 39 |

AbegHelp.me

40 |
44 |
48 |

Delete your Account!

49 | 50 | 51 | 52 | 53 | 54 | 55 |
Abeg help Illustration
56 | 57 | 58 | 59 | 66 | 67 | 68 |
60 |

Dear,${data.name}

61 |

We have processed your request and successfully deleted your account. If this was a mistake or if you change your mind, you have ${data.days} to restore your account.

62 |

To restore your account, please click the button below:

Restore your account 63 |

This verification link will expire in 24 hours for security

64 |

If you did not request this deletion, please contact our support team immediately at donotreply@abeghelp.me.

65 |
69 | 70 | 71 | 72 | 75 | 76 | 77 |
73 |

Best Regards,
The team

74 |
78 | 79 | 80 | 81 | 93 | 94 | 95 |
82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 |
Abeg Help LogoAbeg Help LogoAbeg Help LogoAbeg Help Logo
92 |
96 | 97 | 98 | 99 | 111 | 112 | 113 |
100 | 101 | 102 | 103 | 104 | 107 | 108 | 109 |
Abeg Help Logo 105 |

AbegHelp.me

106 |
110 |
114 |

Your journey into fundraising with ease

115 |
119 | 120 | 121 | `; 122 | }; 123 | -------------------------------------------------------------------------------- /src/queues/templates/forgotPassword.ts: -------------------------------------------------------------------------------- 1 | export const forgotPassword = (data) => { 2 | return ` 3 | 4 | 5 | 6 | 7 | AbegHelp - Forgot your Password! 8 | 9 | 10 | 11 | 12 | 13 | 14 | 31 | 32 | 33 |
15 | 28 |
AbegHelp - Forgot your Password!
29 | 30 |
34 |

AbegHelp - Forgot your password!

35 | 36 | 37 | 38 | 44 | 45 | 46 |
39 |

Hi, ${data.name}

40 |

Seems you forgot your password, no worries. Click the button below to create a new password.

Create new Password 41 |

If you're having trouble clicking the password reset button, copy and paste the URL below into your web browser:${data.token}

42 |

If you did not request a password reset, please ignore this email or contact support if you have questions.

43 |
47 | 48 | 49 | 50 | 53 | 54 | 55 |
51 |

Best Regards,
The team

52 |
56 | 57 | 58 | 59 | 71 | 72 | 73 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
Abeg Help LogoAbeg Help LogoAbeg Help LogoAbeg Help Logo
70 |
74 | 75 | 76 | 77 | 89 | 90 | 91 |
78 | 79 | 80 | 81 | 82 | 85 | 86 | 87 |
Abeg Help Logo 83 |

AbegHelp.me

84 |
88 |
92 |

Your journey into fundraising with ease

93 | 94 | 95 | 96 | 97 | 98 | 99 | `; 100 | }; 101 | -------------------------------------------------------------------------------- /src/queues/templates/get2faCodeViaEmail.ts: -------------------------------------------------------------------------------- 1 | export const get2faCodeViaEmailTemplate = (data) => { 2 | return `
Verify your Account
Abeg Help Logo

AbegHelp.me

Verification Code

Abeg help Illustration

Hi, ${data.name}

To log in your account, use the Verification code below

${data.twoFactorCode}

This expires in ${data.expiryTime} minutes

If you did not initiate this transaction, kindly disregard this email.

Abeg Help Logo Abeg Help Logo Abeg Help Logo Abeg Help Logo
Abeg Help Logo

AbegHelp.me

Your journey into fundraising with ease

3 | `; 4 | }; 5 | -------------------------------------------------------------------------------- /src/queues/templates/index.ts: -------------------------------------------------------------------------------- 1 | export * from './resetPassword'; 2 | export * from './welcomeEmail'; 3 | export * from './forgotPassword'; 4 | export * from './accountDeletedEmail'; 5 | export * from './restoreAccountEmail'; 6 | export * from './get2faCodeViaEmail'; 7 | export * from './loginNotification'; 8 | -------------------------------------------------------------------------------- /src/queues/templates/resetPassword.ts: -------------------------------------------------------------------------------- 1 | export const resetPassword = () => { 2 | return ` 3 | 4 | 5 | 6 | 7 | Reset your AbegHelp! Password 8 | 9 | 10 | 11 | 12 | 13 | 14 | 106 | 107 | 108 |
15 | 28 |
AbegHelp - Reset Password!
29 | 30 | 31 | 32 | 33 | 45 | 46 | 47 |
34 | 35 | 36 | 37 | 38 | 41 | 42 | 43 |
Abeg Help Logo 39 |

AbegHelp.me

40 |
44 |
48 |

Reset your AbegHelp Password!

49 | 50 | 51 | 52 | 53 | 56 | 57 | 58 |
54 |

Your password has been changed successfully. If you didn't initiate this action, kindly reach our support team donotreply@abeghelp.me

55 |
59 | 60 | 61 | 62 | 65 | 66 | 67 |
63 |

Best Regards,
The team

64 |
68 | 69 | 70 | 71 | 83 | 84 | 85 |
72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
Abeg Help LogoAbeg Help LogoAbeg Help LogoAbeg Help Logo
82 |
86 | 87 | 88 | 89 | 101 | 102 | 103 |
90 | 91 | 92 | 93 | 94 | 97 | 98 | 99 |
Abeg Help Logo 95 |

AbegHelp.me

96 |
100 |
104 |

Your journey into fundraising with ease

105 |
109 | 110 | 111 | `; 112 | }; 113 | -------------------------------------------------------------------------------- /src/queues/templates/restoreAccountEmail.ts: -------------------------------------------------------------------------------- 1 | export const accountRestoredEmailTemplate = (data) => { 2 | return ` 3 | 4 | 5 | 6 | 7 | Welcome back to AbegHelp! 8 | 9 | 10 | 11 | 12 | 13 | 14 | 114 | 115 | 116 |
15 | 28 |
AbegHelp - We're glad to have you back! 29 |
30 | 31 | 32 | 33 | 45 | 46 | 47 |
34 | 35 | 36 | 37 | 38 | 41 | 42 | 43 |
Abeg Help Logo 39 |

AbegHelp.me

40 |
44 |
48 |

Welcome Back to AbegHelp!

49 | 50 | 51 | 52 | 53 | 54 | 55 |
Abeg help Illustration
56 | 57 | 58 | 59 | 64 | 65 | 66 |
60 |

Welcome back, ${data.name}

61 |

We're happy to inform you that your account has been successfully restored.

62 |

You can now log in and continue using our services.

Log in 63 |
67 | 68 | 69 | 70 | 73 | 74 | 75 |
71 |

Best Regards,
The team

72 |
76 | 77 | 78 | 79 | 91 | 92 | 93 |
80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 |
Abeg Help LogoAbeg Help LogoAbeg Help LogoAbeg Help Logo
90 |
94 | 95 | 96 | 97 | 109 | 110 | 111 |
98 | 99 | 100 | 101 | 102 | 105 | 106 | 107 |
Abeg Help Logo 103 |

AbegHelp.me

104 |
108 |
112 |

Your journey into fundraising with ease

113 |
117 | 118 | 119 | `; 120 | }; 121 | -------------------------------------------------------------------------------- /src/routes/authRouter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | complete2faSetup, 3 | disable2fa, 4 | forgotPassword, 5 | get2faCodeViaEmail, 6 | resendVerification, 7 | resetPassword, 8 | session, 9 | setupTimeBased2fa, 10 | signIn, 11 | signOut, 12 | signUp, 13 | verifyEmail, 14 | verifyTimeBased2fa, 15 | } from '@/controllers'; 16 | import { pwned } from '@/controllers/pwned'; 17 | import { protect } from '@/middlewares'; 18 | 19 | import { Router } from 'express'; 20 | 21 | const router = Router(); 22 | 23 | router.get('/pwned', pwned); 24 | router.post('/signup', signUp); 25 | router.post('/signin', signIn); 26 | router.post('/password/forgot', forgotPassword); 27 | router.post('/password/reset', resetPassword); 28 | router.post('/verify-email', verifyEmail); 29 | router.post('/resend-verification', resendVerification); 30 | 31 | router.use(protect); // Protect all routes after this middleware 32 | router.get('/session', session); 33 | router.get('/signout', signOut); 34 | router.post('/2fa/setup', setupTimeBased2fa); 35 | router.post('/2fa/complete', complete2faSetup); 36 | router.post('/2fa/verify', verifyTimeBased2fa); 37 | router.get('/2fa/code/email', get2faCodeViaEmail); 38 | router.post('/2fa/disable', disable2fa); 39 | 40 | export { router as authRouter }; 41 | -------------------------------------------------------------------------------- /src/routes/campaignRouter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createCampaign, 3 | deleteCampaign, 4 | createOrUpdateCategory, 5 | featuredCampaigns, 6 | getCategories, 7 | reviewCampaign, 8 | getAllCampaigns, 9 | getOneCampaign, 10 | deleteCategory, 11 | publishCampaign, 12 | } from '@/controllers'; 13 | import { protect } from '@/middlewares'; 14 | import express from 'express'; 15 | import { multerUpload } from '@/common/config'; 16 | 17 | const router = express.Router(); 18 | 19 | router.get('/featured', featuredCampaigns); 20 | router.get('/all', getAllCampaigns); 21 | router.get('/one/:shortId', getOneCampaign); 22 | router.get('/user/:userId', getAllCampaigns); 23 | router.get('/categories', getCategories); 24 | 25 | router.use(protect); 26 | // campaign category 27 | router.post('/publish', publishCampaign); 28 | router.post('/category', multerUpload.single('image'), createOrUpdateCategory); 29 | router.post('/category/delete', deleteCategory); 30 | 31 | // campaign 32 | router.post('/create/:step', multerUpload.array('photos', 5), createCampaign); 33 | router.post('/review/:id', reviewCampaign); 34 | router.post('/delete', deleteCampaign); 35 | 36 | export { router as campaignRouter }; 37 | -------------------------------------------------------------------------------- /src/routes/contactRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { createContactMessage, getAllContactMessages, getOneContactMessage } from '@/controllers'; 3 | 4 | const router = express.Router(); 5 | 6 | // TODO: add admin guards for this route 7 | 8 | router.post('/create', createContactMessage); 9 | router.get('/all', getAllContactMessages); 10 | router.get('/:id', getOneContactMessage); 11 | 12 | export { router as contactRouter }; 13 | -------------------------------------------------------------------------------- /src/routes/donationRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { createDonation } from '@/controllers/donation/create'; 3 | 4 | const router = express.Router(); 5 | 6 | router.post('/create', createDonation); 7 | 8 | export { router as donationRouter }; 9 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authRouter'; 2 | export * from './campaignRouter'; 3 | export * from './userRouter'; 4 | export * from './donationRouter'; 5 | export * from './paymentHookRouter'; 6 | export * from './contactRouter'; 7 | -------------------------------------------------------------------------------- /src/routes/paymentHookRouter.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { paystackHook } from '@/controllers/payment_hooks/paystack'; 3 | import * as expressIpFilter from 'express-ipfilter'; // Import the entire module 4 | import { ENVIRONMENT } from '@/common/config'; 5 | const { IpFilter } = expressIpFilter; 6 | 7 | let allowedPaystackIps: string[]; 8 | 9 | if (ENVIRONMENT.APP.ENV === 'production') { 10 | allowedPaystackIps = ['52.31.139.75', '52.49.173.169', '52.214.14.220']; 11 | } else { 12 | allowedPaystackIps = ['127.0.0.1', '::1', '::ffff:127.0.0.1']; 13 | } 14 | 15 | const router = express.Router(); 16 | 17 | router.post('/paystack/donation/verify', IpFilter(allowedPaystackIps, { mode: 'allow' }), paystackHook); 18 | 19 | export { router as paymentHookRouter }; 20 | -------------------------------------------------------------------------------- /src/routes/userRouter.ts: -------------------------------------------------------------------------------- 1 | import { multerUpload } from '@/common/config'; 2 | import { changePassword, deleteAccount, editUserProfile, restoreAccount, updateProfilePhoto } from '@/controllers'; 3 | import { protect } from '@/middlewares'; 4 | import express from 'express'; 5 | const router = express.Router(); 6 | 7 | router.post('/restore', restoreAccount); 8 | 9 | router.use(protect); // Protect all routes after this middleware 10 | router.post('/update-profile', editUserProfile); 11 | router.post('/profile-photo', multerUpload.single('photo'), updateProfilePhoto); 12 | router.delete('/delete-account', deleteAccount); 13 | router.post('/change-password', changePassword); 14 | export { router as userRouter }; 15 | -------------------------------------------------------------------------------- /src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './main'; 2 | -------------------------------------------------------------------------------- /src/schemas/main.ts: -------------------------------------------------------------------------------- 1 | import { Country, FundraiserEnum, VerifyTimeBased2faTypeEnum, twoFactorTypeEnum } from '@/common/constants'; 2 | import { dateFromString } from '@/common/utils'; 3 | import { PhoneNumberUtil } from 'google-libphonenumber'; 4 | import { z } from 'zod'; 5 | 6 | const verifyPhoneNumber = (value: string) => { 7 | const phoneUtil = PhoneNumberUtil.getInstance(); 8 | if (!value.includes('234') || value.includes('+')) return false; 9 | const number = phoneUtil.parse(`+${value}`, 'NG'); 10 | return phoneUtil.isValidNumber(number); 11 | }; 12 | 13 | const passwordRegexMessage = 14 | 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character or symbol'; 15 | 16 | export const mainSchema = z.object({ 17 | firstName: z 18 | .string() 19 | .min(2, 'First name must be at least 2 characters long') 20 | .max(50, 'First name must not be 50 characters long') 21 | .refine((name) => /^(?!.*-[a-z])[A-Z][a-z'-]*(?:-[A-Z][a-z'-]*)*(?:'[A-Z][a-z'-]*)*$/g.test(name), { 22 | message: 23 | 'First name must be in sentence case, can include hyphen, and apostrophes (e.g., "Ali", "Ade-Bright" or "Smith\'s").', 24 | }), 25 | lastName: z 26 | .string() 27 | .min(2, 'Last name must be at least 2 characters long') 28 | .max(50, 'Last name must not be 50 characters long') 29 | .refine((name) => /^(?!.*-[a-z])[A-Z][a-z'-]*(?:-[A-Z][a-z'-]*)*(?:'[A-Z][a-z'-]*)*$/g.test(name), { 30 | message: 31 | 'Last name must be in sentence case, can include hyphen, and apostrophes (e.g., "Ali", "Ade-Bright" or "Smith\'s").', 32 | }), 33 | email: z.string().email('Please enter a valid email address!'), 34 | password: z 35 | .string() 36 | .min(8, 'Password must have at least 8 characters!') 37 | .regex(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).*$/, { 38 | message: passwordRegexMessage, 39 | }), 40 | confirmPassword: z 41 | .string() 42 | .min(8, 'Confirm Password must have at least 8 characters!') 43 | .regex(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).*$/, { 44 | message: passwordRegexMessage, 45 | }), 46 | code: z.string().min(6, 'Code must be at least 6 characters long'), 47 | phoneNumber: z 48 | .string() 49 | .min(10, 'Last name must be at least 10 characters long') 50 | .refine((value) => verifyPhoneNumber(value), { 51 | message: 'Invalid nigerian phone number. e.g valid format: 234xxxxxxxxxx', 52 | }), 53 | gender: z.enum(['male', 'female', 'other', 'none'], { 54 | errorMap: () => ({ message: 'Please choose one of the gender options' }), 55 | }), 56 | token: z.string(), 57 | userId: z.string().regex(/^[a-f\d]{24}$/i, { 58 | message: `Invalid userId`, 59 | }), 60 | isTermAndConditionAccepted: z.boolean(), 61 | receiveCodeViaEmail: z.boolean(), 62 | twoFactorType: z.enum([twoFactorTypeEnum.APP, twoFactorTypeEnum.EMAIL]), 63 | country: z.enum([...Object.values(Country)] as [string, ...string[]]), 64 | tags: z.string().array(), 65 | description: z.string(), 66 | twoFactorVerificationType: z 67 | .enum([ 68 | VerifyTimeBased2faTypeEnum.CODE, 69 | VerifyTimeBased2faTypeEnum.EMAIL_CODE, 70 | VerifyTimeBased2faTypeEnum.DISABLE_2FA, 71 | ]) 72 | .default(VerifyTimeBased2faTypeEnum.CODE), 73 | name: z.string(), 74 | categoryId: z.string().regex(/^[a-f\d]{24}$/i, { 75 | message: `Invalid categoryId`, 76 | }), 77 | title: z.string().min(3), 78 | fundraiser: z.enum([...Object.values(FundraiserEnum)] as [string, ...string[]]), 79 | goal: z.number().min(1), 80 | deadline: z.custom((value) => dateFromString(value as string)), 81 | story: z.string().min(100), 82 | storyHtml: z.string(), 83 | campaignId: z.string().regex(/^[a-f\d]{24}$/i, { 84 | message: `Invalid campaignId`, 85 | }), 86 | donorEmail: z.string().email(), 87 | donorName: z.string(), 88 | amount: z.number().positive(), 89 | hideMyDetails: z.boolean().default(false), 90 | message: z.string().min(10), 91 | oldPassword: z.string().min(8), 92 | newPassword: z 93 | .string() 94 | .min(8, 'Password must have at least 8 characters!') 95 | .regex(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*\W).*$/, { 96 | message: passwordRegexMessage, 97 | }), 98 | redirectUrl: z.string().url(), 99 | }); 100 | 101 | // Define the partial for partial validation 102 | export const partialMainSchema = mainSchema.partial(); 103 | -------------------------------------------------------------------------------- /src/scripts/seeders/index.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | import mongoose from 'mongoose'; 3 | import { Category, Country, FundraiserEnum, Gender, Provider, StatusEnum } from '../../common/constants'; 4 | import { UserModel, campaignCategoryModel, campaignModel } from '../../models'; 5 | import { customAlphabet } from 'nanoid'; 6 | import { ICampaignCategory, IUser } from '../../common/interfaces'; 7 | 8 | const nanoid = customAlphabet('123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ', 6); 9 | 10 | async function seedCampaigns(size?: number) { 11 | // Seed data 12 | try { 13 | const campaignsToSeed: unknown[] = []; 14 | 15 | let creator: IUser | null = null; 16 | let category: ICampaignCategory | null = null; 17 | 18 | for (let i = 0; i < size! ?? 100; i++) { 19 | if (i % 3 === 0) { 20 | try { 21 | creator = await UserModel.create({ 22 | firstName: faker.internet.userName(), 23 | lastName: faker.internet.userName(), 24 | email: faker.internet.email(), 25 | password: faker.internet.password(), 26 | phoneNumber: faker.phone.number(), 27 | isProfileComplete: true, 28 | provider: Provider.Local, 29 | isTermAndConditionAccepted: true, 30 | gender: Gender.Male, 31 | }); 32 | } catch (error) { 33 | console.log(`Error creating creator ${error}`); 34 | } 35 | } 36 | 37 | if (!creator) { 38 | console.log('Unable to create creator'); 39 | continue; 40 | } 41 | 42 | if (i % 2 === 0) { 43 | const categoryName = faker.helpers.arrayElement(Object.values(Category)); 44 | 45 | category = await campaignCategoryModel.findOneAndUpdate( 46 | { 47 | name: categoryName, 48 | }, 49 | { 50 | name: categoryName, 51 | isDeleted: false, 52 | image: faker.image.avatar(), 53 | }, 54 | { upsert: true, new: true } 55 | ); 56 | } 57 | 58 | if (!category) { 59 | console.log('Unable to create category'); 60 | continue; 61 | } 62 | 63 | const newCampaign = { 64 | shortId: nanoid(), 65 | category: category!['_id'], 66 | country: faker.helpers.arrayElement(Object.values(Country)), 67 | tags: [faker.lorem.word(), faker.lorem.word()], // Generate random tags 68 | title: faker.lorem.words(10), 69 | fundraiser: faker.helpers.arrayElement(Object.values(FundraiserEnum)), 70 | goal: faker.number.int({ min: 5000, max: 100000 }), 71 | amountRaised: faker.number.int({ min: 5000, max: 100000 }), 72 | deadline: faker.date.future(), 73 | images: [ 74 | { 75 | secureUrl: faker.image.url(), 76 | blurHash: faker.image.urlPlaceholder(), 77 | }, 78 | ], 79 | story: faker.lorem.sentence(100), 80 | storyHtml: faker.lorem.sentence(100), 81 | creator: creator!['_id'], 82 | status: StatusEnum.APPROVED, 83 | isFlagged: faker.datatype.boolean(), 84 | flaggedReasons: [], 85 | isDeleted: false, 86 | featured: faker.datatype.boolean(), 87 | isPublished: true, 88 | }; 89 | 90 | campaignsToSeed.push(newCampaign); 91 | } 92 | 93 | // Insert data into MongoDB 94 | await campaignModel.insertMany(campaignsToSeed); 95 | 96 | console.log('Campaigns seeded successfully.'); 97 | } catch (error) { 98 | console.error('Error seeding campaigns:', error); 99 | } finally { 100 | // Disconnect from MongoDB 101 | await mongoose.disconnect(); 102 | } 103 | } 104 | 105 | export async function runSeeders(size: number) { 106 | try { 107 | // Seed the campaigns 108 | seedCampaigns(size); 109 | } catch (error) { 110 | console.log('Error seeding campaigns:', error); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | // observations - JC 2 | 1. send email notification to donor and campaign owner when new donation 3 | 2. add endpoints for bookmarking of campaign 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | /* Basic Options */ 5 | // "incremental": true, /* Enable incremental compilation */ 6 | "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */, 7 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 8 | // "lib": [], /* Specify library files to be included in the compilation. */ 9 | "allowJs": true /* Allow javascript files to be compiled. */, 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 12 | //"declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 14 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | "outDir": "./build" /* Redirect output structure to the directory. */, 17 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 18 | // "composite": true, /* Enable project compilation */ 19 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 20 | // "removeComments": true, /* Do not emit comments to output. */ 21 | // "noEmit": true, /* Do not emit outputs. */ 22 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 23 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 24 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 25 | 26 | /* Strict Type-Checking Options */ 27 | "strict": true /* Enable all strict type-checking options. */, 28 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, 29 | // "strictNullChecks": true, /* Enable strict null checks. */ 30 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 42 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */ 43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 44 | 45 | /* Module Resolution Options */ 46 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 47 | "baseUrl": "./" /* Base directory to resolve non-absolute module names. */, 48 | "paths": { 49 | "@/*": ["./src/*"] 50 | } /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */, 51 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 52 | // "typeRoots": [], /* List of folders to include type definitions from. */ 53 | // "types": [], /* Type declaration files to be included in compilation. */ 54 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 55 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 56 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 57 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 58 | 59 | /* Source Map Options */ 60 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 63 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 64 | 65 | /* Experimental Options */ 66 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 67 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 68 | 69 | /* Advanced Options */ 70 | "skipLibCheck": true /* Skip type checking of declaration files. */, 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */, 72 | "resolveJsonModule": true 73 | }, 74 | "exclude": ["node_modules"] 75 | } 76 | --------------------------------------------------------------------------------