├── .env.example ├── .env.test ├── .github ├── FUNDING.yml └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .vscode └── launch.json ├── CONTRIBUTING.md ├── README.md ├── content └── email_templates │ ├── README.md │ ├── blessing-05-21.html │ ├── layout.html │ ├── mentor-application-approved.html │ ├── mentor-application-declined.html │ ├── mentor-application-received.html │ ├── mentor-freeze.html │ ├── mentor-not-active.html │ ├── mentorship-accepted.html │ ├── mentorship-cancelled.html │ ├── mentorship-declined.html │ ├── mentorship-reminder.html │ ├── mentorship-requested.html │ ├── show.js │ └── welcome.html ├── docker-compose-db.yml ├── docker-compose.yml ├── docs ├── cc-api-spec.json ├── favicon-16x16.png ├── favicon-32x32.png ├── index.html ├── oauth2-redirect.html ├── swagger-ui-bundle.js ├── swagger-ui-bundle.js.map ├── swagger-ui-standalone-preset.js ├── swagger-ui-standalone-preset.js.map ├── swagger-ui.css ├── swagger-ui.css.map ├── swagger-ui.js └── swagger-ui.js.map ├── mongo-common-queries ├── delete-all-mentorships.mongodb ├── mentorship-cancelled-to-new.mongodb └── reset-mentorship-sent-at.mongodb ├── nest-cli.json ├── nodemon-debug.json ├── nodemon-emails.json ├── nodemon.json ├── package.json ├── scripts ├── addroles.js ├── import-mentors.ts └── set-availability.js ├── src ├── app.module.ts ├── config │ └── index.ts ├── database │ ├── database.module.ts │ └── database.providers.ts ├── logger.ts ├── main.ts ├── middlewares │ └── auth.middleware.ts ├── modules │ ├── admin │ │ ├── __tests__ │ │ │ └── mentors.controller.spec.ts │ │ ├── admin.controller.ts │ │ └── admin.module.ts │ ├── common │ │ ├── auth0.service.ts │ │ ├── common.module.ts │ │ ├── common.providers.ts │ │ ├── dto │ │ │ ├── application.dto.ts │ │ │ ├── filter.dto.ts │ │ │ ├── findOneParams.dto.ts │ │ │ ├── mentorfilters.dto.ts │ │ │ ├── pagination.dto.ts │ │ │ ├── user-record.dto.ts │ │ │ └── user.dto.ts │ │ ├── file.service.ts │ │ ├── interfaces │ │ │ ├── application.interface.ts │ │ │ ├── filemeta.interface.ts │ │ │ ├── user-record.interface.ts │ │ │ └── user.interface.ts │ │ ├── mentors.service.ts │ │ ├── pipes │ │ │ └── pagination.pipe.ts │ │ ├── schemas │ │ │ ├── application.schema.ts │ │ │ ├── user-record.schema.ts │ │ │ └── user.schema.ts │ │ └── users.service.ts │ ├── email │ │ ├── email.module.ts │ │ ├── email.service.ts │ │ └── interfaces │ │ │ └── email.interface.ts │ ├── lists │ │ ├── __tests__ │ │ │ ├── favorites.controller.spec.ts │ │ │ └── lists.controller.spec.ts │ │ ├── dto │ │ │ └── list.dto.ts │ │ ├── favorites.controller.ts │ │ ├── interfaces │ │ │ └── list.interface.ts │ │ ├── list.providers.ts │ │ ├── lists.controller.ts │ │ ├── lists.module.ts │ │ ├── lists.service.ts │ │ └── schemas │ │ │ └── list.schema.ts │ ├── mentors │ │ ├── __tests__ │ │ │ └── mentors.controller.spec.ts │ │ ├── mentors.controller.ts │ │ └── mentors.module.ts │ ├── mentorships │ │ ├── __tests__ │ │ │ └── mentorships.controller.spec.ts │ │ ├── dto │ │ │ ├── mentorship.dto.ts │ │ │ ├── mentorshipSummary.dto.ts │ │ │ └── mentorshipUpdatePayload.dto.ts │ │ ├── interfaces │ │ │ └── mentorship.interface.ts │ │ ├── mentorships.controller.ts │ │ ├── mentorships.module.ts │ │ ├── mentorships.providers.ts │ │ ├── mentorships.service.ts │ │ ├── mentorshipsToDto.ts │ │ └── schemas │ │ │ └── mentorship.schema.ts │ ├── reports │ │ ├── __tests__ │ │ │ └── reports.controller.spec.ts │ │ ├── interfaces │ │ │ └── totals.interface.ts │ │ ├── reports.controller.ts │ │ ├── reports.module.ts │ │ └── reports.service.ts │ └── users │ │ ├── __tests__ │ │ └── users.controller.spec.ts │ │ ├── users.controller.ts │ │ └── users.module.ts └── utils │ ├── mimes.ts │ ├── objectid.ts │ └── request.d.ts ├── test ├── api │ ├── mentors.e2e-spec.ts │ ├── mentorships.e2e-spec.ts │ └── users.e2e-spec.ts ├── jest-e2e.json ├── setup.ts ├── teardown.ts └── utils │ ├── jwt.ts │ └── seeder.ts ├── tsconfig.build.json ├── tsconfig.json ├── tslint.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | 3 | # The mongo database URL to connect 4 | MONGO_DATABASE_URL=mongodb://localhost/codingcoach 5 | 6 | # Auth0 credentials for login 7 | AUTH0_DOMAIN=DOMAIN.eu.auth0.com 8 | AUTH0_FRONTEND_CLIENT_ID=client-id-from-auth0 9 | AUTH0_FRONTEND_CLIENT_SECRET=secret-from-auth0 10 | 11 | # Auth0 credentials to request data from auth0 APIs 12 | AUTH0_BACKEND_CLIENT_ID=machine-to-machine-client-id-from-auth0 13 | AUTH0_BACKEND_CLIENT_SECRET=machine-to-machine-secret-from-auth0 14 | 15 | SENDGRID_API_KEY=sendgrid-api-key 16 | 17 | # The public folder where we can upload public assets 18 | PUBLIC_FOLDER=public 19 | 20 | # CORS origins restriction 21 | CORS_ORIGIN=* 22 | 23 | # Use this port to serve as the API for find-a-mentor (the FE) 24 | # PORT=3002 25 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | AUTH0_DOMAIN=test.us.auth0.com 3 | AUTH0_FRONTEND_CLIENT_ID=test-frontend-client-id 4 | AUTH0_FRONTEND_CLIENT_SECRET=test-frontend-client-secret 5 | AUTH0_BACKEND_CLIENT_ID=test-backend-client-id 6 | AUTH0_BACKEND_CLIENT_SECRET=test-backend-client-secret 7 | SENDGRID_API_KEY=sendgrid-api-key 8 | PUBLIC_FOLDER=public 9 | CORS_ORIGIN=* 10 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: codingcoach_io 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Tests 4 | 5 | on: 6 | pull_request: 7 | branches: 8 | - master 9 | push: 10 | branches: 11 | - master 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | build: 16 | 17 | runs-on: ubuntu-latest 18 | 19 | strategy: 20 | matrix: 21 | node-version: [14.x] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: yarn 30 | - run: npm run build --if-present 31 | - run: npm test 32 | - run: npm run test:e2e 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /public 3 | /dist 4 | /node_modules 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | config/deploy.rb 38 | local-mongo 39 | 40 | # Environment configurations 41 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | script: 5 | - yarn lint 6 | - yarn test 7 | - yarn test:e2e 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach by Process ID", 11 | "processId": "${command:PickProcess}" 12 | }, 13 | { 14 | "type": "node", 15 | "request": "launch", 16 | "name": "Launch Program", 17 | "program": "${workspaceFolder}/src/main.ts", 18 | "preLaunchTask": "tsc: build - tsconfig.json", 19 | "outFiles": [ 20 | "${workspaceFolder}/dist/**/*.js" 21 | ] 22 | } 23 | ] 24 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Thank you for your interest on contributing to Coding Coach! In this guide you will learn how to setup the backend on your local machine. 3 | 4 | ## Workflow 5 | 6 | This section describes the workflow we are going to follow when working in a new feature or fixing a bug. If you want to contribute, please follow these steps: 7 | 8 | 1. Fork this project 9 | 2. Clone the forked project to your local environment, for example: `git clone git@github.com:crysfel/find-a-mentor-api.git` (Make sure to replace the URL to your own forked repository). 10 | 3. Add the original project as a remote, in this example the name is `upstream`, feel free to use whatever name you want. `git remote add upstream git@github.com:Coding-Coach/find-a-mentor-api.git`. 11 | 12 | Forking the project will create a copy of that project in your own GitHub account, you will commit your work against your own repository. 13 | 14 | ### Updating your local 15 | 16 | In order to update your local environment to the latest version on `master`, you will have to pull the changes using the `upstream` repository, for example: `git pull upstream master`. This will pull all the new commits from the upstream repository to your local environment. 17 | 18 | ### Features/Bugs 19 | 20 | When working on a new feature, create a new branch `feature/something` from the `master` branch, for example `feature/login-form`. Commit your work against this new branch and push everything to your forked project. Once everything is completed, you should create a PR to the original project. Make sure to add a description about your work. 21 | 22 | When fixing a bug, create a new branch `fix/something` from the `master` branch, for example `fix/date-format`. When completed, push your commits to your forked repository and create a PR from there. Please make sure to describe what was the problem and how did you fix it. 23 | 24 | ### Updating your local branch 25 | 26 | Let's say you've been working on a feature for a couple days, most likely there are new changes in `master` and your branch is behind. In order to update it to the latest (You might not need/want to do this) you need to pull the latest changes on `master` and then rebase your current branch. 27 | 28 | ```bash 29 | $ git checkout master 30 | $ git pull upstream master 31 | $ git checkout feature/something-awesome 32 | $ git rebase master 33 | ``` 34 | 35 | After this, your commits will be on top of the `master` commits. From here you can push to your `origin` repository and create a PR. 36 | 37 | You might have some conflicts while rebasing, try to resolve the conflicts for each individual commit. Rebasing is intimidating at the beginning, if you need help don't be afraid to reach out in slack. 38 | 39 | ### Pull Requests 40 | 41 | In order to merge a PR, it will first go through a review process. Once approved, we will merge to `master` branch using the `Squash` button in github. 42 | 43 | When using squash, all the commits will be squashed into one. The idea is to merge features/fixes as oppose of merging each individual commit. This helps when looking back in time for changes in the code base, and if the PR has a great comment, it's easier to know why that code was introduced. 44 | 45 | 46 | ## Setting up vendors 47 | Before setting up the project, you will need to signup to the following third party vendors: 48 | 49 | - [Auth0](https://auth0.com/signup), we use auth0 to handle authentication in the app. 50 | - [SendGrid](https://sendgrid.com/pricing/), we use this service to send emails, you can use the free tier. 51 | 52 | In order to setup the third party vendors, you will need to create your `.env` file at the root folder. There's an `.env.example` file that you can use for reference. Just duplicate this file and name it `.env`. 53 | 54 | ```bash 55 | $ cp .env.example .env 56 | ``` 57 | 58 | ### Configuring Auth0 59 | After creating a new account, setting your tenant domain and region. You will need to create two applications. 60 | 61 | - **Single Page Web Application**, for the client app to handle the redirects and tokens. 62 | - **Machine to Machine Application**, for the backend server to pull data from auth0 to create a user profile. 63 | 64 | #### Single Page Web Application 65 | Click the `Create Application` button on the dashboard page, give it a name and select `Single Page Web Applications` from the given options, then click the `Create` button. 66 | 67 | Once the app gets created click the `Settings` tab, from here you will need to copy to your `.env` file the following values: 68 | 69 | ``` 70 | AUTH0_DOMAIN=YOUR-DOMAIN.eu.auth0.com 71 | AUTH0_FRONTEND_CLIENT_ID=client-id-from-auth0 72 | AUTH0_FRONTEND_CLIENT_SECRET=client-secret-from-auth0 73 | ``` 74 | 75 | Next, you need to set `http://localhost:3000` as the value for `Allowed Callback URLs`, `Allowed Web Origins`, `Allowed Logout URLs` and `Allowed Origins (CORS)`. These fields are on the settings tabs of your new SPA app in auth0. 76 | 77 | #### Machine to Machine Application 78 | Click the `Create Application` button on the dashboard page, give it a name and select `Machine to Machine Applications` from the given options, then click the `Create` button. 79 | 80 | Machine to Machine Applications require at least one authorized API, from the dropdown select the `Auth0 Management Api`. 81 | 82 | After that you will need to select the following scopes: 83 | 84 | - `read:users` 85 | - `read:roles` 86 | - `delete:users` 87 | 88 | These are the only scopes we need, but you can select all if you want. 89 | 90 | After selecting the scope, click the `Authorize` button to create the app, then go to the `Settings` tab. 91 | 92 | Open the `.env` file, and then add the following two values to the these configs: 93 | 94 | ``` 95 | AUTH0_BACKEND_CLIENT_ID=client-id-from-auth0 96 | AUTH0_BACKEND_CLIENT_SECRET=client-secret-from-auth0 97 | ``` 98 | 99 | And that's all! Your environment is ready to use Auth0 to authenticate users! 🎉 100 | 101 | ### Configuring SendGrid 102 | We use sendgrid to send transactional emails, as well as managing our newsletter. 103 | 104 | After signing up for the free plan, in the left menu go to `Settings - API Keys` and click the `Create Create Api` button at the top. 105 | 106 | Give it a name to your new api key, select `Full Access` from the menu and click `Create & View` button. 107 | 108 | Make sure to copy the key and save it in a safe place (because SendGrid will not show this key again), then open your `.env` file an set the key value as follow: 109 | 110 | ``` 111 | SENDGRID_API_KEY=sendgrid-api-key-here 112 | ``` 113 | 114 | That's all! Now you can start sending emails from the app. 115 | 116 | ## Setting up the database 117 | We use [mongodb](https://www.mongodb.com/) to store our data. You have a couple options here: 118 | 119 | 1. [Download](https://www.mongodb.com/download-center/community) and install mongodb in your system 120 | 2. Use [docker](https://www.docker.com/) to run mongo in a container, we provide a script to run it easily 121 | 3. Use [AtlasDB](https://www.mongodb.com/cloud/atlas) for development (they have a free plan) 122 | 123 | 124 | ### Installing mongodb 125 | This is the easiest way to run the app, as you only need to install mongo and then go to the next step in this guide. 126 | 127 | ### Using Docker 128 | If using docker, just run the container using docker compose: 129 | 130 | ```bash 131 | $ docker-compose -f docker-compose-db.yml up -d 132 | ``` 133 | 134 | ### Using Atlas 135 | If using AtlasDB, you will need to do some additional steps such as creating a new user, whitelisting your IP address and a couple other things, just follow the [documentation](https://docs.atlas.mongodb.com/connect-to-cluster/) and you should be good to go. 136 | 137 | Then update the `.env` file with the AtlasDB URL to connect to your cluster. 138 | 139 | ``` 140 | MONGO_DATABASE_URL=mongodb+srv://:@.mongodb.net/test?retryWrites=true&w=majority 141 | ``` 142 | 143 | You will get this value from Atlas 144 | 145 | ## Running the app 146 | After setting up the vendors and the database, all that's left is to install the dependencies using yarn and run the code! 147 | 148 | ``` 149 | $ yarn install 150 | $ yarn start:dev 151 | ``` 152 | 153 | That's all! The API should be up and running. You can take a look at the [API Documentation](https://api-staging.codingcoach.io/) to learn about the endpoinds we already have. 154 | 155 | 156 | ## FAQ 157 | #### How can I update a user's role? 158 | By default new users are assigned the role of `Member`, in order to set a user as an `Admin`, all you have to do is run the following task in your terminal: 159 | 160 | ``` 161 | $ yarn user:roles --email crysfel@bleext.com --roles 'Admin,Member' 162 | ``` 163 | 164 | Or, you can always update the database directly 🤓 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Tests](https://github.com/Coding-Coach/find-a-mentor-api/actions/workflows/main.yml/badge.svg)](https://github.com/Coding-Coach/find-a-mentor-api/actions/workflows/main.yml) 3 | 4 | ## Description 5 | 6 | A simple REST API to support the [Coding Coach mentors' app](https://mentors.codingcoach.io/). 7 | 8 | ### Contributing 9 | If you would like to contribute to this project, just follow the [contributing guide](CONTRIBUTING.md), it will show you how to setup the project in your local environment. 10 | 11 | ### Endpoints 12 | We are using swagger to document the endpoints, you can find the current spec at https://api-staging.codingcoach.io/ 13 | 14 | ### e2e Testing 15 | 16 | #### Running the e2e suite of tests 17 | 18 | If you'd like to run the entire suite of e2e tests locally you can use the command below: 19 | 20 | ``` 21 | yarn test:e2e 22 | ``` 23 | 24 | #### Running tests in a single e2e file 25 | 26 | To run the tests for a specific e2e file, use the command below as a starting point: 27 | 28 | ``` 29 | yarn test:e2e ./test/api/mentors.e2e-spec.ts 30 | ``` 31 | -------------------------------------------------------------------------------- /content/email_templates/README.md: -------------------------------------------------------------------------------- 1 | ### Run 2 | 3 | ```bash 4 | nodemon --config nodemon-emails.json 5 | ``` 6 | 7 | ### Links 8 | 9 | ||| 10 | |--- |--- | 11 | |Welcome|http://localhost:3003/welcome?data={%22name%22:%22Moshe%22}| 12 | |Mentorship accepted|http://localhost:3003/mentorship-accepted?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%20%22Brent%22,%22contactURL%22:%20%22https%22,%20%22openRequests%22:%208}| 13 | |Mentorship declined|http://localhost:3003/mentorship-declined?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22reason%22:%22because%22}| 14 | |Mentorship declined (by system)|http://localhost:3003/mentorship-declined?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22reason%22:%22because%22,%22bySystem%22:true}| 15 | |Mentorship cancelled|http://localhost:3003/mentorship-cancelled?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%20%22Brent%22,%22reason%22:%20%22I%27ve%20already%20found%20a%20mentor%22}| 16 | |Mentorship requested|http://localhost:3003/mentorship-requested?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22message%22:%22because%22,%22background%22:%22here%20is%20my%20background%22,%22expectation%22:%22I'm%20expecting%20for%20the%20best!%22}| 17 | |Mentorship reminder|http://localhost:3003/mentorship-reminder?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22message%22:%22because%22}| 18 | |Mentor application received|http://localhost:3003/mentor-application-received?data={%22name%22:%22Brent%22}| 19 | |Mentorship application denied|http://localhost:3003/mentor-application-declined?data={%22name%22:%22Moshe%22,%22reason%22:%22your%20avatar%20is%20not%20you%22}| 20 | |Mentorship application approved|http://localhost:3003/mentor-application-approved?data={%22name%22:%22Moshe%22}| -------------------------------------------------------------------------------- /content/email_templates/blessing-05-21.html: -------------------------------------------------------------------------------- 1 |

Hey Mentors!

2 |

3 |

 

4 | It's been a while, but we're excited to reach out to you today. We want to 5 | thank you all for being part of CodingCoach and helping the members of our 6 | community take that next step on the journey to becoming a software developer 7 | or advancing their career. 8 |

9 |

 

10 |

11 | We're always thinking of ways to improve the experience for both mentors and 12 | mentees. For example, it's sometimes difficult to remember who's asked us to 13 | be their mentor. Every mentee introduces themselves in different way across a 14 | variety of channels since there isn't a formal way to make this connection on 15 | the CodingCoach platform. Did they ask me on Slack? Twitter? Email? I can't 16 | remember. 17 |

18 |

 

19 |

20 | Soon we'll be launching new functionality that allows mentors and mentees to 21 | establish a connection using the codingCoach platform directly. Prospective 22 | mentees will now submit a mentorship request to you using in-app tools. You'll 23 | receive an email as well as seeing a new request after you log in. Once you 24 | review the request – and either accept or decline the request (be nice) – the 25 | mentee will be informed of your decision and the request will be closed. 26 |

27 |

 

28 |

29 | Once you approve, we'll send an email to the mentee and only then, they will 30 | see your channels. If you decline their request, they won't see your contact 31 | information. With this change, both sides of the mentorship request will have 32 | a better understanding of what's happening, which will lead to a better 33 | outcome for everyone. 34 |

35 |

 

36 |

37 | We really appreciate every mentor in CodingCoach! We want to remind you that 38 | if you're no longer available to take mentees, please set yourself as 39 | inactive. 40 |

41 |

 

42 |

43 | If something is not working as expected or you have great ideas about this 44 | process or others, please let us know or create an issue or discussion in our 45 | GitHub organization. 46 |

47 | -------------------------------------------------------------------------------- /content/email_templates/mentor-application-approved.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 12 | 13 | 14 |
6 | Illustration 11 |
15 |

18 | <%= name %>, You did it! 19 |
20 | You're a mentor! 21 |

22 |

23 | Once you've made your way through that list then it's just a matter of time 24 | before a mentee gets in touch and you'll be on your way to making a real 25 | difference in someone's career. 26 |

27 |

 

28 |

29 | Well, we know you must be busy so we'll let you go for now. Thanks again for 30 | your commitment to our community and its members. We feel lucky that you've 31 | taken the time to take part and we look forward to everyone working together 32 | for each other's mutual benefit. 33 |

34 |

 

35 |

36 | Congratulations and thank you for promising to dedicate your time and 37 | expertise to giving back to a community that we know will have given you so 38 | much. 39 |

40 |

 

41 |

42 | You've taken an important first step towards ensuring that the thought 43 | leaders of tomorrow are supported enough to achieve their true potential. In 44 | many ways...you're kind of like a developer superhero! 45 |

46 |

 

47 |

48 | After you've finished sliding into your new cape and mask, you might be 49 | wondering what you can do next to really embody the shining crusader of 50 | mentorship that you're destined to become. 51 |

52 |

 

53 | 54 | 55 | 56 | 100 | 101 | 102 |
57 | Don't worry, we got you covered, here are some ideas: 58 |
    59 |
  • 60 | Tweet to the world about your new commitment to bettering 61 | tomorrow's amazing talent. Don't forget to 62 | @codingcoach_io 65 | us so we can retweet and give you some reach. 66 |
  • 67 |
  • 68 | Join our 69 | Slack 74 | so you can chat real-time with other mentors and your mentees 75 |
  • 76 |
  • 77 | Once you join the Slack you might also want to ask for an invite 78 | for the private mentor's channel from either Mosh or Crysfel 79 |
  • 80 |
  • 81 | Have a read over the 82 | Coding Coach mentorship guidelines 87 | there are plenty of hints and tips there for both mentors and 88 | mentees to set your expectations and give you an idea of what the 89 | journey ahead of you looks like 90 |
  • 91 |
  • 92 | We know you have a great amount of development knowledge and skill 93 | and if you'd care to share it with us then why not join us in 94 | building the coding coach platform? 97 |
  • 98 |
99 |
103 |
104 | -------------------------------------------------------------------------------- /content/email_templates/mentor-application-declined.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 12 | 13 | 14 |
6 | Illustration 11 |
15 |

16 | Hello, <%= name %>! 17 |

18 |

21 | Sorry but we can't approve your application yet 22 |

23 |

24 | Unfortunately, we can't approve your application yet due to the reason 25 | below. If you feel your application should be accepted as is, please send an 26 | email to 27 | admin@codingcoach.io.
30 | Once you fix the application, please submit it again. 31 |

32 |
33 |     Reason: <%= reason %>
34 |   
35 | 36 | 37 | 45 | 46 |
38 | Fix my profile 44 |
47 |
48 | -------------------------------------------------------------------------------- /content/email_templates/mentor-application-received.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 12 | 13 | 14 |
6 | Illustration 11 |
15 |

16 | Hello, <%= name %>! 17 |

18 |

21 | Mentor Application Received 22 |

23 |

24 | Thank you so much for applying to become a mentor here at Coding Coach. We are reviewing your application, and will let you know when we have completed our review. 25 |

26 |

 

27 |

28 | Until then, have a look at this super helpful document to get yourself ready to be a mentor! 29 |

30 | 31 | 32 | 40 | 41 |
33 | View mentorship guidelines 39 |
42 |
43 | -------------------------------------------------------------------------------- /content/email_templates/mentor-freeze.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 11 | 12 | 13 |
6 | 10 |
14 |

Hello, <%= mentorName %>!

15 |

23 | Seems like you're not here 😔 24 |

25 |
26 |

27 | Few days ago we sent you an email asking you to respond to your mentorship requests. 28 | Since we weren't hearing from you we assume that you're not available for taking 29 | mentees. 30 |

31 |

 

32 |

33 | Hi that's fine, no hard feelings but since we are obligated to include 34 | only active mentors in the list, we set you "not available". 35 |

36 |

 

37 |

38 | Is it a mistake? Ready to be back? Please let us know at 39 | admin@codingcoach.io 42 |

43 |

🖖 Live long and prosper

44 |
45 | 46 | 47 | 70 | 71 |
48 | Go to your profile 69 |
72 |
73 | -------------------------------------------------------------------------------- /content/email_templates/mentor-not-active.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 11 | 12 | 13 |
6 | 10 |
14 |

Hello, <%= mentorName %>!

15 |

23 | Are you still with us 😀? 24 |

25 |
26 |

27 | We're reaching out because we noticed that you received 28 | <%= numOfMentorshipRequests %> mentorship requests in the last month (give or 29 | take) and you haven't responded to any of them. 30 |

31 |

 

32 |

33 | As a mentor, you don't have to take all the mentorship requests of course. 34 | In fact, you don't have to take any of them. The least we ask is to 35 | respond. If you can't take a mentorship, please 36 | decline it so 37 | the mentee will know they should ask someone else. 38 |

39 |

 

40 |

41 | If you can't take mentees right now, it's also fine. For transparency, 42 | please 43 | set yourself 50 | as "not available for new mentees". 51 |

52 |

 

53 |

54 | Please notice that if we won't hear from you in the next few days, 56 | we'll have to set you "not available" 58 |

59 |

 

60 |

61 | If you have any questions or technical difficulties please let us know. 62 |

63 |

64 | We appreciate your willingness to be a mentor and help people and wish you 65 | the best. 66 |

67 |
68 | 69 | 70 | 93 | 94 | 95 | 102 | 103 |
71 | Review requests 92 |
96 | View mentorship guidelines 101 |
104 |
105 | -------------------------------------------------------------------------------- /content/email_templates/mentorship-accepted.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 12 | 13 | 14 |
6 | Illustration 11 |
15 |

16 | Congratulations, <%= menteeName %>! 17 |

18 |

21 | Mentorship Request Accepted 22 |

23 |

24 | Your request for mentorship with 25 | <%= mentorName %> 26 | has been approved. 27 |

28 |

29 | <%= mentorName %> asks that you contact them at 30 | <%= contactURL %> in order to get started. 31 |

32 | <% if (openRequests) { %> 33 |

34 | 👉 Note that you have <%= openRequests %> open mentorship requests. 35 | Once the mentorship is actually started, please cancel your other similar requests via your Backoffice. 41 |

42 | <% } %> 43 |
44 | -------------------------------------------------------------------------------- /content/email_templates/mentorship-cancelled.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 12 | 13 | 14 |
6 | Illustration 11 |
15 |

Hello, <%= mentorName %>!

16 |

24 | Mentorship Cancelled 25 |

26 | 27 |

28 | Thank you for considering mentoring <%= menteeName %>. Now they asked to 29 | withdraw the request because 30 |

31 | 32 |
<%= reason %>
33 | 34 |

35 | Although this one didn't work out, we're sure you'll get more requests soon. 36 |

37 |
38 | -------------------------------------------------------------------------------- /content/email_templates/mentorship-declined.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 12 | 13 | 14 |
6 | Illustration 11 |
15 |

16 | Hello, <%= menteeName %>! 17 |

18 |

26 | Mentorship Request Not Accepted 27 |

28 | 29 | <% if (bySystem) { %> 30 |

31 | Unfortunately, your request for mentorship with 32 | <%= mentorName %> 33 | has been declined by our system because 34 | <%= mentorName %> 35 | seems to be unavailable at the moment. 36 |

37 | <% } else { %> 38 |

39 | Unfortunately, your request for mentorship with 40 | <%= mentorName %> 41 | was not accepted. 42 |

43 |

44 | They provided the reason below, which we hope will help you find your next 45 | mentor: 46 |

47 |
<%= reason %>
48 | <% } %> 49 | 50 |

51 | Although this one didn't work out, there are many other mentors at 52 | CodingCoach looking to mentor 53 | someone like you. 54 |

55 |
56 | -------------------------------------------------------------------------------- /content/email_templates/mentorship-reminder.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 11 | 12 | 13 |
6 | 10 |
14 |

15 | Hello, <%= mentorName %>! 16 |

17 |

20 | A kindly reminder 21 |

22 |

23 | We just want to remind you that 24 | <%= menteeName %> 25 | would like you to mentor them. 26 |

27 |

Here's a brief message they wanted to share with you:

28 |
<%= message %>
29 |

30 | Please check your account to review the full request and respond when you 31 | can. They are as excited as you are to learn everything you have to share! 32 |

33 |

34 | If you can't take this mentorship, it's perfectly fine. 35 | Please let <%= menteeName %> know by declining the request. 36 |

37 |

38 | If you can't take more mentorships now, please set yourself as "not available for new mentees". 39 |

40 | 41 | 42 | 50 | 51 | 52 | 59 | 60 |
43 | Review request 49 |
53 | View mentorship guidelines 58 |
61 |
62 | -------------------------------------------------------------------------------- /content/email_templates/mentorship-requested.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 12 | 13 | 14 |
6 | Illustration 11 |
15 |

16 | Hello, <%= mentorName %>! 17 |

18 |

21 | Mentorship Requested 22 |

23 |

24 | You have a new mentorship request from 25 | <%= menteeName %> 26 |

27 |

Here are the details they shared with you about them

28 |

Message

29 |
<%= message %>
30 |

Background

31 |
<%= background %>
32 |

Expectations

33 |
<%= expectation %>
34 |

35 | Please check your account to review the full request and respond when you 36 | can. They are as excited as you are to learn everything you have to share! 37 |

38 | 39 | 40 | 48 | 49 | 50 | 57 | 58 |
41 | Review request 47 |
51 | View mentorship guidelines 56 |
59 |
60 | -------------------------------------------------------------------------------- /content/email_templates/show.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { compile } = require('ejs'); 3 | const fs = require('fs'); 4 | 5 | const app = express(); 6 | const port = 3003; 7 | const layout = fs.readFileSync('content/email_templates/layout.html', { 8 | encoding: 'utf8', 9 | }); 10 | 11 | function injectData(template, data) { 12 | const content = compile(template)(data); 13 | return compile(layout)({ 14 | content, 15 | }); 16 | } 17 | 18 | app.get('/:templateName', function (req, res) { 19 | const { templateName } = req.params; 20 | if (templateName.includes('.')) return; 21 | const { data } = req.query; 22 | const template = fs.readFileSync( 23 | `content/email_templates/${templateName}.html`, 24 | { encoding: 'utf8' }, 25 | ); 26 | const content = injectData( 27 | template, 28 | JSON.parse(data || '{}'), 29 | ); 30 | res.send(content); 31 | }); 32 | 33 | app.listen(port, () => { 34 | console.log(`Example app listening at http://localhost:${port}`); 35 | }); 36 | -------------------------------------------------------------------------------- /docker-compose-db.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | find-a-mentor-mongo: 5 | image: mongo:4 6 | container_name: find-a-mentor-mongo 7 | hostname: find-a-mentor-mongo 8 | volumes: 9 | - "./local-mongo:/data/db" 10 | ports: 11 | - 27017:27017 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | find-a-mentor-api: 5 | image: node:12.20.1-alpine3.10 6 | container_name: find-a-mentor-api 7 | command: sh -c "yarn install; yarn start:dev" 8 | volumes: 9 | - ./:/usr/src 10 | ports: 11 | - 3000:3000 12 | working_dir: /usr/src 13 | environment: 14 | - MONGO_DATABASE_URL=mongodb://find-a-mentor-mongo/codingcoach 15 | 16 | find-a-mentor-mongo: 17 | image: mongo:4 18 | container_name: find-a-mentor-mongo 19 | hostname: find-a-mentor-mongo 20 | volumes: 21 | - "./local-mongo:/data/db" 22 | ports: 23 | - 27017:27017 24 | -------------------------------------------------------------------------------- /docs/cc-api-spec.json: -------------------------------------------------------------------------------- 1 | {"swagger":"2.0","info":{"description":"A REST API for the coding coach platform","version":"1.0","title":"Coding Coach"},"basePath":"/","tags":[],"schemes":["https","http"],"securityDefinitions":{"bearer":{"type":"apiKey","name":"Authorization","in":"header"}},"paths":{"/mentors":{"get":{"summary":"Return all mentors in the platform by the given filters","parameters":[{"type":"number","name":"page","required":false,"in":"query"},{"type":"number","name":"limit","required":false,"in":"query"},{"type":"boolean","name":"available","required":false,"in":"query"},{"type":"string","name":"tags","required":false,"in":"query"},{"type":"string","name":"country","required":false,"in":"query"},{"type":"string","name":"spokenLanguages","required":false,"in":"query"},{"type":"string","name":"name","required":false,"in":"query"}],"responses":{"200":{"description":""}},"tags":["/mentors"],"produces":["application/json"],"consumes":["application/json"]}},"/mentors/featured":{"get":{"summary":"Retrieves a random mentor to be featured in the blog (or anywhere else)","responses":{"200":{"description":""}},"tags":["/mentors"],"produces":["application/json"],"consumes":["application/json"]}},"/mentors/applications":{"get":{"summary":"Retrieve applications filter by the given status","parameters":[{"type":"string","name":"status","required":true,"in":"query"}],"responses":{"200":{"description":""}},"tags":["/mentors"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]},"post":{"summary":"Creates a new request to become a mentor, pending for Admin to approve","parameters":[{"name":"ApplicationDto","required":true,"in":"body","schema":{"$ref":"#/definitions/ApplicationDto"}}],"responses":{"201":{"description":""}},"tags":["/mentors"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/mentors/{userId}/applications":{"get":{"summary":"Retrieve applications for the given user","parameters":[{"type":"string","name":"status","required":true,"in":"query"},{"type":"string","name":"userId","required":true,"in":"path"}],"responses":{"200":{"description":""}},"tags":["/mentors"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/mentors/applications/{id}":{"put":{"summary":"Approves or rejects an application after review","parameters":[{"name":"ApplicationDto","required":true,"in":"body","schema":{"$ref":"#/definitions/ApplicationDto"}},{"type":"string","name":"id","required":true,"in":"path"}],"responses":{"200":{"description":""}},"tags":["/mentors"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/mentorships/{mentorId}/apply":{"post":{"summary":"Creates a new mentorship request for the given mentor","parameters":[{"name":"MentorshipDto","required":true,"in":"body","schema":{"$ref":"#/definitions/MentorshipDto"}},{"type":"string","name":"mentorId","required":true,"in":"path"}],"responses":{"201":{"description":""}},"tags":["/mentorships"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/mentorships/{userId}/requests":{"get":{"summary":"Returns the mentorship requests for a mentor or a mentee.","parameters":[{"type":"string","name":"userId","required":true,"in":"path"}],"responses":{"200":{"description":""}},"tags":["/mentorships"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/mentorships/{userId}/requests/{id}":{"put":{"summary":"Updates the mentorship status by the mentor or mentee","parameters":[{"name":"MentorshipUpdatePayload","required":true,"in":"body","schema":{"$ref":"#/definitions/MentorshipUpdatePayload"}},{"name":"id","required":true,"in":"path","description":"Mentorship's id","type":""},{"name":"userId","required":true,"in":"path","description":"Mentor's id","type":""}],"responses":{"200":{"description":""}},"tags":["/mentorships"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/mentorships/requests":{"get":{"summary":"Returns all the mentorship requests","responses":{"200":{"description":""}},"tags":["/mentorships"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/mentorships/requests/{id}/reminder":{"put":{"summary":"Send mentor a reminder about an open mentorship","parameters":[],"responses":{"200":{"description":""}},"tags":["/mentorships"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{userid}/favorites":{"get":{"summary":"Returns the favorite list for the given user","parameters":[{"type":"string","name":"userid","required":true,"in":"path"}],"responses":{"200":{"description":""}},"tags":["/users/:userid/favorites"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{userid}/favorites/{mentorid}":{"post":{"summary":"Adds or removes a mentor from the favorite list","parameters":[{"type":"string","name":"mentorid","required":true,"in":"path"},{"type":"string","name":"userid","required":true,"in":"path"}],"responses":{"201":{"description":""}},"tags":["/users/:userid/favorites"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{userid}/lists":{"post":{"summary":"Creates a new mentor's list for the given user","parameters":[{"name":"ListDto","required":true,"in":"body","schema":{"$ref":"#/definitions/ListDto"}},{"type":"string","name":"userid","required":true,"in":"path"}],"responses":{"201":{"description":""}},"tags":["/users/:userid/lists"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]},"get":{"summary":"Gets mentor's list for the given user","parameters":[{"type":"string","name":"userid","required":true,"in":"path"}],"responses":{"200":{"description":""}},"tags":["/users/:userid/lists"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{userid}/lists/{listid}":{"put":{"summary":"Updates a given list","parameters":[{"name":"ListDto","required":true,"in":"body","schema":{"$ref":"#/definitions/ListDto"}},{"type":"string","name":"listid","required":true,"in":"path"},{"type":"string","name":"userid","required":true,"in":"path"}],"responses":{"200":{"description":""}},"tags":["/users/:userid/lists"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{userid}/lists/{listId}":{"delete":{"summary":"Deletes the given mentor's list for the given user","parameters":[{"type":"string","name":"listId","required":true,"in":"path"},{"type":"string","name":"userid","required":true,"in":"path"}],"responses":{"200":{"description":""}},"tags":["/users/:userid/lists"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{userid}/lists/{listid}/add":{"put":{"summary":"Add a new mentor to existing list","parameters":[{"name":"ListDto","required":true,"in":"body","schema":{"$ref":"#/definitions/ListDto"}},{"type":"string","name":"listid","required":true,"in":"path"},{"type":"string","name":"userid","required":true,"in":"path"}],"responses":{"200":{"description":""}},"tags":["/users/:userid/lists"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users":{"get":{"summary":"Return all registered users","responses":{"200":{"description":""}},"tags":["/users"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/current":{"get":{"summary":"Returns the current user","responses":{"200":{"description":""}},"tags":["/users"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{id}":{"get":{"summary":"Returns a single user by ID","parameters":[{"name":"id","required":true,"in":"path","description":"The user _id","type":""}],"responses":{"200":{"description":""}},"tags":["/users"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]},"put":{"summary":"Updates an existing user","parameters":[{"name":"UserDto","required":true,"in":"body","schema":{"$ref":"#/definitions/UserDto"}},{"name":"id","required":true,"in":"path","description":"The user _id","type":""}],"responses":{"200":{"description":""}},"tags":["/users"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]},"delete":{"summary":"Deletes the given user","parameters":[{"name":"id","required":true,"in":"path","description":"The user _id","type":""}],"responses":{"200":{"description":""}},"tags":["/users"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{id}/avatar":{"post":{"summary":"Upload an avatar for the given user","parameters":[{"name":"id","required":true,"in":"path","description":"The user _id","type":""}],"responses":{"201":{"description":""}},"tags":["/users"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/users/{id}/records":{"post":{"summary":"Add a record to user","parameters":[{"name":"UserRecordDto","required":true,"in":"body","schema":{"$ref":"#/definitions/UserRecordDto"}},{"name":"id","required":true,"in":"path","description":"The user _id","type":""}],"responses":{"201":{"description":""}},"tags":["/users"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]},"get":{"summary":"Get user records","parameters":[{"name":"id","required":true,"in":"path","description":"The user _id","type":""}],"responses":{"200":{"description":""}},"tags":["/users"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/reports/users":{"get":{"summary":"Return total number of users by role for the given date range","parameters":[{"type":"string","name":"end","required":true,"in":"query"},{"type":"string","name":"start","required":true,"in":"query"}],"responses":{"200":{"description":""}},"tags":["/reports"],"security":[{"bearer":[]}],"produces":["application/json"],"consumes":["application/json"]}},"/admin/mentor/{id}/notActive":{"put":{"summary":"Send an email to not active mentor","responses":{"200":{"description":""}},"tags":["/admin"],"produces":["application/json"],"consumes":["application/json"]}},"/admin/mentor/{id}/freeze":{"put":{"summary":"Retrieves a random mentor to be featured in the blog (or anywhere else)","responses":{"200":{"description":""}},"tags":["/admin"],"produces":["application/json"],"consumes":["application/json"]}}},"definitions":{"ApplicationDto":{"type":"object","properties":{"status":{"type":"string"},"description":{"type":"string"},"reason":{"type":"string"}},"required":["status","description","reason"]},"MentorshipDto":{"type":"object","properties":{"status":{"type":"string"},"message":{"type":"string"},"goals":{"type":"array","items":{"type":"string"}},"expectation":{"type":"string"},"background":{"type":"string"},"reason":{"type":"string"}},"required":["message"]},"MentorshipUpdatePayload":{"type":"object","properties":{"status":{"type":"string"},"reason":{"type":"string"}},"required":["status"]},"ListDto":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"public":{"type":"boolean"},"mentors":{"type":"array","items":{"type":"string"}}},"required":["_id","name"]},"UserDto":{"type":"object","properties":{"_id":{"type":"string"},"email":{"type":"string"},"name":{"type":"string"},"available":{"type":"boolean"},"avatar":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"country":{"type":"string"},"timezone":{"type":"string"},"capacity":{"type":"number"},"spokenLanguages":{"type":"array","items":{"type":"string"}},"tags":{"type":"array","items":{"type":"string"}},"roles":{"type":"array","items":{"type":"string"}},"channels":{"type":"array","items":{"type":"string"}}},"required":["_id","email","name","available"]},"UserRecordDto":{"type":"object","properties":{"_id":{"type":"string"},"user":{"type":"string"},"type":{"type":"number"}},"required":["_id","user","type"]}}} -------------------------------------------------------------------------------- /docs/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding-Coach/find-a-mentor-api/957fdb327822ffe743d8a7e062f3dfcdca70f714/docs/favicon-16x16.png -------------------------------------------------------------------------------- /docs/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding-Coach/find-a-mentor-api/957fdb327822ffe743d8a7e062f3dfcdca70f714/docs/favicon-32x32.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Swagger UI 7 | 8 | 9 | 10 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /docs/oauth2-redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 68 | -------------------------------------------------------------------------------- /mongo-common-queries/delete-all-mentorships.mongodb: -------------------------------------------------------------------------------- 1 | // MongoDB Playground 2 | // Use Ctrl+Space inside a snippet or a string literal to trigger completions. 3 | 4 | // The current database to use. 5 | use('codingcoach'); 6 | 7 | db.getCollection('mentorships') 8 | .deleteMany({}); -------------------------------------------------------------------------------- /mongo-common-queries/mentorship-cancelled-to-new.mongodb: -------------------------------------------------------------------------------- 1 | // MongoDB Playground 2 | // Use Ctrl+Space inside a snippet or a string literal to trigger completions. 3 | 4 | // The current database to use. 5 | use('codingcoach'); 6 | 7 | try { 8 | 9 | db.getCollection('mentorships') 10 | .updateOne({ 11 | _id: ObjectId('60de24ebfd14b7f8d116f4d2') 12 | }, { 13 | $set: { 14 | 'status': 'New' 15 | } 16 | }) 17 | } catch(e) { 18 | print('error', e); 19 | } -------------------------------------------------------------------------------- /mongo-common-queries/reset-mentorship-sent-at.mongodb: -------------------------------------------------------------------------------- 1 | // MongoDB Playground 2 | // Use Ctrl+Space inside a snippet or a string literal to trigger completions. 3 | 4 | // The current database to use. 5 | use('codingcoach'); 6 | 7 | db.getCollection('mentorships') 8 | .updateOne({ 9 | _id: ObjectId('60e5431d3f48a5e331348066') 10 | }, { 11 | $set: { 12 | 'reminderSentAt': null 13 | } 14 | }); -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /nodemon-debug.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "node --inspect-brk -r ts-node/register -r tsconfig-paths/register src/main.ts" 6 | } 7 | -------------------------------------------------------------------------------- /nodemon-emails.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["content/email_templates"], 3 | "ext": "html,js", 4 | "exec": "node content/email_templates/show.js" 5 | } 6 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["dist"], 3 | "ext": "js", 4 | "exec": "node dist/main" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rest-api", 3 | "version": "1.37.0", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "tsc -p tsconfig.build.json", 9 | "format": "prettier --write \"src/**/*.ts\"", 10 | "start": "ts-node -r tsconfig-paths/register src/main.ts", 11 | "start:dev": "concurrently --handle-input \"wait-on dist/main.js && nodemon\" \"tsc -w -p tsconfig.build.json\" ", 12 | "start:debug": "nodemon --config nodemon-debug.json", 13 | "start:emails": "nodemon --config nodemon-emails.json", 14 | "prestart:prod": "rimraf dist && npm run build", 15 | "start:prod": "node dist/main.js", 16 | "lint": "tslint -p tsconfig.json -c tslint.json 'src/**/*.ts'", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --verbose --runInBand --config ./test/jest-e2e.json", 22 | "user:availability": "node ./scripts/set-availability", 23 | "user:import": "ts-node -r tsconfig-paths/register ./scripts/import-mentors.ts", 24 | "user:roles": "node ./scripts/addroles" 25 | }, 26 | "dependencies": { 27 | "@nestjs/common": "^6.5.3", 28 | "@nestjs/core": "^6.5.3", 29 | "@nestjs/mongoose": "^6.1.2", 30 | "@nestjs/platform-express": "^6.0.0", 31 | "@nestjs/swagger": "^3.1.0", 32 | "@sendgrid/client": "^6.4.0", 33 | "@sendgrid/mail": "^6.4.0", 34 | "@sentry/node": "^5.7.1", 35 | "class-transformer": "^0.3.1", 36 | "class-validator": "^0.9.1", 37 | "dotenv": "^8.0.0", 38 | "ejs": "^3.1.6", 39 | "express-jwt": "^6.0.0", 40 | "i18n-iso-countries": "^4.3.1", 41 | "iso-639-1": "^2.1.0", 42 | "jwks-rsa": "^1.5.0", 43 | "mongoose": "^5.5.10", 44 | "reflect-metadata": "^0.1.12", 45 | "rimraf": "^2.6.2", 46 | "rxjs": "^6.3.3", 47 | "sharp": "^0.25.2", 48 | "swagger-ui-express": "^4.0.4" 49 | }, 50 | "devDependencies": { 51 | "@nestjs/testing": "^6.3.1", 52 | "@types/dotenv": "^6.1.1", 53 | "@types/ejs": "^3.0.7", 54 | "@types/express": "^4.16.1", 55 | "@types/express-jwt": "^0.0.42", 56 | "@types/jest": "^26.0.19", 57 | "@types/node": "^10.12.18", 58 | "@types/supertest": "^2.0.7", 59 | "concurrently": "^4.1.0", 60 | "faker": "^5.1.0", 61 | "husky": "2.7.0", 62 | "jest": "^26.6.3", 63 | "jsonwebtoken": "^8.5.1", 64 | "lint-staged": "^9.4.3", 65 | "minimist": "^1.2.0", 66 | "mongodb-memory-server": "^6.9.2", 67 | "nock": "^13.0.5", 68 | "nodemon": "^1.18.9", 69 | "prettier": "^2.3.0", 70 | "source-map-support": "^0.5.19", 71 | "supertest": "^3.4.1", 72 | "ts-jest": "^26.4.4", 73 | "ts-node": "8.1.0", 74 | "tsconfig-paths": "3.8.0", 75 | "tslint": "5.16.0", 76 | "typescript": "4.1.2", 77 | "wait-on": "^3.2.0" 78 | }, 79 | "resolutions": { 80 | "**/**/lodash": "^4.17.13", 81 | "**/**/set-value": "^2.0.1", 82 | "**/**/mixin-deep": "^1.3.2", 83 | "**/**/braces": "^2.3.1", 84 | "**/**/handlebars": "^4.3.0" 85 | }, 86 | "jest": { 87 | "collectCoverage": true, 88 | "moduleFileExtensions": [ 89 | "js", 90 | "json", 91 | "ts" 92 | ], 93 | "rootDir": "src", 94 | "testRegex": ".spec.ts$", 95 | "transform": { 96 | "^.+\\.(t|j)s$": "ts-jest" 97 | }, 98 | "coverageDirectory": "../coverage", 99 | "testEnvironment": "node" 100 | }, 101 | "husky": { 102 | "hooks": { 103 | "pre-commit": "lint-staged" 104 | } 105 | }, 106 | "lint-staged": { 107 | "*.ts": [ 108 | "prettier --write", 109 | "tslint -c tslint.json 'src/**/*.ts' --fix", 110 | "git add" 111 | ] 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /scripts/addroles.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Use this script to set roles to the users using the command line, 3 | * this is useful to set the first use as an Admin. 4 | * 5 | * Usage: 6 | * 7 | * $ yarn user:roles --email crysfel@bleext.com --roles 'Admin,Member' 8 | * 9 | */ 10 | const minimist = require('minimist'); 11 | const mongoose = require('mongoose'); 12 | const dotenv = require('dotenv'); 13 | 14 | dotenv.config(); 15 | 16 | (async () => { 17 | const { email, roles: rolesInput } = minimist(process.argv.slice(2)); 18 | const rolesAvailable = { 19 | 'Admin': true, 20 | 'Mentor': true, 21 | 'Member': true, 22 | }; 23 | 24 | if (!email && !rolesInput) { 25 | console.error('`email` and `roles` are required'); 26 | process.exit(1) 27 | } 28 | 29 | const roles = rolesInput.split(','); 30 | roles.forEach((role) => { 31 | if (!rolesAvailable[role]) { 32 | console.error(`The role '${role}' is not valid, please choose one from 'Admin, Mentor or Member'`); 33 | process.exit(1) 34 | } 35 | }); 36 | 37 | mongoose.connect(process.env.MONGO_DATABASE_URL, { useNewUrlParser: true }); 38 | 39 | 40 | const User = mongoose.model('User', { 41 | _id: String, 42 | email: String, 43 | roles: Array, 44 | }); 45 | 46 | const user = await User.findOne({ email }).exec(); 47 | 48 | if (!user) { 49 | console.log(`User with email '${email}' not found`); 50 | process.exit(1); 51 | } 52 | 53 | const result = await User.updateOne({ email }, { roles }); 54 | 55 | if (result.nModified === 1) { 56 | console.log('User updated successfully! 🎉'); 57 | process.exit(0) 58 | } 59 | 60 | console.log('The user was not updated 🤷‍♂️'); 61 | process.exit(1); 62 | 63 | })(); 64 | -------------------------------------------------------------------------------- /scripts/import-mentors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Use this script to import the mentors from the 3 | * https://github.com/Coding-Coach/find-a-mentor repository to the mongo 4 | * database. 5 | * 6 | * Usage: 7 | * $ yarn import:mentors 8 | */ 9 | import * as dotenv from 'dotenv'; 10 | import * as fetch from 'node-fetch'; 11 | import * as mongoose from 'mongoose'; 12 | 13 | import { Role } from 'src/modules/common/interfaces/user.interface'; 14 | import { ChannelSchema } from 'src/modules/common/schemas/user.schema'; 15 | 16 | const fetchMentors = async (request: RequestInfo): Promise => { 17 | return new Promise(resolve => { 18 | fetch(request) 19 | .then(response => response.json()) 20 | .then(body => { 21 | resolve(body); 22 | }); 23 | }); 24 | }; 25 | 26 | async function importMentors() { 27 | console.log('Fetch the mentors'); 28 | const mentors = await fetchMentors('https://raw.githubusercontent.com/Coding-Coach/find-a-mentor/master/src/mentors.json'); 29 | console.log(`Fetched ${mentors.length} mentors`); 30 | 31 | console.log('Connect to database'); 32 | mongoose.connect(process.env.MONGO_DATABASE_URL, { useNewUrlParser: true }); 33 | 34 | const UserSchema = new mongoose.Schema({ 35 | auth0Id: String, 36 | email: String, 37 | name: String, 38 | avatar: String, 39 | title: String, 40 | description: String, 41 | country: String, 42 | spokenLanguages: Array, 43 | tags: Array, 44 | roles: Array, 45 | channels: [ChannelSchema], 46 | }); 47 | 48 | let User = mongoose.model('User', UserSchema); 49 | 50 | console.log('Store mentors to database'); 51 | for (const mentor of mentors) { 52 | // only add the mentor, if there isn't already an entry 53 | const user = await User.findOne({ email: mentor.id }).exec(); 54 | if (!user) { 55 | console.log(`Add mentor '${mentor.id}' to database`); 56 | let newUser = new User({ 57 | name: mentor.name, 58 | email: mentor.id, 59 | avatar: mentor.avatar, 60 | title: mentor.title, 61 | description: mentor.description, 62 | country: mentor.country, 63 | spokenLanguages: mentor.spokenLanguages, 64 | tags: mentor.tags, 65 | roles: [Role.MEMBER, Role.MENTOR], 66 | channels: mentor.channels, 67 | }); 68 | 69 | await newUser.save(); 70 | } 71 | } 72 | 73 | console.log('Finished adding new mentors to the database'); 74 | process.exit(0); 75 | } 76 | 77 | dotenv.config(); 78 | importMentors(); 79 | -------------------------------------------------------------------------------- /scripts/set-availability.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script adds the `available=true` field to all mentors that 3 | * have not set this field already 4 | * 5 | * Usage: 6 | * 7 | * $ yarn user:availability 8 | * 9 | */ 10 | const mongoose = require('mongoose'); 11 | const dotenv = require('dotenv'); 12 | 13 | dotenv.config(); 14 | 15 | (async () => { 16 | await mongoose.connect(process.env.MONGO_DATABASE_URL, { useNewUrlParser: true }); 17 | 18 | 19 | const User = mongoose.model('User', { 20 | _id: String, 21 | available: Boolean, 22 | email: String, 23 | roles: Array, 24 | }); 25 | 26 | const total = await User.find({ roles: 'Mentor', available: undefined }).count(); 27 | 28 | const result = await User.updateMany({ roles: 'Mentor', available: undefined }, { available: true }); 29 | 30 | console.log(`${result.nModified} records updated out of ${total}`) 31 | process.exit(0) 32 | 33 | })(); -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Module, 3 | MiddlewareConsumer, 4 | NestModule, 5 | RequestMethod, 6 | } from '@nestjs/common'; 7 | import { AuthMiddleware } from './middlewares/auth.middleware'; 8 | import { MentorsModule } from './modules/mentors/mentors.module'; 9 | import { MentorshipsModule } from './modules/mentorships/mentorships.module'; 10 | import { ListsModule } from './modules/lists/lists.module'; 11 | import { UsersModule } from './modules/users/users.module'; 12 | import { ReportsModule } from './modules/reports/reports.module'; 13 | import { AdminModule } from './modules/admin/admin.module'; 14 | 15 | @Module({ 16 | imports: [ 17 | MentorsModule, 18 | MentorshipsModule, 19 | ListsModule, 20 | UsersModule, 21 | ReportsModule, 22 | AdminModule, 23 | ], 24 | }) 25 | export class AppModule implements NestModule { 26 | configure(consumer: MiddlewareConsumer) { 27 | consumer 28 | .apply(AuthMiddleware) 29 | .forRoutes({ path: '/**', method: RequestMethod.ALL }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | mongo: { 3 | url: process.env.MONGO_DATABASE_URL, 4 | }, 5 | auth0: { 6 | // to decode the token 7 | frontend: { 8 | CLIENT_ID: process.env.AUTH0_FRONTEND_CLIENT_ID, 9 | CLIENT_SECRET: process.env.AUTH0_FRONTEND_CLIENT_SECRET, 10 | DOMAIN: process.env.AUTH0_DOMAIN, 11 | }, 12 | // To get access to auth0 admin features 13 | backend: { 14 | CLIENT_ID: process.env.AUTH0_BACKEND_CLIENT_ID, 15 | CLIENT_SECRET: process.env.AUTH0_BACKEND_CLIENT_SECRET, 16 | DOMAIN: process.env.AUTH0_DOMAIN, 17 | }, 18 | }, 19 | sendGrid: { 20 | API_KEY: process.env.SENDGRID_API_KEY, 21 | }, 22 | sentry: { 23 | DSN: process.env.SENTRY_DSN, 24 | }, 25 | email: { 26 | FROM: 'Coding Coach ', 27 | }, 28 | files: { 29 | public: process.env.PUBLIC_FOLDER, 30 | avatars: `avatars`, 31 | }, 32 | pagination: { 33 | limit: 20, 34 | }, 35 | }; 36 | 37 | export default config; 38 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { databaseProviders } from './database.providers'; 3 | 4 | @Module({ 5 | providers: [...databaseProviders], 6 | exports: [...databaseProviders], 7 | }) 8 | export class DatabaseModule {} 9 | -------------------------------------------------------------------------------- /src/database/database.providers.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import Config from '../config'; 3 | 4 | export const databaseProviders = [ 5 | { 6 | provide: 'DATABASE_CONNECTION', 7 | useFactory: async (): Promise => 8 | await mongoose.connect(Config.mongo.url, { useNewUrlParser: true }), 9 | }, 10 | ]; 11 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { appendFile } from 'fs'; 3 | 4 | export class MyLogger extends Logger { 5 | error(message: string, trace: string) { 6 | appendFile( 7 | 'log.log', 8 | ` 9 | Message: ${message}\n\n 10 | Trace: ${trace}\n\n 11 | ==================================== 12 | `, 13 | (err) => { 14 | if (err) { 15 | // tslint:disable-next-line:no-console 16 | console.log(err.message); 17 | } 18 | }, 19 | ); 20 | super.error(message, trace); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'source-map-support/register'; 2 | import * as dotenv from 'dotenv'; 3 | import * as fs from 'fs'; 4 | import { MyLogger } from './logger'; 5 | dotenv.config(); 6 | 7 | import Config from './config'; 8 | import * as Sentry from '@sentry/node'; 9 | Sentry.init({ dsn: Config.sentry.DSN }); 10 | 11 | import { NestFactory } from '@nestjs/core'; 12 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 13 | import { AppModule } from './app.module'; 14 | 15 | async function bootstrap() { 16 | const app = await NestFactory.create(AppModule, { 17 | logger: new MyLogger(), 18 | }); 19 | app.enableCors({ origin: process.env.CORS_ORIGIN || /codingcoach\.io$/ }); 20 | 21 | const options = new DocumentBuilder() 22 | .setTitle('Coding Coach') 23 | .setDescription('A REST API for the coding coach platform') 24 | .setVersion(process.env.DOCS_VERSION || '1.0') 25 | .addBearerAuth() 26 | .build(); 27 | 28 | if (process.env.NODE_ENV !== 'production') { 29 | // We want to get the swagger docs only on development 30 | const document = SwaggerModule.createDocument(app, options); 31 | 32 | document.schemes = ['https', 'http']; 33 | fs.writeFileSync('./docs/cc-api-spec.json', JSON.stringify(document)); 34 | 35 | SwaggerModule.setup('/docs', app, document); 36 | } 37 | // tslint:disable-next-line:no-console 38 | console.log(`Server is up on port: ${process.env.PORT || 3000}`); 39 | await app.listen(process.env.PORT || 3000); 40 | } 41 | bootstrap(); 42 | -------------------------------------------------------------------------------- /src/middlewares/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { Request, Response, NextFunction } from 'express'; 3 | import * as jwt from 'express-jwt'; 4 | import { expressJwtSecret } from 'jwks-rsa'; 5 | import Config from '../config'; 6 | 7 | const secret = expressJwtSecret({ 8 | cache: true, 9 | rateLimit: true, 10 | jwksRequestsPerMinute: 5, 11 | jwksUri: `https://${Config.auth0.frontend.DOMAIN}/.well-known/jwks.json`, 12 | }); 13 | 14 | const middleware = jwt({ 15 | secret, 16 | issuer: `https://${Config.auth0.frontend.DOMAIN}/`, 17 | algorithms: ['RS256'], 18 | }); 19 | 20 | /** 21 | * Public paths that doesn't require authentication 22 | */ 23 | const publicUrls: RegExp[] = [ 24 | /^\/mentors$/, 25 | /^\/mentors\/featured$/, 26 | /^\/users\/[^\/]*$/, 27 | /^\/avatars\/[^\/]*$/, 28 | ]; 29 | 30 | const isPublicUrl = (req: Request) => 31 | publicUrls.some((re) => re.test(req.baseUrl)); 32 | 33 | @Injectable() 34 | export class AuthMiddleware implements NestMiddleware { 35 | use(req: Request, res: Response, next: NextFunction) { 36 | middleware(req, res, (error) => { 37 | if (req.user) { 38 | // @ts-ignore 39 | req.user.auth0Id = req.user.sub; 40 | } 41 | if (isPublicUrl(req)) { 42 | next(); 43 | return; 44 | } 45 | if (error) { 46 | const status = error.status || 401; 47 | const message = 48 | error.message || 49 | 'You need to be authenticated in order to access this resource.'; 50 | 51 | return res.status(status).send({ 52 | success: false, 53 | errors: [message], 54 | }); 55 | } 56 | next(); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/modules/admin/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | MethodNotAllowedException, 4 | NotFoundException, 5 | Put, 6 | Req, 7 | } from '@nestjs/common'; 8 | import { ApiOperation, ApiUseTags } from '@nestjs/swagger'; 9 | import { Request } from 'express'; 10 | import { MentorsService } from '../common/mentors.service'; 11 | import { MentorshipsService } from '../mentorships/mentorships.service'; 12 | import { EmailService } from '../email/email.service'; 13 | import { 14 | Mentorship, 15 | Status, 16 | } from '../mentorships/interfaces/mentorship.interface'; 17 | import { UsersService } from '../common/users.service'; 18 | import { UserRecordType } from '../common/interfaces/user-record.interface'; 19 | import { UserRecordDto } from '../common/dto/user-record.dto'; 20 | import { UserDto } from '../common/dto/user.dto'; 21 | 22 | const MONTH = 2.628e9; 23 | 24 | @ApiUseTags('/admin') 25 | @Controller('admin') 26 | export class AdminController { 27 | constructor( 28 | private readonly usersService: UsersService, 29 | private readonly mentorsService: MentorsService, 30 | private readonly emailService: EmailService, 31 | private readonly mentorshipsService: MentorshipsService, 32 | ) {} 33 | 34 | private respondedRecently(requestsAsMentor: Mentorship[]) { 35 | return requestsAsMentor.some( 36 | ({ status, updatedAt }) => 37 | [Status.APPROVED, Status.REJECTED].includes(status) && 38 | Date.now() - updatedAt.getTime() <= MONTH, 39 | ); 40 | } 41 | 42 | @Put('mentor/:id/notActive') 43 | @ApiOperation({ 44 | title: 'Send an email to not active mentor', 45 | }) 46 | async mentorNotActive(@Req() request: Request) { 47 | const { id } = request.params; 48 | const mentor = await this.mentorsService.findById(id); 49 | if (!mentor) { 50 | throw new NotFoundException('Mentor not found'); 51 | } 52 | const requestsAsMentor = ( 53 | await this.mentorshipsService.findMentorshipsByUser(mentor._id) 54 | ).filter(({ mentor }) => mentor._id.equals(id)); 55 | 56 | if (this.respondedRecently(requestsAsMentor)) { 57 | throw new MethodNotAllowedException('Mentor responded to some requests'); 58 | } 59 | 60 | const record = this.usersService.addRecord( 61 | new UserRecordDto({ 62 | user: mentor._id, 63 | type: UserRecordType.MentorNotResponding, 64 | }), 65 | ); 66 | 67 | this.emailService.sendLocalTemplate({ 68 | name: 'mentor-not-active', 69 | to: mentor.email, 70 | subject: 'Hi from CodingCoach, are you there?', 71 | data: { 72 | mentorName: mentor.name, 73 | numOfMentorshipRequests: requestsAsMentor.length, 74 | }, 75 | }); 76 | 77 | return { 78 | success: true, 79 | data: record, 80 | }; 81 | } 82 | 83 | @Put('mentor/:id/freeze') 84 | @ApiOperation({ 85 | title: 86 | 'Retrieves a random mentor to be featured in the blog (or anywhere else)', 87 | }) 88 | async freezeMentor(@Req() request: Request) { 89 | const { id } = request.params; 90 | const mentor = await this.mentorsService.findById(id); 91 | if (!mentor) { 92 | throw new NotFoundException('Mentor not found'); 93 | } 94 | const requestsAsMentor = ( 95 | await this.mentorshipsService.findMentorshipsByUser(mentor._id) 96 | ).filter(({ mentor }) => mentor._id.equals(id)); 97 | 98 | if (this.respondedRecently(requestsAsMentor)) { 99 | throw new MethodNotAllowedException('Mentor responded to some requests'); 100 | } 101 | 102 | this.usersService.update( 103 | new UserDto({ 104 | _id: mentor._id, 105 | available: false, 106 | }), 107 | ); 108 | 109 | this.emailService.sendLocalTemplate({ 110 | name: 'mentor-freeze', 111 | to: mentor.email, 112 | subject: 'Seems like you are not here 😔', 113 | data: { 114 | mentorName: mentor.name, 115 | }, 116 | }); 117 | 118 | const mentorships = ( 119 | await this.mentorshipsService.findMentorshipsByUser(id) 120 | ).filter( 121 | (mentorship) => 122 | mentorship.mentor.id === id && 123 | [Status.NEW, Status.VIEWED].includes(mentorship.status), 124 | ); 125 | 126 | mentorships.forEach((mentorship) => { 127 | mentorship.status = Status.REJECTED; 128 | mentorship.reason = 'Automatic decline - Mentor is no longer available'; 129 | (mentorship as any).save(); 130 | 131 | this.emailService.sendLocalTemplate({ 132 | to: mentorship.mentee.email, 133 | name: 'mentorship-declined', 134 | subject: 'Mentorship Declined – Mentor no longer available', 135 | data: { 136 | mentorName: mentorship.mentor.name, 137 | menteeName: mentorship.mentee.name, 138 | reason: mentorship.reason, 139 | bySystem: true, 140 | }, 141 | }); 142 | }); 143 | 144 | return { 145 | success: true, 146 | }; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/modules/admin/admin.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AdminController } from './admin.controller'; 3 | import { CommonModule } from '../common/common.module'; 4 | import { MentorsService } from '../common/mentors.service'; 5 | import { DatabaseModule } from '../../database/database.module'; 6 | import { EmailService } from '../email/email.service'; 7 | import { UsersService } from '../common/users.service'; 8 | import { MentorshipsService } from '../mentorships/mentorships.service'; 9 | 10 | /** 11 | * Admin module, Endpoints in this module are 12 | * defined to allow admin to run some priviliage operations 13 | */ 14 | @Module({ 15 | imports: [DatabaseModule, CommonModule, EmailService], 16 | controllers: [AdminController], 17 | providers: [MentorsService, EmailService, UsersService, MentorshipsService], 18 | }) 19 | export class AdminModule {} 20 | -------------------------------------------------------------------------------- /src/modules/common/auth0.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import fetch from 'node-fetch'; 3 | import Config from '../../config'; 4 | 5 | @Injectable() 6 | export class Auth0Service { 7 | // Get an access token for the Auth0 Admin API 8 | async getAdminAccessToken() { 9 | const options = { 10 | method: 'POST', 11 | headers: { 'content-type': 'application/json' }, 12 | body: JSON.stringify({ 13 | client_id: Config.auth0.backend.CLIENT_ID, 14 | client_secret: Config.auth0.backend.CLIENT_SECRET, 15 | audience: `https://${Config.auth0.backend.DOMAIN}/api/v2/`, 16 | grant_type: 'client_credentials', 17 | }), 18 | }; 19 | 20 | const response = await fetch( 21 | `https://${Config.auth0.backend.DOMAIN}/oauth/token`, 22 | options, 23 | ); 24 | const json = await response.json(); 25 | 26 | return json; 27 | } 28 | 29 | // Get the user's profile from auth0 30 | async getUserProfile(accessToken, userID) { 31 | const options = { 32 | headers: { 33 | Authorization: `Bearer ${accessToken}`, 34 | }, 35 | }; 36 | 37 | const response = await fetch( 38 | `https://${Config.auth0.backend.DOMAIN}/api/v2/users/${userID}`, 39 | options, 40 | ); 41 | const json = await response.json(); 42 | 43 | return json; 44 | } 45 | 46 | // Deletes a user from auth0 47 | async deleteUser(accessToken: string, userID: string) { 48 | const options = { 49 | method: 'DELETE', 50 | headers: { 51 | Authorization: `Bearer ${accessToken}`, 52 | }, 53 | }; 54 | 55 | const response = await fetch( 56 | `https://${Config.auth0.backend.DOMAIN}/api/v2/users/${userID}`, 57 | options, 58 | ); 59 | 60 | return response; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/modules/common/common.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { Auth0Service } from './auth0.service'; 3 | import { UsersService } from './users.service'; 4 | import { commonProviders } from './common.providers'; 5 | import { DatabaseModule } from '../../database/database.module'; 6 | import { MentorsService } from './mentors.service'; 7 | import { FileService } from './file.service'; 8 | 9 | @Module({ 10 | imports: [DatabaseModule], 11 | providers: [ 12 | MentorsService, 13 | UsersService, 14 | Auth0Service, 15 | FileService, 16 | ...commonProviders, 17 | ], 18 | exports: [ 19 | MentorsService, 20 | UsersService, 21 | Auth0Service, 22 | FileService, 23 | ...commonProviders, 24 | ], 25 | }) 26 | export class CommonModule {} 27 | -------------------------------------------------------------------------------- /src/modules/common/common.providers.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'mongoose'; 2 | import { ApplicationSchema } from './schemas/application.schema'; 3 | import { UserSchema } from './schemas/user.schema'; 4 | import { UserRecordSchema } from './schemas/user-record.schema'; 5 | import { MentorshipSchema } from '../mentorships/schemas/mentorship.schema'; 6 | 7 | export const commonProviders = [ 8 | { 9 | provide: 'USER_MODEL', 10 | useFactory: (connection: Connection) => 11 | connection.model('User', UserSchema), 12 | inject: ['DATABASE_CONNECTION'], 13 | }, 14 | { 15 | provide: 'USER_RECORD_MODEL', 16 | useFactory: (connection: Connection) => 17 | connection.model('UserRecord', UserRecordSchema), 18 | inject: ['DATABASE_CONNECTION'], 19 | }, 20 | { 21 | provide: 'APPLICATION_MODEL', 22 | useFactory: (connection: Connection) => 23 | connection.model('Application', ApplicationSchema), 24 | inject: ['DATABASE_CONNECTION'], 25 | }, 26 | { 27 | provide: 'MENTORSHIP_MODEL', 28 | useFactory: (connection: Connection) => 29 | connection.model('Mentorship', MentorshipSchema), 30 | inject: ['DATABASE_CONNECTION'], 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /src/modules/common/dto/application.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelProperty } from '@nestjs/swagger'; 2 | import { Length, IsString, IsIn, IsDefined } from 'class-validator'; 3 | import { Status } from '../interfaces/application.interface'; 4 | 5 | export class ApplicationDto { 6 | readonly _id: string; 7 | 8 | @ApiModelProperty() 9 | @IsDefined() 10 | @IsString() 11 | @IsIn([Status.APPROVED, Status.PENDING, Status.REJECTED]) 12 | readonly status: Status; 13 | 14 | @ApiModelProperty() 15 | @IsString() 16 | @Length(3, 200) 17 | readonly description: string; 18 | 19 | @ApiModelProperty() 20 | @IsString() 21 | @Length(3, 400) 22 | readonly reason: string; 23 | 24 | constructor(values) { 25 | Object.assign(this, values); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/common/dto/filter.dto.ts: -------------------------------------------------------------------------------- 1 | export class FilterDto { 2 | readonly id: string; 3 | readonly label: string; 4 | 5 | constructor(values) { 6 | Object.assign(this, values); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/common/dto/findOneParams.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsMongoId } from 'class-validator'; 2 | 3 | export class FindOneParams { 4 | @IsMongoId() 5 | id: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/common/dto/mentorfilters.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelPropertyOptional } from '@nestjs/swagger'; 2 | import { IsOptional } from 'class-validator'; 3 | import { Transform } from 'class-transformer'; 4 | import { PaginationDto } from './pagination.dto'; 5 | 6 | export class MentorFiltersDto extends PaginationDto { 7 | @ApiModelPropertyOptional() 8 | @IsOptional() 9 | @Transform((val: string) => val === 'true') 10 | readonly available: boolean; 11 | 12 | @ApiModelPropertyOptional() 13 | @IsOptional() 14 | readonly tags: string; 15 | 16 | @ApiModelPropertyOptional() 17 | @IsOptional() 18 | readonly country: string; 19 | 20 | @ApiModelPropertyOptional() 21 | @IsOptional() 22 | readonly spokenLanguages: string; 23 | 24 | @ApiModelPropertyOptional() 25 | @IsOptional() 26 | readonly name: string; 27 | } 28 | -------------------------------------------------------------------------------- /src/modules/common/dto/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelPropertyOptional } from '@nestjs/swagger'; 2 | import { IsOptional } from 'class-validator'; 3 | 4 | export class PaginationDto { 5 | @ApiModelPropertyOptional() 6 | @IsOptional() 7 | readonly page: number; 8 | 9 | @ApiModelPropertyOptional() 10 | @IsOptional() 11 | readonly limit: number; 12 | 13 | readonly offset: number; 14 | 15 | readonly total: number; 16 | 17 | readonly hasMore: boolean; 18 | 19 | constructor(values) { 20 | if (!values) { 21 | return; 22 | } 23 | 24 | Object.assign(this, values); 25 | this.hasMore = 26 | (values.page - 1) * values.limit + values.limit < values.total; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/modules/common/dto/user-record.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelProperty } from '@nestjs/swagger'; 2 | import { 3 | UserRecord, 4 | UserRecordType, 5 | } from '../interfaces/user-record.interface'; 6 | 7 | export class UserRecordDto { 8 | @ApiModelProperty() 9 | readonly _id: string; 10 | 11 | @ApiModelProperty() 12 | readonly user: string; 13 | 14 | @ApiModelProperty() 15 | readonly type: UserRecordType; 16 | 17 | constructor(values: Partial) { 18 | Object.assign(this, values); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/common/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelProperty, ApiModelPropertyOptional } from '@nestjs/swagger'; 2 | import { 3 | IsBoolean, 4 | IsEmail, 5 | IsUrl, 6 | IsIn, 7 | IsOptional, 8 | Length, 9 | IsString, 10 | IsArray, 11 | ArrayMinSize, 12 | ArrayMaxSize, 13 | IsInt, 14 | } from 'class-validator'; 15 | import { Role, Channel } from '../interfaces/user.interface'; 16 | 17 | export class UserDto { 18 | @ApiModelProperty() 19 | readonly _id: string; 20 | 21 | @ApiModelProperty() 22 | @IsEmail() 23 | @IsString() 24 | readonly email: string; 25 | 26 | @ApiModelProperty() 27 | @Length(3, 50) 28 | @IsString() 29 | readonly name: string; 30 | 31 | @ApiModelProperty() 32 | @IsBoolean() 33 | readonly available: boolean; 34 | 35 | @ApiModelPropertyOptional() 36 | @IsString() 37 | @IsUrl() 38 | readonly avatar: string; 39 | 40 | @ApiModelPropertyOptional() 41 | @Length(3, 50) 42 | @IsString() 43 | @IsOptional() 44 | readonly title: string; 45 | 46 | @ApiModelPropertyOptional() 47 | @Length(3, 400) 48 | @IsString() 49 | @IsOptional() 50 | readonly description: string; 51 | 52 | @ApiModelPropertyOptional() 53 | @IsOptional() 54 | @IsString() 55 | readonly country: string; 56 | 57 | @ApiModelPropertyOptional() 58 | @IsOptional() 59 | @IsString() 60 | readonly timezone: string; 61 | 62 | @ApiModelPropertyOptional() 63 | @IsOptional() 64 | @IsInt() 65 | readonly capacity: number; 66 | 67 | @ApiModelPropertyOptional() 68 | @IsOptional() 69 | @IsString({ 70 | each: true, 71 | }) 72 | readonly spokenLanguages: string[]; 73 | 74 | @ApiModelPropertyOptional() 75 | @IsOptional() 76 | @ArrayMinSize(1) 77 | @ArrayMaxSize(10) 78 | @IsString({ 79 | each: true, 80 | }) 81 | readonly tags: string[]; 82 | 83 | @ApiModelPropertyOptional() 84 | @IsOptional() 85 | @IsIn([Role.ADMIN, Role.MENTOR, Role.MEMBER], { 86 | each: true, 87 | }) 88 | readonly roles: Role[]; 89 | 90 | @ApiModelPropertyOptional() 91 | @IsOptional() 92 | @IsArray() 93 | @ArrayMinSize(1) 94 | @ArrayMaxSize(3) 95 | readonly channels: Channel[]; 96 | 97 | constructor(values) { 98 | Object.assign(this, values); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/modules/common/file.service.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as sharp from 'sharp'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | @Injectable() 6 | export class FileService { 7 | async removeFile(path: string): Promise { 8 | try { 9 | if (fs.existsSync(path)) { 10 | fs.unlinkSync(path); 11 | } 12 | 13 | return Promise.resolve(true); 14 | } catch (error) { 15 | return Promise.resolve(false); 16 | } 17 | } 18 | 19 | async createThumbnail( 20 | source: string, 21 | destination: string, 22 | options: any, 23 | ): Promise { 24 | const buffer = await sharp(source) 25 | .resize(options.width, options.height) 26 | .toBuffer(); 27 | const img = buffer.toString('base64'); 28 | 29 | fs.writeFileSync(destination, img, { encoding: 'base64' }); 30 | 31 | return Promise.resolve(true); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/modules/common/interfaces/application.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document, ObjectID } from 'mongoose'; 2 | 3 | export enum Status { 4 | APPROVED = 'Approved', 5 | PENDING = 'Pending', 6 | REJECTED = 'Rejected', 7 | } 8 | 9 | export interface Application extends Document { 10 | readonly _id: ObjectID; 11 | readonly status: Status; 12 | readonly user: ObjectID; 13 | readonly description: string; 14 | readonly reason: string; 15 | readonly createdAt: Date; 16 | readonly updatedAt: Date; 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/common/interfaces/filemeta.interface.ts: -------------------------------------------------------------------------------- 1 | export interface FileMeta extends Document { 2 | readonly fieldname: string; 3 | readonly originalname: string; 4 | readonly encoding: string; 5 | readonly mimetype: string; 6 | readonly destination: string; 7 | readonly filename: string; 8 | readonly path: string; 9 | readonly size: number; 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/common/interfaces/user-record.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document, ObjectID } from 'mongoose'; 2 | 3 | export enum UserRecordType { 4 | MentorNotResponding = 1, 5 | } 6 | 7 | export interface UserRecord extends Document { 8 | readonly _id: ObjectID; 9 | user: string; 10 | type: UserRecordType; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/common/interfaces/user.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document, ObjectID } from 'mongoose'; 2 | import { FileMeta } from './filemeta.interface'; 3 | 4 | export enum Role { 5 | ADMIN = 'Admin', 6 | MENTOR = 'Mentor', 7 | MEMBER = 'Member', 8 | } 9 | 10 | export enum ChannelName { 11 | EMAIL = 'email', 12 | SLACK = 'slack', 13 | LINKED = 'linkedin', 14 | FACEBOOK = 'facebook', 15 | TWITTER = 'twitter', 16 | GITHUB = 'github', 17 | WEBSITE = 'website', 18 | } 19 | 20 | export interface Channel extends Document { 21 | readonly type: ChannelName; 22 | readonly id: string; 23 | } 24 | 25 | export interface User extends Document { 26 | readonly _id: ObjectID; 27 | readonly auth0Id: string; 28 | readonly email: string; 29 | readonly available: boolean; 30 | readonly name: string; 31 | readonly avatar: string; 32 | readonly image: FileMeta; 33 | readonly title: string; 34 | readonly description: string; 35 | readonly country: string; 36 | readonly spokenLanguages: string[]; 37 | readonly tags: string[]; 38 | readonly roles: Role[]; 39 | readonly channels: Channel[]; 40 | readonly createdAt: string; 41 | readonly capacity: number; 42 | readonly timezone: string; 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/common/mentors.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Query, Model } from 'mongoose'; 3 | import { MentorFiltersDto } from './dto/mentorfilters.dto'; 4 | import { ApplicationDto } from './dto/application.dto'; 5 | import { FilterDto } from './dto/filter.dto'; 6 | import { PaginationDto } from './dto/pagination.dto'; 7 | import { Role, User } from './interfaces/user.interface'; 8 | import { Application, Status } from './interfaces/application.interface'; 9 | import { isObjectId } from '../../utils/objectid'; 10 | import { Mentorship } from '../mentorships/interfaces/mentorship.interface'; 11 | 12 | @Injectable() 13 | export class MentorsService { 14 | constructor( 15 | @Inject('USER_MODEL') private readonly userModel: Model, 16 | @Inject('APPLICATION_MODEL') 17 | private readonly applicationModel: Model, 18 | @Inject('MENTORSHIP_MODEL') 19 | private readonly mentorshipModel: Model, 20 | ) {} 21 | 22 | /** 23 | * Finds a mentor by ID 24 | * @param _id 25 | */ 26 | findById(_id: string): Promise { 27 | if (isObjectId(_id)) { 28 | return this.userModel.findOne({ _id, roles: Role.MENTOR }).exec(); 29 | } 30 | 31 | return Promise.resolve(null); 32 | } 33 | 34 | /** 35 | * Search for mentors by the given filters 36 | * @param filters filters to apply 37 | */ 38 | async findAll(filters: MentorFiltersDto, userAuth0Id: string): Promise { 39 | const onlyMentors: any = { 40 | roles: Role.MENTOR, 41 | }; 42 | const projections = this.getMentorFields(); 43 | 44 | const isLoggedIn = !!userAuth0Id; 45 | if (isLoggedIn) { 46 | projections.channels = true; 47 | } 48 | 49 | if (filters.name) { 50 | onlyMentors.name = { $regex: filters.name, $options: 'i' }; 51 | } 52 | 53 | if ('available' in filters) { 54 | onlyMentors.available = filters.available; 55 | } 56 | 57 | if (filters.tags) { 58 | onlyMentors.tags = { $all: filters.tags.split(',') }; 59 | } 60 | 61 | if (filters.country) { 62 | onlyMentors.country = filters.country; 63 | } 64 | 65 | if (filters.spokenLanguages) { 66 | onlyMentors.spokenLanguages = filters.spokenLanguages; 67 | } 68 | 69 | const countries: FilterDto[] = await this.userModel.findUniqueCountries( 70 | onlyMentors, 71 | ); 72 | const languages: FilterDto[] = await this.userModel.findUniqueLanguages( 73 | onlyMentors, 74 | ); 75 | const technologies: FilterDto[] = await this.userModel 76 | .find(onlyMentors) 77 | .distinct('tags'); 78 | const total: number = await this.userModel 79 | .find(onlyMentors) 80 | .countDocuments(); 81 | const mentors: User[] = await this.userModel 82 | .find(onlyMentors) 83 | .select(projections) 84 | .skip(filters.offset) 85 | .limit(filters.limit) 86 | .sort({ createdAt: 'desc' }) 87 | .exec(); 88 | 89 | const mentorsForCurrentUser = new Set(); 90 | 91 | // determine if we have a logged in user 92 | if (isLoggedIn) { 93 | const user = await this.userModel 94 | .findOne({ auth0Id: userAuth0Id }) 95 | .exec(); 96 | 97 | if (user) { 98 | // find all of their mentorships 99 | const mentorships: Mentorship[] = await this.mentorshipModel 100 | .find({ mentee: user._id, status: Status.APPROVED }) 101 | .exec(); 102 | 103 | // flatten the mentors into a set 104 | mentorships.forEach(mentorship => 105 | mentorsForCurrentUser.add(mentorship.mentor.toString()), 106 | ); 107 | } 108 | } 109 | 110 | return { 111 | mentors: mentors.map(mentor => { 112 | // channels are only visible to a mentee if they have a mentorship with the mentor 113 | const showChannels = mentorsForCurrentUser.has(mentor._id.toString()); 114 | 115 | return { 116 | _id: mentor._id, 117 | available: mentor.available, 118 | spokenLanguages: mentor.spokenLanguages, 119 | tags: mentor.tags, 120 | name: mentor.name, 121 | avatar: mentor.avatar, 122 | title: mentor.title, 123 | description: mentor.description, 124 | country: mentor.country, 125 | createdAt: mentor.createdAt, 126 | channels: showChannels ? mentor.channels : [], 127 | }; 128 | }), 129 | pagination: new PaginationDto({ 130 | total, 131 | page: filters.page, 132 | limit: filters.limit, 133 | }), 134 | filters: { 135 | countries, 136 | languages, 137 | technologies: technologies.sort(), 138 | }, 139 | }; 140 | } 141 | 142 | findApplications(filters): Promise { 143 | return this.applicationModel 144 | .find(filters) 145 | .populate({ 146 | path: 'user', 147 | select: [ 148 | '_id', 149 | 'name', 150 | 'email', 151 | 'avatar', 152 | 'channels', 153 | 'country', 154 | 'createdAt', 155 | 'description', 156 | 'roles', 157 | 'spokenLanguages', 158 | 'tags', 159 | 'title', 160 | ], 161 | }) 162 | .exec(); 163 | } 164 | 165 | /** 166 | * Creates a new application for a user to become a mentor 167 | * @param applicationDto user's application 168 | */ 169 | createApplication(applicationDto: ApplicationDto): Promise> { 170 | const application = new this.applicationModel(applicationDto); 171 | return application.save(); 172 | } 173 | 174 | updateApplication(application: ApplicationDto): Promise> { 175 | return this.applicationModel.updateOne( 176 | { _id: application._id }, 177 | application, 178 | ); 179 | } 180 | 181 | /** 182 | * Find a single application by the given user and status 183 | * @param user 184 | */ 185 | findActiveApplicationByUser(user: User): Promise { 186 | return this.applicationModel 187 | .findOne({ 188 | user: user._id, 189 | status: { $in: [Status.PENDING, Status.APPROVED] }, 190 | }) 191 | .exec(); 192 | } 193 | 194 | findApplicationById(id: string): Promise { 195 | return this.applicationModel.findOne({ _id: id }).exec(); 196 | } 197 | 198 | /** 199 | * Get a random mentor from the database 200 | */ 201 | async findRandomMentor(): Promise { 202 | const filter: any = { roles: 'Mentor' }; 203 | const projections = this.getMentorFields(); 204 | 205 | const total: number = await this.userModel.find(filter).countDocuments(); 206 | const random: number = Math.floor(Math.random() * total); 207 | 208 | return this.userModel 209 | .findOne(filter) 210 | .select(projections) 211 | .skip(random) 212 | .exec(); 213 | } 214 | 215 | removeAllApplicationsByUserId(user: string) { 216 | return this.applicationModel.deleteMany({ user }).exec(); 217 | } 218 | 219 | getMentorFields(): any { 220 | const projections: any = { 221 | available: true, 222 | name: true, 223 | avatar: true, 224 | title: true, 225 | description: true, 226 | createdAt: true, 227 | tags: true, 228 | country: true, 229 | spokenLanguages: true, 230 | }; 231 | 232 | return projections; 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/modules/common/pipes/pagination.pipe.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentMetadata, PipeTransform, Injectable } from '@nestjs/common'; 2 | import Config from '../../../config'; 3 | 4 | @Injectable() 5 | export class PaginationPipe implements PipeTransform { 6 | transform(value: any, metadata: ArgumentMetadata) { 7 | if (value && metadata.type === 'query') { 8 | let page = parseInt(value.page, 10); 9 | let limit = parseInt(value.limit, 10); 10 | 11 | if (isNaN(page) || page <= 0) { 12 | page = 1; 13 | } 14 | 15 | if (isNaN(limit) || limit <= 0) { 16 | limit = Config.pagination.limit; 17 | } 18 | 19 | const offset = (page - 1) * limit; 20 | 21 | return { 22 | ...value, 23 | page, 24 | limit, 25 | offset, 26 | }; 27 | } 28 | 29 | return value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/modules/common/schemas/application.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | /** 4 | * Stores user's application to become a mentor 5 | */ 6 | export const ApplicationSchema = new mongoose.Schema({ 7 | status: String, 8 | description: String, 9 | reason: String, 10 | user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, 11 | }); 12 | 13 | ApplicationSchema.set('timestamps', true); 14 | -------------------------------------------------------------------------------- /src/modules/common/schemas/user-record.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | export const UserRecordSchema = new mongoose.Schema({ 4 | user: { 5 | type: mongoose.Schema.Types.ObjectId, 6 | ref: 'User', 7 | }, 8 | type: { 9 | type: Number, 10 | required: true, 11 | }, 12 | }); 13 | 14 | UserRecordSchema.set('timestamps', true); 15 | -------------------------------------------------------------------------------- /src/modules/common/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import * as countriesDb from 'i18n-iso-countries'; 3 | import * as languagesDb from 'iso-639-1'; 4 | import { ChannelName } from '../interfaces/user.interface'; 5 | import { FilterDto } from '../dto/filter.dto'; 6 | 7 | export const ChannelSchema = new mongoose.Schema({ 8 | type: { 9 | type: String, 10 | enum: Object.values(ChannelName), 11 | required: true, 12 | }, 13 | id: { 14 | type: String, 15 | required: true, 16 | }, 17 | }); 18 | 19 | export const AvatarSchema = new mongoose.Schema({ 20 | fieldname: String, 21 | originalname: String, 22 | encoding: String, 23 | mimetype: String, 24 | destination: String, 25 | filename: String, 26 | path: String, 27 | size: Number, 28 | }); 29 | 30 | export const UserSchema = new mongoose.Schema({ 31 | auth0Id: { 32 | type: String, 33 | required: true, 34 | }, 35 | email: { 36 | type: String, 37 | required: true, 38 | }, 39 | available: Boolean, 40 | name: { 41 | type: String, 42 | required: true, 43 | }, 44 | avatar: { 45 | type: String, 46 | required: true, 47 | }, 48 | image: AvatarSchema, 49 | title: String, 50 | description: String, 51 | country: String, 52 | spokenLanguages: Array, 53 | tags: Array, 54 | roles: Array, 55 | channels: [ChannelSchema], 56 | }); 57 | 58 | UserSchema.set('timestamps', true); 59 | 60 | UserSchema.statics.findUniqueCountries = async function( 61 | filters, 62 | ): Promise { 63 | const result: FilterDto[] = []; 64 | 65 | const countries = await this.find(filters).distinct('country'); 66 | 67 | countries.sort().forEach(id => { 68 | const label: string = countriesDb.getName(id, 'en'); 69 | 70 | if (label) { 71 | result.push(new FilterDto({ id, label })); 72 | } 73 | }); 74 | 75 | return result; 76 | }; 77 | 78 | UserSchema.statics.findUniqueLanguages = async function( 79 | filters, 80 | ): Promise { 81 | const result: FilterDto[] = []; 82 | 83 | const languages = await this.find(filters).distinct('spokenLanguages'); 84 | 85 | languages.sort().forEach(id => { 86 | // @ts-ignore 87 | const label: string = languagesDb.getName(id); 88 | 89 | if (label) { 90 | result.push(new FilterDto({ id, label })); 91 | } 92 | }); 93 | 94 | return result; 95 | }; 96 | -------------------------------------------------------------------------------- /src/modules/common/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Query, Model } from 'mongoose'; 3 | import { UserDto } from './dto/user.dto'; 4 | import { User, Role } from './interfaces/user.interface'; 5 | import { isObjectId } from '../../utils/objectid'; 6 | import { UserRecord } from './interfaces/user-record.interface'; 7 | 8 | @Injectable() 9 | export class UsersService { 10 | constructor( 11 | @Inject('USER_MODEL') private readonly userModel: Model, 12 | @Inject('USER_RECORD_MODEL') 13 | private readonly userRecordModel: Model, 14 | ) {} 15 | 16 | async create(userDto: UserDto): Promise { 17 | const user = new this.userModel(userDto); 18 | return await user.save(); 19 | } 20 | 21 | async findById(_id: string): Promise { 22 | if (isObjectId(_id)) { 23 | return await this.userModel.findOne({ _id }).lean().exec(); 24 | } 25 | 26 | return Promise.resolve(null); 27 | } 28 | 29 | async findByAuth0Id(auth0Id: string): Promise { 30 | return await this.userModel.findOne({ auth0Id }).exec(); 31 | } 32 | 33 | async findByEmail(email: string): Promise { 34 | return await this.userModel.findOne({ email }).exec(); 35 | } 36 | 37 | async findAll(): Promise { 38 | return await this.userModel.find().exec(); 39 | } 40 | 41 | async update(userDto: UserDto): Promise> { 42 | return await this.userModel.updateOne({ _id: userDto._id }, userDto, { 43 | runValidators: true, 44 | }); 45 | } 46 | 47 | async remove(_id: string): Promise> { 48 | if (isObjectId(_id)) { 49 | return await this.userModel.deleteOne({ _id }); 50 | } 51 | 52 | return Promise.resolve({ 53 | ok: 0, 54 | }); 55 | } 56 | 57 | async addRecord(userRecordDto: UserRecord): Promise { 58 | const userRecord = new this.userRecordModel(userRecordDto); 59 | return await userRecord.save(); 60 | } 61 | 62 | getRecords(user: string): Promise { 63 | return this.userRecordModel.find({ user }).exec(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/modules/email/email.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EmailService } from './email.service'; 3 | 4 | @Module({ 5 | providers: [EmailService], 6 | exports: [EmailService], 7 | }) 8 | export class EmailModule {} 9 | -------------------------------------------------------------------------------- /src/modules/email/email.service.ts: -------------------------------------------------------------------------------- 1 | import Config from '../../config'; 2 | import * as sgMail from '@sendgrid/mail'; 3 | import * as sgClient from '@sendgrid/client'; 4 | import { EmailParams, SendData } from './interfaces/email.interface'; 5 | import { Injectable } from '@nestjs/common'; 6 | import { User } from '../common/interfaces/user.interface'; 7 | import { promises } from 'fs'; 8 | import { compile } from 'ejs'; 9 | 10 | const isProduction = process.env.NODE_ENV === 'production'; 11 | const defaults = { 12 | from: Config.email.FROM, 13 | }; 14 | 15 | const DEV_TESTING_LIST = '423467cd-c4bd-410c-ad52-adcd8dfbc389'; 16 | 17 | @Injectable() 18 | export class EmailService { 19 | constructor() { 20 | sgMail.setApiKey(Config.sendGrid.API_KEY); 21 | sgClient.setApiKey(Config.sendGrid.API_KEY); 22 | } 23 | 24 | static LIST_IDS = { 25 | // We are adding all dev/testing contacts to a dev list, so we can remove them easly 26 | MENTORS: isProduction 27 | ? '3e581cd7-9b14-4486-933e-1e752557433f' 28 | : DEV_TESTING_LIST, 29 | NEWSLETTER: isProduction 30 | ? '6df91cab-90bd-4eaa-9710-c3804f8aba01' 31 | : DEV_TESTING_LIST, 32 | }; 33 | 34 | private getTemplateContent(name: string) { 35 | return promises.readFile(`content/email_templates/${name}.html`, { 36 | encoding: 'utf8', 37 | }); 38 | } 39 | 40 | get layout() { 41 | return this.getTemplateContent('layout'); 42 | } 43 | 44 | async send(data: SendData) { 45 | const newData = Object.assign({}, defaults, data); 46 | return await sgMail.send(newData); 47 | } 48 | 49 | async sendLocalTemplate(params: EmailParams) { 50 | const { to, subject, data = {}, name } = params; 51 | const content = await this.injectData(name, data); 52 | try { 53 | await sgMail.send({ 54 | to, 55 | subject, 56 | html: content, 57 | from: Config.email.FROM, 58 | }); 59 | } catch (error) { 60 | // tslint:disable-next-line:no-console 61 | console.log('Send email error', params, JSON.stringify(error, null, 2)); 62 | } 63 | } 64 | 65 | async addMentor(contact: User) { 66 | const request = { 67 | json: undefined, // <--- I spent hours finding out why Sendgrid was returning 400 error, this fixed the issue 68 | method: 'PUT', 69 | url: '/v3/marketing/contacts', 70 | body: JSON.stringify({ 71 | list_ids: [EmailService.LIST_IDS.MENTORS], 72 | contacts: [ 73 | { 74 | email: contact.email, 75 | first_name: contact.name, 76 | country: contact.country, 77 | custom_fields: { 78 | // We can clean our list in SG with this field 79 | e2_T: isProduction ? 'production' : 'development', 80 | }, 81 | }, 82 | ], 83 | }), 84 | }; 85 | 86 | return await sgClient.request(request); 87 | } 88 | 89 | private async injectData(name: string, data: Record) { 90 | const template = await this.getTemplateContent(name); 91 | const layout = await this.layout; 92 | const content = compile(template)(data); 93 | return compile(layout)({ content }); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/modules/email/interfaces/email.interface.ts: -------------------------------------------------------------------------------- 1 | import { Channel } from '../../common/interfaces/user.interface'; 2 | import type { MailData } from '@sendgrid/helpers/classes/mail'; 3 | 4 | export enum Template { 5 | WELCOME_MESSAGE = 'd-1434be390e1b4288b8011507f1c8d786', 6 | MENTOR_APPLICATION_RECEIVED = 'd-bf78306901e747a7b3f92761b9884f2e', 7 | MENTOR_APPLICATION_APPROVED = 'd-88dc20e5dd164510a32f659f9347824e', 8 | MENTOR_APPLICATION_REJECTED = 'd-ad08366d02654587916a41bb3270afed', 9 | MENTORSHIP_REQUEST = 'd-f2547d9191624163b1dd6dad40afa777', 10 | USER_DELETED = 'de2e0c5217b6422a88274a6affd327e7', 11 | MENTORSHIP_REQUEST_APPROVED = 'd-f92a1768d23842818335d54ec5bb96e1', 12 | MENTORSHIP_REQUEST_REJECTED = 'd-8521ac50737f4b0384a95552dc02db9f', 13 | } 14 | 15 | interface WelcomePayload { 16 | name: 'welcome'; 17 | data: { 18 | name: string; 19 | }; 20 | } 21 | 22 | interface MentorshipAccepted { 23 | name: 'mentorship-accepted'; 24 | data: { 25 | menteeName: string; 26 | mentorName: string; 27 | contactURL: string; 28 | openRequests: number; 29 | }; 30 | } 31 | 32 | interface MentorshipCancelled { 33 | name: 'mentorship-cancelled'; 34 | data: { 35 | mentorName: string; 36 | menteeName: string; 37 | reason: string; 38 | }; 39 | } 40 | 41 | interface MentorshipDeclined { 42 | name: 'mentorship-declined'; 43 | data: { 44 | menteeName: string; 45 | mentorName: string; 46 | reason: string; 47 | bySystem: boolean; 48 | }; 49 | } 50 | 51 | interface MentorshipRequested { 52 | name: 'mentorship-requested'; 53 | data: { 54 | menteeName: string; 55 | menteeEmail: string; 56 | mentorName: string; 57 | message: string; 58 | background: string; 59 | expectation: string; 60 | }; 61 | } 62 | 63 | interface MentorshipReminder { 64 | name: 'mentorship-reminder'; 65 | data: { 66 | menteeName: string; 67 | mentorName: string; 68 | message: string; 69 | }; 70 | } 71 | 72 | interface MentorApplicationReceived { 73 | name: 'mentor-application-received'; 74 | data: { 75 | name: string; 76 | }; 77 | } 78 | 79 | interface MentorApplicationDeclined { 80 | name: 'mentor-application-declined'; 81 | data: { 82 | name: string; 83 | reason: string; 84 | }; 85 | } 86 | 87 | interface MentorApplicationApproved { 88 | name: 'mentor-application-approved'; 89 | data: { 90 | name: string; 91 | }; 92 | } 93 | 94 | interface MentorNotActive { 95 | name: 'mentor-not-active'; 96 | data: { 97 | mentorName: string; 98 | numOfMentorshipRequests: number; 99 | }; 100 | } 101 | 102 | interface MentorFreeze { 103 | name: 'mentor-freeze'; 104 | data: { 105 | mentorName: string; 106 | }; 107 | } 108 | 109 | export type EmailParams = Required> & 110 | ( 111 | | WelcomePayload 112 | | MentorshipAccepted 113 | | MentorshipCancelled 114 | | MentorshipDeclined 115 | | MentorshipRequested 116 | | MentorshipReminder 117 | | MentorApplicationReceived 118 | | MentorApplicationDeclined 119 | | MentorApplicationApproved 120 | | MentorNotActive 121 | | MentorFreeze 122 | ); 123 | 124 | export interface SendData { 125 | to: string; 126 | templateId: Template; 127 | dynamic_template_data?: T; 128 | } 129 | 130 | /** 131 | * Data for SendGrid templates, when an admin rejects a mentor from the platform. 132 | */ 133 | export interface SendDataRejectParams { 134 | reason: string; 135 | } 136 | 137 | /** 138 | * Data for SendGrid templates, when a user request a mentorship. 139 | */ 140 | export interface SendDataMentorshipParams { 141 | name: string; 142 | message: string; 143 | } 144 | 145 | export interface SendDataMentorshipApprovalParams { 146 | menteeName: string; 147 | mentorName: string; 148 | contactURL: string; 149 | channels: Channel[]; 150 | } 151 | 152 | export interface SendDataMentorshipRejectionParams { 153 | menteeName: string; 154 | mentorName: string; 155 | reason: string; 156 | } 157 | -------------------------------------------------------------------------------- /src/modules/lists/__tests__/favorites.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, UnauthorizedException } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { Request } from 'express'; 4 | import { FavoritesController } from '../favorites.controller'; 5 | import { ListsService } from '../lists.service'; 6 | import { List } from '../interfaces/list.interface'; 7 | import { ListDto } from '../dto/list.dto'; 8 | import { UsersService } from '../../common/users.service'; 9 | import { Role, User } from '../../common/interfaces/user.interface'; 10 | 11 | class ServiceMock {} 12 | 13 | class ObjectIdMock { 14 | current: string; 15 | 16 | constructor(current: string) { 17 | this.current = current; 18 | } 19 | equals(value) { 20 | return this.current === value.current; 21 | } 22 | } 23 | 24 | describe('modules/lists/FavoritesController', () => { 25 | let favoritesController: FavoritesController; 26 | let usersService: UsersService; 27 | let listsService: ListsService; 28 | 29 | beforeEach(async () => { 30 | const module = await Test.createTestingModule({ 31 | controllers: [FavoritesController], 32 | providers: [ 33 | { 34 | provide: UsersService, 35 | useValue: new ServiceMock(), 36 | }, 37 | { 38 | provide: ListsService, 39 | useValue: new ServiceMock(), 40 | }, 41 | ], 42 | }).compile(); 43 | 44 | usersService = module.get(UsersService); 45 | listsService = module.get(ListsService); 46 | favoritesController = module.get(FavoritesController); 47 | }); 48 | 49 | describe('toggle', () => { 50 | let userId: string; 51 | let mentorId: string; 52 | let mentorIdObj: ObjectIdMock; 53 | let request: any; 54 | let response: any; 55 | 56 | beforeEach(() => { 57 | userId = '1234'; 58 | mentorId = '5678'; 59 | mentorIdObj = new ObjectIdMock(mentorId); 60 | request = { user: { auth0Id: '1234' } }; 61 | response = { success: true }; 62 | usersService.findByAuth0Id = jest.fn(() => 63 | Promise.resolve({ 64 | _id: new ObjectIdMock(userId), 65 | roles: [Role.MEMBER, Role.ADMIN], 66 | } as User), 67 | ); 68 | usersService.findById = jest.fn(() => 69 | Promise.resolve({ 70 | _id: new ObjectIdMock(userId), 71 | roles: [Role.MEMBER, Role.MENTOR], 72 | } as User), 73 | ); 74 | listsService.createList = jest.fn(() => 75 | Promise.resolve({ _id: '12345' } as List), 76 | ); 77 | listsService.findFavoriteList = jest.fn(() => 78 | Promise.resolve({ 79 | _id: '12345', 80 | mentors: [{ _id: mentorIdObj }], 81 | } as List), 82 | ); 83 | listsService.update = jest.fn(() => Promise.resolve()); 84 | }); 85 | 86 | it('should throw an error when user not found', async () => { 87 | usersService.findById = jest.fn(() => Promise.resolve(undefined)); 88 | 89 | await expect( 90 | favoritesController.toggle(request as Request, userId, mentorId), 91 | ).rejects.toThrow(BadRequestException); 92 | }); 93 | 94 | it('should throw an error if trying to add not a mentor', async () => { 95 | usersService.findById = jest.fn(() => 96 | Promise.resolve({ 97 | _id: new ObjectIdMock(mentorId), 98 | roles: [Role.MEMBER], 99 | } as User), 100 | ); 101 | 102 | await expect( 103 | favoritesController.toggle(request as Request, userId, mentorId), 104 | ).rejects.toThrow(BadRequestException); 105 | }); 106 | 107 | it('should throw an error when toggling favorites for other user', async () => { 108 | usersService.findByAuth0Id = jest.fn(() => 109 | Promise.resolve({ 110 | _id: new ObjectIdMock('1234'), 111 | roles: [Role.MEMBER], 112 | } as User), 113 | ); 114 | usersService.findById = jest.fn(() => 115 | Promise.resolve({ 116 | _id: new ObjectIdMock('5678'), 117 | roles: [Role.MEMBER, Role.MENTOR], 118 | } as User), 119 | ); 120 | 121 | await expect( 122 | favoritesController.toggle(request as Request, userId, mentorId), 123 | ).rejects.toThrow(UnauthorizedException); 124 | }); 125 | 126 | it('should create a new favorite list and add a mentor when favorite list doesn\'t exist', async () => { 127 | listsService.findFavoriteList = jest.fn(() => Promise.resolve(undefined)); 128 | 129 | expect( 130 | await favoritesController.toggle(request, userId, mentorId), 131 | ).toEqual(response); 132 | expect(listsService.createList).toHaveBeenCalledTimes(1); 133 | expect(listsService.createList).toHaveBeenCalledWith({ 134 | name: 'Favorites', 135 | isFavorite: true, 136 | user: { _id: new ObjectIdMock(userId) }, 137 | // this should be `mentorId`, but I need to find out how to mock a second call in jest to return a different value 138 | mentors: [{ _id: new ObjectIdMock(userId) }], 139 | }); 140 | }); 141 | 142 | it('should add a new mentor to the existing favorite list', async () => { 143 | expect( 144 | await favoritesController.toggle(request, userId, mentorId), 145 | ).toEqual(response); 146 | expect(listsService.update).toHaveBeenCalledTimes(1); 147 | expect(listsService.update).toHaveBeenCalledWith({ 148 | _id: '12345', 149 | mentors: [ 150 | { _id: new ObjectIdMock('5678') }, 151 | { _id: new ObjectIdMock('1234') }, 152 | ], 153 | }); 154 | }); 155 | 156 | it('should remove and existing mentor from the favorite list', async () => { 157 | const _id = { _id: mentorIdObj }; 158 | usersService.findById = jest.fn(() => 159 | Promise.resolve({ _id, roles: [Role.MEMBER, Role.MENTOR] } as User), 160 | ); 161 | // @ts-ignore 162 | listsService.findFavoriteList = jest.fn(() => 163 | Promise.resolve({ _id: '12345', mentors: [_id] } as List), 164 | ); 165 | 166 | expect( 167 | await favoritesController.toggle(request, userId, mentorId), 168 | ).toEqual(response); 169 | expect(listsService.update).toHaveBeenCalledTimes(1); 170 | }); 171 | }); 172 | 173 | describe('list', () => { 174 | let userId: string; 175 | let response: any; 176 | let request: Request; 177 | let list: List; 178 | let user: User; 179 | 180 | beforeEach(() => { 181 | userId = '123'; 182 | list = { 183 | _id: 123, 184 | name: 'Favorites', 185 | mentors: [ 186 | { _id: 123, name: 'Sarah Doe' }, 187 | { _id: 456, name: 'John Doe' }, 188 | ], 189 | } as List; 190 | request = { user: { auth0Id: '1234' } } as Request; 191 | response = { success: true, data: list }; 192 | user = { _id: new ObjectIdMock(userId), roles: [Role.MEMBER] } as User; 193 | 194 | usersService.findByAuth0Id = jest.fn(() => Promise.resolve(user)); 195 | usersService.findById = jest.fn(() => Promise.resolve(user)); 196 | listsService.findFavoriteList = jest.fn(() => Promise.resolve(list)); 197 | }); 198 | 199 | it('should throw an error when user doesnt exist', async () => { 200 | usersService.findById = jest.fn(() => Promise.resolve(undefined)); 201 | 202 | await expect(favoritesController.list(request, userId)).rejects.toThrow( 203 | BadRequestException, 204 | ); 205 | }); 206 | 207 | it('should throw an error when trying to get favorites from other user', async () => { 208 | usersService.findByAuth0Id = jest.fn(() => 209 | Promise.resolve({ 210 | _id: new ObjectIdMock('9843'), 211 | roles: [Role.MEMBER], 212 | } as User), 213 | ); 214 | 215 | await expect(favoritesController.list(request, userId)).rejects.toThrow( 216 | UnauthorizedException, 217 | ); 218 | }); 219 | 220 | it('should return the favorites for any user to an admin', async () => { 221 | usersService.findByAuth0Id = jest.fn(() => 222 | Promise.resolve({ 223 | _id: new ObjectIdMock('9843'), 224 | roles: [Role.ADMIN], 225 | } as User), 226 | ); 227 | 228 | expect(await favoritesController.list(request, userId)).toEqual(response); 229 | expect(listsService.findFavoriteList).toHaveBeenCalledTimes(1); 230 | expect(listsService.findFavoriteList).toHaveBeenCalledWith(user); 231 | }); 232 | 233 | it('should return the favorites for the current user', async () => { 234 | expect(await favoritesController.list(request, userId)).toEqual(response); 235 | expect(listsService.findFavoriteList).toHaveBeenCalledTimes(1); 236 | expect(listsService.findFavoriteList).toHaveBeenCalledWith(user); 237 | }); 238 | 239 | it('should return and empty array when there are no favorites', async () => { 240 | listsService.findFavoriteList = jest.fn(() => Promise.resolve(undefined)); 241 | 242 | expect(await favoritesController.list(request, userId)).toEqual({ 243 | success: true, 244 | data: { mentors: [] }, 245 | }); 246 | expect(listsService.findFavoriteList).toHaveBeenCalledTimes(1); 247 | expect(listsService.findFavoriteList).toHaveBeenCalledWith(user); 248 | }); 249 | }); 250 | }); 251 | -------------------------------------------------------------------------------- /src/modules/lists/dto/list.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelProperty, ApiModelPropertyOptional } from '@nestjs/swagger'; 2 | import { 3 | IsArray, 4 | IsBoolean, 5 | IsOptional, 6 | IsString, 7 | Length, 8 | } from 'class-validator'; 9 | import { User } from '../../common/interfaces/user.interface'; 10 | 11 | export class ListDto { 12 | @ApiModelProperty() 13 | readonly _id: string; 14 | 15 | @ApiModelProperty() 16 | @IsString() 17 | @Length(3, 50) 18 | readonly name: string; 19 | 20 | @ApiModelPropertyOptional() 21 | @IsOptional() 22 | @IsBoolean() 23 | readonly public: boolean; 24 | 25 | @ApiModelPropertyOptional() 26 | @IsOptional() 27 | @IsArray() 28 | readonly mentors: User[]; 29 | 30 | readonly user: User; 31 | 32 | constructor(values) { 33 | Object.assign(this, values); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/lists/favorites.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Controller, 4 | Get, 5 | Post, 6 | Param, 7 | Req, 8 | UnauthorizedException, 9 | UsePipes, 10 | ValidationPipe, 11 | } from '@nestjs/common'; 12 | import { ApiBearerAuth, ApiOperation, ApiUseTags } from '@nestjs/swagger'; 13 | import { Request } from 'express'; 14 | import { Role, User } from '../common/interfaces/user.interface'; 15 | import { List } from './interfaces/list.interface'; 16 | import { UsersService } from '../common/users.service'; 17 | import { ListsService } from './lists.service'; 18 | import { ListDto } from './dto/list.dto'; 19 | 20 | @ApiUseTags('/users/:userid/favorites') 21 | @ApiBearerAuth() 22 | @Controller('users/:userid/favorites') 23 | export class FavoritesController { 24 | constructor( 25 | private readonly usersService: UsersService, 26 | private readonly listsService: ListsService, 27 | ) {} 28 | 29 | @ApiOperation({ title: 'Returns the favorite list for the given user' }) 30 | @Get() 31 | async list(@Req() request: Request, @Param('userid') userId: string) { 32 | const [current, user]: [User, User] = await Promise.all([ 33 | this.usersService.findByAuth0Id(request.user.auth0Id), 34 | this.usersService.findById(userId), 35 | ]); 36 | 37 | // Make sure user exist 38 | if (!user) { 39 | throw new BadRequestException('User not found'); 40 | } 41 | 42 | // Only admins or current user can get the favorites 43 | if (!current._id.equals(user._id) && !current.roles.includes(Role.ADMIN)) { 44 | throw new UnauthorizedException( 45 | 'Not authorized to perform this operation', 46 | ); 47 | } 48 | 49 | const list: List = await this.listsService.findFavoriteList(user); 50 | const data: List = list || ({ mentors: [] } as List); 51 | 52 | return { 53 | success: true, 54 | data, 55 | }; 56 | } 57 | 58 | @ApiOperation({ title: 'Adds or removes a mentor from the favorite list' }) 59 | @Post(':mentorid') 60 | async toggle( 61 | @Req() request: Request, 62 | @Param('userid') userId: string, 63 | @Param('mentorid') mentorId: string, 64 | ) { 65 | const [current, user, mentor]: [User, User, User] = await Promise.all([ 66 | this.usersService.findByAuth0Id(request.user.auth0Id), 67 | this.usersService.findById(userId), 68 | this.usersService.findById(mentorId), 69 | ]); 70 | 71 | // Make sure user exist 72 | if (!user) { 73 | throw new BadRequestException('User not found'); 74 | } 75 | 76 | // Make sure mentor exist 77 | if (!mentor || !mentor.roles.includes(Role.MENTOR)) { 78 | throw new BadRequestException('Mentor not found'); 79 | } 80 | 81 | // Only admins can toggle favorites for other users 82 | if (!current._id.equals(user._id) && !current.roles.includes(Role.ADMIN)) { 83 | throw new UnauthorizedException( 84 | 'Not authorized to perform this operation', 85 | ); 86 | } 87 | 88 | // We can only have a single favorites list 89 | const list: List = await this.listsService.findFavoriteList(user); 90 | 91 | // If the favorites doesn't exist yet, we need to create it 92 | if (!list) { 93 | const favorites: ListDto = new ListDto({ 94 | name: 'Favorites', 95 | isFavorite: true, 96 | user: { _id: user._id } as User, 97 | mentors: [{ _id: mentor._id } as User], 98 | }); 99 | 100 | await this.listsService.createList(favorites); 101 | } else { 102 | let listDto: ListDto; 103 | 104 | if (list.mentors.find(item => item._id.equals(mentor._id))) { 105 | // If the mentor exist in the list we need to remove it 106 | listDto = new ListDto({ 107 | _id: list._id, 108 | mentors: list.mentors.filter(item => !item._id.equals(mentor._id)), 109 | }); 110 | } else { 111 | // If the mentor doesn't exist in the list we need to add it 112 | listDto = new ListDto({ 113 | _id: list._id, 114 | mentors: [...list.mentors, { _id: mentor._id }], 115 | }); 116 | } 117 | 118 | await this.listsService.update(listDto); 119 | } 120 | 121 | return { 122 | success: true, 123 | }; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/modules/lists/interfaces/list.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document, ObjectID } from 'mongoose'; 2 | import { User } from '../../common/interfaces/user.interface'; 3 | 4 | export interface List extends Document { 5 | readonly _id: ObjectID; 6 | readonly user: ObjectID; 7 | readonly name: string; 8 | readonly public: boolean; 9 | readonly isFavorite: boolean; 10 | readonly mentors: User[]; 11 | readonly createdAt: Date; 12 | readonly updatedAt: Date; 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/lists/list.providers.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'mongoose'; 2 | import { ListSchema } from './schemas/list.schema'; 3 | 4 | export const listsProviders = [ 5 | { 6 | provide: 'LIST_MODEL', 7 | useFactory: (connection: Connection) => 8 | connection.model('List', ListSchema), 9 | inject: ['DATABASE_CONNECTION'], 10 | }, 11 | ]; 12 | -------------------------------------------------------------------------------- /src/modules/lists/lists.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Get, 6 | Post, 7 | Param, 8 | Req, 9 | UnauthorizedException, 10 | UsePipes, 11 | ValidationPipe, 12 | Delete, 13 | Put, 14 | } from '@nestjs/common'; 15 | import { ApiBearerAuth, ApiOperation, ApiUseTags } from '@nestjs/swagger'; 16 | import { Request } from 'express'; 17 | import { Role, User } from '../common/interfaces/user.interface'; 18 | import { List } from './interfaces/list.interface'; 19 | import { UsersService } from '../common/users.service'; 20 | import { ListsService } from './lists.service'; 21 | import { ListDto } from './dto/list.dto'; 22 | 23 | @ApiUseTags('/users/:userid/lists') 24 | @ApiBearerAuth() 25 | @Controller('users/:userid/lists') 26 | export class ListsController { 27 | constructor( 28 | private readonly usersService: UsersService, 29 | private readonly listsService: ListsService, 30 | ) {} 31 | 32 | @ApiOperation({ title: `Creates a new mentor's list for the given user` }) 33 | @Post() 34 | @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) 35 | async store( 36 | @Req() request: Request, 37 | @Param('userid') userId: string, 38 | @Body() data: ListDto, 39 | ) { 40 | const [current, user]: [User, User] = await Promise.all([ 41 | this.usersService.findByAuth0Id(request.user.auth0Id), 42 | this.usersService.findById(userId), 43 | ]); 44 | 45 | // Make sure user exist 46 | if (!user) { 47 | throw new BadRequestException('User not found'); 48 | } 49 | 50 | // Only admins can create lists for other users 51 | if (!current._id.equals(user._id) && !current.roles.includes(Role.ADMIN)) { 52 | throw new UnauthorizedException( 53 | 'Not authorized to perform this operation', 54 | ); 55 | } 56 | 57 | const list: List = await this.listsService.createList({ 58 | ...data, 59 | user: { _id: user._id } as User, 60 | }); 61 | 62 | return { 63 | success: true, 64 | list: { _id: list._id }, 65 | }; 66 | } 67 | 68 | @ApiOperation({ title: `Gets mentor's list for the given user` }) 69 | @Get() 70 | async myList(@Req() request, @Param('userid') userId: string) { 71 | const [current, user]: [User, User] = await Promise.all([ 72 | this.usersService.findByAuth0Id(request.user.auth0Id), 73 | this.usersService.findById(userId), 74 | ]); 75 | 76 | // check if user exists 77 | if (!user) { 78 | throw new BadRequestException('User not found'); 79 | } 80 | let lists: List[]; 81 | 82 | // Only current user and admins can view both private and public lists for a user 83 | if (current._id.equals(user._id) || current.roles.includes(Role.ADMIN)) { 84 | lists = await this.listsService.findByUserId({ userId }); 85 | } else { 86 | lists = await this.listsService.findByUserId({ userId, public: true }); 87 | } 88 | 89 | return { 90 | success: true, 91 | lists, 92 | }; 93 | } 94 | 95 | @ApiOperation({ title: 'Updates a given list' }) 96 | @Put('/:listid') 97 | @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) 98 | async updateList( 99 | @Req() request: Request, 100 | @Param('userid') userId: string, 101 | @Param('listid') listId: string, 102 | @Body() data: ListDto, 103 | ) { 104 | const [current, user]: [User, User] = await Promise.all([ 105 | this.usersService.findByAuth0Id(request.user.auth0Id), 106 | this.usersService.findById(userId), 107 | ]); 108 | 109 | // check if user exists 110 | if (!user) { 111 | throw new BadRequestException('User not found'); 112 | } 113 | 114 | // only admins and current user can update lists 115 | if (!current._id.equals(user._id) && !current.roles.includes(Role.ADMIN)) { 116 | throw new UnauthorizedException( 117 | 'Not authorized to perform this operation', 118 | ); 119 | } 120 | 121 | const list: List[] = await this.listsService.findByUserId({ 122 | userId, 123 | listId, 124 | isFavorite: false, 125 | }); 126 | 127 | // check if list exists 128 | if (list.length < 1) { 129 | throw new BadRequestException('list not found'); 130 | } 131 | 132 | // update list 133 | const listInfo: ListDto = { 134 | _id: listId, 135 | ...data, 136 | }; 137 | 138 | await this.listsService.update(listInfo); 139 | 140 | return { 141 | success: true, 142 | }; 143 | } 144 | 145 | @ApiOperation({ title: `Deletes the given mentor's list for the given user` }) 146 | @Delete(':listId') 147 | async deleteList( 148 | @Req() request: Request, 149 | @Param('userid') userId: string, 150 | @Param('listId') listId: string, 151 | ) { 152 | const [current, user]: [User, User] = await Promise.all([ 153 | this.usersService.findByAuth0Id(request.user.auth0Id), 154 | this.usersService.findById(userId), 155 | ]); 156 | 157 | if (!user) { 158 | throw new BadRequestException('User not found'); 159 | } 160 | 161 | if (!current._id.equals(user._id) && !current.roles.includes(Role.ADMIN)) { 162 | throw new UnauthorizedException( 163 | 'Not authorized to perform this operation', 164 | ); 165 | } 166 | 167 | try { 168 | const res: any = await this.listsService.delete(listId); 169 | return { 170 | success: res.ok === 1, 171 | }; 172 | } catch (error) { 173 | return { 174 | success: false, 175 | error: error.message, 176 | }; 177 | } 178 | } 179 | 180 | @ApiOperation({ title: 'Add a new mentor to existing list' }) 181 | @Put('/:listid/add') 182 | async addMentorToList( 183 | @Req() request: Request, 184 | @Param('userid') userId: string, 185 | @Param('listid') listId: string, 186 | @Body() data: ListDto, 187 | ) { 188 | const [current, user]: [User, User] = await Promise.all([ 189 | this.usersService.findByAuth0Id(request.user.auth0Id), 190 | this.usersService.findById(userId), 191 | ]); 192 | 193 | // check if user exists 194 | if (!user) { 195 | throw new BadRequestException('User not found'); 196 | } 197 | 198 | // only admins and current user can add mentors to a list 199 | if (!current._id.equals(user._id) && !current.roles.includes(Role.ADMIN)) { 200 | throw new UnauthorizedException( 201 | 'Not authorized to perform this operation', 202 | ); 203 | } 204 | 205 | const list: List[] = await this.listsService.findByUserId({ 206 | userId, 207 | listId, 208 | isFavorite: false, 209 | }); 210 | 211 | // check if list exists 212 | if (list.length < 1) { 213 | throw new BadRequestException('list not found'); 214 | } 215 | const listInfo = { 216 | _id: listId, 217 | mentors: [...list[0].mentors, ...data.mentors], 218 | } as ListDto; 219 | 220 | await this.listsService.update(listInfo); 221 | 222 | return { 223 | success: true, 224 | }; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/modules/lists/lists.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ListsController } from './lists.controller'; 3 | import { FavoritesController } from './favorites.controller'; 4 | import { DatabaseModule } from '../../database/database.module'; 5 | import { CommonModule } from '../common/common.module'; 6 | import { ListsService } from './lists.service'; 7 | import { listsProviders } from './list.providers'; 8 | 9 | @Module({ 10 | imports: [DatabaseModule, CommonModule], 11 | controllers: [FavoritesController, ListsController], 12 | providers: [ListsService, ...listsProviders], 13 | exports: [ListsService, ...listsProviders], 14 | }) 15 | export class ListsModule {} 16 | -------------------------------------------------------------------------------- /src/modules/lists/lists.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Query, Model } from 'mongoose'; 3 | import { List } from './interfaces/list.interface'; 4 | import { ListDto } from './dto/list.dto'; 5 | import { User } from '../common/interfaces/user.interface'; 6 | 7 | @Injectable() 8 | export class ListsService { 9 | constructor(@Inject('LIST_MODEL') private readonly listModel: Model) {} 10 | 11 | /** 12 | * Creates a new list in the database 13 | */ 14 | async createList(data: ListDto): Promise { 15 | const list = new this.listModel(data); 16 | return await list.save(); 17 | } 18 | 19 | /** 20 | * Updates an existing list 21 | */ 22 | async update(list: ListDto): Promise> { 23 | return await this.listModel.updateOne({ _id: list._id }, list, { 24 | runValidators: true, 25 | }); 26 | } 27 | 28 | /** 29 | * Find the favorite list for the given user 30 | */ 31 | async findFavoriteList(user: User): Promise { 32 | return await this.listModel 33 | .findOne({ user: user._id, isFavorite: true }) 34 | .select({ name: true, mentors: true }) 35 | .populate({ 36 | path: 'mentors', 37 | select: ['name', 'avatar', 'title', 'description', 'country'], 38 | }) 39 | .exec(); 40 | } 41 | 42 | /** 43 | * Return all lists for a given user 44 | */ 45 | async findByUserId(params: any): Promise { 46 | const filters: any = {}; 47 | if (params.userId) { 48 | filters.user = { _id: params.userId }; 49 | } 50 | if (params.public) { 51 | filters.public = { public: params.public }; 52 | } 53 | if (params.listId) { 54 | filters._id = { _id: params.listId }; 55 | } 56 | 57 | if (params.isFavorite !== undefined) { 58 | filters.isFavorite = params.isFavorite; 59 | } 60 | 61 | if (filters.public !== undefined) { 62 | filters.public = params.public; 63 | } 64 | 65 | // TODO: Add more filters here later on (as we need them) 66 | 67 | return await this.listModel.find(filters).exec(); 68 | } 69 | 70 | /** 71 | * Delete a list for a given list id 72 | */ 73 | async delete(_id: string): Promise> { 74 | return await this.listModel.deleteOne({ _id }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/modules/lists/schemas/list.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | export const ListSchema = new mongoose.Schema({ 4 | user: { 5 | type: mongoose.Schema.Types.ObjectId, 6 | ref: 'User', 7 | required: true, 8 | }, 9 | name: { 10 | type: String, 11 | required: true, 12 | }, 13 | public: { 14 | type: Boolean, 15 | default: false, 16 | }, 17 | isFavorite: { 18 | type: Boolean, 19 | default: false, 20 | }, 21 | mentors: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], 22 | }); 23 | 24 | ListSchema.set('timestamps', true); 25 | -------------------------------------------------------------------------------- /src/modules/mentors/mentors.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Body, 4 | Controller, 5 | Get, 6 | Param, 7 | Post, 8 | Put, 9 | Query, 10 | Req, 11 | UnauthorizedException, 12 | UsePipes, 13 | ValidationPipe, 14 | } from '@nestjs/common'; 15 | import { ApiBearerAuth, ApiOperation, ApiUseTags } from '@nestjs/swagger'; 16 | import { Request } from 'express'; 17 | import { MentorsService } from '../common/mentors.service'; 18 | import { UsersService } from '../common/users.service'; 19 | import { MentorFiltersDto } from '../common/dto/mentorfilters.dto'; 20 | import { ApplicationDto } from '../common/dto/application.dto'; 21 | import { User, Role } from '../common/interfaces/user.interface'; 22 | import { 23 | Application, 24 | Status, 25 | } from '../common/interfaces/application.interface'; 26 | import { UserDto } from '../common/dto/user.dto'; 27 | import { EmailService } from '../email/email.service'; 28 | import { EmailParams } from '../email/interfaces/email.interface'; 29 | import { PaginationPipe } from '../common/pipes/pagination.pipe'; 30 | 31 | @ApiUseTags('/mentors') 32 | @Controller('mentors') 33 | export class MentorsController { 34 | constructor( 35 | private readonly mentorsService: MentorsService, 36 | private readonly usersService: UsersService, 37 | private readonly emailService: EmailService, 38 | ) {} 39 | 40 | @ApiOperation({ 41 | title: 'Return all mentors in the platform by the given filters', 42 | }) 43 | @Get() 44 | @UsePipes( 45 | new PaginationPipe(), 46 | new ValidationPipe({ transform: true, whitelist: true }), 47 | ) 48 | async index(@Req() request: Request, @Query() filters: MentorFiltersDto) { 49 | const userId: string = request.user?.auth0Id; 50 | const data = await this.mentorsService.findAll(filters, userId); 51 | 52 | return { 53 | success: true, 54 | filters: data.filters, 55 | pagination: data.pagination, 56 | data: data.mentors, 57 | }; 58 | } 59 | 60 | @Get('featured') 61 | @ApiOperation({ 62 | title: 63 | 'Retrieves a random mentor to be featured in the blog (or anywhere else)', 64 | }) 65 | async featured(@Req() request: Request) { 66 | const data: User = await this.mentorsService.findRandomMentor(); 67 | 68 | return { 69 | success: true, 70 | data, 71 | }; 72 | } 73 | 74 | @Get('applications') 75 | @ApiOperation({ title: 'Retrieve applications filter by the given status' }) 76 | @ApiBearerAuth() 77 | async applications(@Req() request: Request, @Query('status') status: string) { 78 | const current: User = await this.usersService.findByAuth0Id( 79 | request.user.auth0Id, 80 | ); 81 | 82 | if (!current.roles.includes(Role.ADMIN)) { 83 | throw new UnauthorizedException('Access denied'); 84 | } 85 | 86 | const filters: any = {}; 87 | 88 | if (status) { 89 | const key: string = Status[status.toUpperCase()]; 90 | if (key) { 91 | filters.status = key; 92 | } 93 | } 94 | 95 | const data: Application[] = await this.mentorsService.findApplications( 96 | filters, 97 | ); 98 | 99 | return { 100 | success: true, 101 | data, 102 | }; 103 | } 104 | 105 | @Get(':userId/applications') 106 | @ApiOperation({ title: 'Retrieve applications for the given user' }) 107 | @ApiBearerAuth() 108 | async myApplications( 109 | @Req() request: Request, 110 | @Param('userId') userId: string, 111 | @Query('status') status: string, 112 | ) { 113 | const [current, user]: [User, User] = await Promise.all([ 114 | this.usersService.findByAuth0Id(request.user.auth0Id), 115 | this.usersService.findById(userId), 116 | ]); 117 | 118 | if (!user) { 119 | throw new BadRequestException('User not found'); 120 | } 121 | 122 | // Only current user or admin can get applications 123 | if (!current._id.equals(user._id) && !current.roles.includes(Role.ADMIN)) { 124 | throw new UnauthorizedException( 125 | 'Not authorized to perform this operation', 126 | ); 127 | } 128 | 129 | const filters: any = { 130 | user: user._id, 131 | }; 132 | 133 | if (status) { 134 | const key: string = Status[status.toUpperCase()]; 135 | if (key) { 136 | filters.status = key; 137 | } 138 | } 139 | 140 | const data: Application[] = await this.mentorsService.findApplications( 141 | filters, 142 | ); 143 | 144 | return { 145 | success: true, 146 | data, 147 | }; 148 | } 149 | 150 | @ApiOperation({ 151 | title: 152 | 'Creates a new request to become a mentor, pending for Admin to approve', 153 | }) 154 | @ApiBearerAuth() 155 | @Post('applications') 156 | @UsePipes( 157 | new ValidationPipe({ 158 | transform: true, 159 | skipMissingProperties: true, 160 | whitelist: true, 161 | }), 162 | ) 163 | async applyToBecomeMentor( 164 | @Req() request: Request, 165 | @Body() data: ApplicationDto, 166 | ) { 167 | const user: User = await this.usersService.findByAuth0Id( 168 | request.user.auth0Id, 169 | ); 170 | const application: Application = 171 | await this.mentorsService.findActiveApplicationByUser(user); 172 | const applicationDto = new ApplicationDto({ 173 | description: data.description, 174 | status: Status.PENDING, 175 | user, 176 | }); 177 | 178 | // Users can only apply once 179 | if (application) { 180 | if (application.status === Status.PENDING) { 181 | throw new BadRequestException( 182 | 'You already applied, your application is in review.', 183 | ); 184 | } else if (application.status === Status.APPROVED) { 185 | throw new BadRequestException( 186 | 'You already applied, your application has been approved', 187 | ); 188 | } 189 | } 190 | 191 | await this.mentorsService.createApplication(applicationDto); 192 | 193 | this.emailService.sendLocalTemplate({ 194 | to: user.email, 195 | subject: 'Mentor Application Received', 196 | name: 'mentor-application-received', 197 | data: { 198 | name: user.name, 199 | }, 200 | }); 201 | 202 | return { 203 | success: true, 204 | }; 205 | } 206 | 207 | @ApiOperation({ title: 'Approves or rejects an application after review' }) 208 | @ApiBearerAuth() 209 | @Put('applications/:id') 210 | @UsePipes( 211 | new ValidationPipe({ 212 | transform: true, 213 | skipMissingProperties: true, 214 | whitelist: true, 215 | }), 216 | ) 217 | async reviewApplication( 218 | @Req() request: Request, 219 | @Param('id') applicationId: string, 220 | @Body() data: ApplicationDto, 221 | ) { 222 | const current: User = await this.usersService.findByAuth0Id( 223 | request.user.auth0Id, 224 | ); 225 | 226 | if (!current.roles.includes(Role.ADMIN)) { 227 | throw new UnauthorizedException('Access denied'); 228 | } 229 | 230 | const application: Application = 231 | await this.mentorsService.findApplicationById(applicationId); 232 | 233 | if (!application) { 234 | throw new BadRequestException('Application not found'); 235 | } 236 | 237 | if (application.status === Status.APPROVED) { 238 | throw new BadRequestException('This Application is already approved'); 239 | } 240 | 241 | const user: User = await this.usersService.findById(application.user); 242 | const applicationDto: ApplicationDto = new ApplicationDto({ 243 | _id: application._id, 244 | reason: data.reason, 245 | status: data.status, 246 | }); 247 | const userDto: UserDto = new UserDto({ 248 | _id: application.user, 249 | available: true, 250 | roles: [...user.roles, Role.MENTOR], 251 | }); 252 | 253 | let sendEmailParams: EmailParams; 254 | 255 | if (applicationDto.status === Status.REJECTED) { 256 | sendEmailParams = { 257 | to: user.email, 258 | name: 'mentor-application-declined', 259 | subject: 'Mentor Application Denied', 260 | data: { 261 | name: user.name, 262 | reason: data.reason, 263 | }, 264 | }; 265 | } else { 266 | await this.usersService.update(userDto); 267 | sendEmailParams = { 268 | to: user.email, 269 | name: 'mentor-application-approved', 270 | subject: 'Mentor Application Approved 🏅', 271 | data: { 272 | name: user.name, 273 | }, 274 | }; 275 | } 276 | 277 | const res: any = await this.mentorsService.updateApplication( 278 | applicationDto, 279 | ); 280 | try { 281 | await this.emailService.sendLocalTemplate(sendEmailParams); 282 | await this.emailService.addMentor(user); 283 | } catch (error) { 284 | console.error(error); // tslint:disable-line no-console 285 | } 286 | 287 | return { 288 | success: res.ok === 1, 289 | }; 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/modules/mentors/mentors.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MentorsController } from './mentors.controller'; 3 | import { CommonModule } from '../common/common.module'; 4 | import { MentorsService } from '../common/mentors.service'; 5 | import { UsersService } from '../common/users.service'; 6 | import { DatabaseModule } from '../../database/database.module'; 7 | import { EmailService } from '../email/email.service'; 8 | 9 | /** 10 | * Become a mentor module, Endpoints in this module are 11 | * defined to allow any user to become a mentor 12 | * by submiting their profiles for review 13 | */ 14 | @Module({ 15 | imports: [DatabaseModule, CommonModule, EmailService], 16 | controllers: [MentorsController], 17 | providers: [MentorsService, EmailService, UsersService], 18 | }) 19 | export class MentorsModule {} 20 | -------------------------------------------------------------------------------- /src/modules/mentorships/__tests__/mentorships.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, UnauthorizedException } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { Request } from 'express'; 4 | import { MentorshipsController } from '../mentorships.controller'; 5 | import { UsersService } from '../../common/users.service'; 6 | import { MentorsService } from '../../common/mentors.service'; 7 | import { EmailService } from '../../email/email.service'; 8 | import { MentorshipsService } from '../mentorships.service'; 9 | import { Role, User } from '../../common/interfaces/user.interface'; 10 | import { MentorshipDto } from '../dto/mentorship.dto'; 11 | import { Mentorship, Status } from '../interfaces/mentorship.interface'; 12 | 13 | class ServiceMock {} 14 | 15 | class ObjectIdMock { 16 | current: string; 17 | 18 | constructor(current: string) { 19 | this.current = current; 20 | } 21 | 22 | equals(value) { 23 | return this.current === value.current; 24 | } 25 | } 26 | 27 | describe('modules/mentorships/MentorshipsController', () => { 28 | let mentorshipsController: MentorshipsController; 29 | let usersService: UsersService; 30 | let mentorsService: MentorsService; 31 | let mentorshipsService: MentorshipsService; 32 | let emailService: EmailService; 33 | 34 | beforeEach(async () => { 35 | const module = await Test.createTestingModule({ 36 | controllers: [MentorshipsController], 37 | providers: [ 38 | { 39 | provide: UsersService, 40 | useValue: new ServiceMock(), 41 | }, 42 | { 43 | provide: MentorsService, 44 | useValue: new ServiceMock(), 45 | }, 46 | { 47 | provide: MentorshipsService, 48 | useValue: new ServiceMock(), 49 | }, 50 | { 51 | provide: EmailService, 52 | useValue: new ServiceMock(), 53 | }, 54 | ], 55 | }).compile(); 56 | 57 | usersService = module.get(UsersService); 58 | mentorsService = module.get(MentorsService); 59 | mentorshipsService = module.get(MentorshipsService); 60 | emailService = module.get(EmailService); 61 | mentorshipsController = module.get( 62 | MentorshipsController, 63 | ); 64 | }); 65 | 66 | describe('apply', () => { 67 | let mentorId: string; 68 | let menteeId: string; 69 | let mentorship: MentorshipDto; 70 | let request; 71 | 72 | beforeEach(() => { 73 | mentorId = '5678'; 74 | menteeId = '91011'; 75 | request = { user: { auth0Id: '1234' } }; 76 | mentorship = { 77 | message: `Hi there! I'd like to learn from you!`, 78 | } as MentorshipDto; 79 | usersService.findByAuth0Id = jest.fn(() => 80 | Promise.resolve({ 81 | _id: new ObjectIdMock(request.user.auth0Id), 82 | roles: [Role.MEMBER], 83 | } as User), 84 | ); 85 | mentorsService.findById = jest.fn(() => 86 | Promise.resolve({ 87 | _id: new ObjectIdMock(mentorId), 88 | available: true, 89 | roles: [Role.MENTOR], 90 | } as User), 91 | ); 92 | mentorshipsService.findMentorship = jest.fn(() => Promise.resolve(null)); 93 | mentorshipsService.createMentorship = jest.fn(() => 94 | Promise.resolve(null), 95 | ); 96 | emailService.sendLocalTemplate = jest.fn(() => Promise.resolve(null)); 97 | }); 98 | 99 | it('should return a 400 error if mentor not found', async () => { 100 | mentorsService.findById = jest.fn(() => Promise.resolve(null)); 101 | await expect( 102 | mentorshipsController.applyForMentorship(request, '123', mentorship), 103 | ).rejects.toThrow(BadRequestException); 104 | }); 105 | 106 | it('should return a 400 error if mentor is not available', async () => { 107 | mentorsService.findById = jest.fn(() => 108 | Promise.resolve({ 109 | _id: new ObjectIdMock(mentorId), 110 | available: false, 111 | roles: [Role.MENTOR], 112 | } as User), 113 | ); 114 | await expect( 115 | mentorshipsController.applyForMentorship(request, mentorId, mentorship), 116 | ).rejects.toThrow(BadRequestException); 117 | }); 118 | 119 | it('should return a 400 error if a request already exist', async () => { 120 | mentorshipsService.findMentorship = jest.fn(() => 121 | Promise.resolve({} as Mentorship), 122 | ); 123 | await expect( 124 | mentorshipsController.applyForMentorship(request, '123', mentorship), 125 | ).rejects.toThrow(BadRequestException); 126 | }); 127 | 128 | it('should return a 400 error if the user exceeded the allowed open requests', async () => { 129 | mentorshipsService.getOpenRequestsCount = jest.fn(() => 130 | Promise.resolve(5), 131 | ); 132 | 133 | await expect( 134 | mentorshipsController.applyForMentorship( 135 | request, 136 | mentorId, 137 | mentorship, 138 | ), 139 | ).rejects.toThrow(BadRequestException); 140 | }); 141 | 142 | it('should return a successful response', async () => { 143 | mentorshipsService.getOpenRequestsCount = jest.fn(() => 144 | Promise.resolve(3), 145 | ); 146 | const data = await mentorshipsController.applyForMentorship( 147 | request as Request, 148 | mentorId, 149 | mentorship, 150 | ); 151 | expect(data.success).toBe(true); 152 | }); 153 | 154 | it('should return mentorship requests for a given mentor', async () => { 155 | request = { user: { _id: mentorId, auth0Id: '1234' } }; 156 | 157 | usersService.findByAuth0Id = jest.fn(() => 158 | Promise.resolve({ 159 | _id: new ObjectIdMock(mentorId), 160 | roles: [Role.MENTOR], 161 | } as User), 162 | ); 163 | 164 | usersService.findById = jest.fn(() => 165 | Promise.resolve({ 166 | _id: new ObjectIdMock(mentorId), 167 | roles: [Role.MENTOR], 168 | } as User), 169 | ); 170 | 171 | mentorshipsService.findMentorshipsByUser = jest.fn(() => 172 | Promise.resolve([ 173 | { 174 | _id: new ObjectIdMock('1'), 175 | mentor: new ObjectIdMock(mentorId), 176 | mentee: new ObjectIdMock('ANYMENTEEID'), 177 | status: Status.NEW, 178 | goals: [], 179 | message: 'MESSAGE', 180 | expectation: 'EXPECTATION', 181 | background: 'BACKGROUND', 182 | reason: 'REASON', 183 | updatedAt: new Date(), 184 | createdAt: new Date(), 185 | } as Mentorship, 186 | ]), 187 | ); 188 | 189 | const response = await mentorshipsController.getMentorshipRequests( 190 | request as Request, 191 | mentorId, 192 | ); 193 | 194 | expect(response.success).toBe(true); 195 | expect(response.data.length).toBe(1); 196 | expect(response.data[0].isMine).toBe(false); 197 | }); 198 | 199 | it('should filter out requests of deleted users', async () => { 200 | usersService.findByAuth0Id = jest.fn(() => 201 | Promise.resolve({ 202 | _id: new ObjectIdMock(mentorId), 203 | roles: [Role.MENTOR], 204 | } as User), 205 | ); 206 | 207 | usersService.findById = jest.fn(() => 208 | Promise.resolve({ 209 | _id: new ObjectIdMock(mentorId), 210 | roles: [Role.MENTOR], 211 | } as User), 212 | ); 213 | 214 | mentorshipsService.findMentorshipsByUser = jest.fn(() => 215 | Promise.resolve([ 216 | { 217 | _id: new ObjectIdMock('1'), 218 | mentor: new ObjectIdMock('ANYMENTORID'), 219 | mentee: null, 220 | status: Status.NEW, 221 | goals: [], 222 | message: 'MESSAGE', 223 | expectation: 'EXPECTATION', 224 | background: 'BACKGROUND', 225 | reason: 'REASON', 226 | updatedAt: new Date(), 227 | createdAt: new Date(), 228 | } as Mentorship, 229 | ]), 230 | ); 231 | 232 | const response = await mentorshipsController.getMentorshipRequests( 233 | request as Request, 234 | mentorId, 235 | ); 236 | 237 | expect(response.data.length).toBe(1); 238 | expect(response.data[0].mentee).toEqual(null); 239 | }); 240 | 241 | it('should return mentorship applications for a given mentee', async () => { 242 | request = { user: { _id: menteeId, auth0Id: '1234' } }; 243 | 244 | usersService.findByAuth0Id = jest.fn(() => 245 | Promise.resolve({ 246 | _id: new ObjectIdMock(menteeId), 247 | roles: [Role.MEMBER], 248 | } as User), 249 | ); 250 | 251 | usersService.findById = jest.fn(() => 252 | Promise.resolve({ 253 | _id: new ObjectIdMock(menteeId), 254 | roles: [Role.MEMBER], 255 | } as User), 256 | ); 257 | 258 | mentorshipsService.findMentorshipsByUser = jest.fn(() => 259 | Promise.resolve([ 260 | { 261 | _id: new ObjectIdMock('1'), 262 | mentor: new ObjectIdMock('ANYMENTORID'), 263 | mentee: new ObjectIdMock(menteeId), 264 | status: Status.NEW, 265 | goals: [], 266 | message: 'MESSAGE', 267 | expectation: 'EXPECTATION', 268 | background: 'BACKGROUND', 269 | reason: 'REASON', 270 | updatedAt: new Date(), 271 | createdAt: new Date(), 272 | } as Mentorship, 273 | ]), 274 | ); 275 | 276 | const response = await mentorshipsController.getMentorshipRequests( 277 | request as Request, 278 | menteeId, 279 | ); 280 | 281 | expect(response.success).toBe(true); 282 | expect(response.data.length).toBe(1); 283 | expect(response.data[0].isMine).toBe(true); 284 | }); 285 | 286 | it('should return unauthorised if user is not admin and requesting another user\'s applications', async () => { 287 | request = { user: { _id: menteeId, auth0Id: '1234' } }; 288 | 289 | usersService.findByAuth0Id = jest.fn(() => 290 | Promise.resolve({ 291 | _id: new ObjectIdMock(menteeId), 292 | roles: [Role.MEMBER], 293 | } as User), 294 | ); 295 | 296 | usersService.findById = jest.fn(() => 297 | Promise.resolve({ 298 | _id: new ObjectIdMock(mentorId), 299 | roles: [Role.MENTOR], 300 | } as User), 301 | ); 302 | 303 | await expect( 304 | mentorshipsController.getMentorshipRequests( 305 | request as Request, 306 | mentorId, 307 | ), 308 | ).rejects.toThrow(UnauthorizedException); 309 | }); 310 | 311 | it('should return mentorship applications for any given mentor/mentee if user is admin', async () => { 312 | const adminId = '0000'; 313 | request = { user: { _id: adminId, auth0Id: '1234' } }; 314 | 315 | usersService.findByAuth0Id = jest.fn(() => 316 | Promise.resolve({ 317 | _id: new ObjectIdMock(adminId), 318 | roles: [Role.ADMIN], 319 | } as User), 320 | ); 321 | 322 | usersService.findById = jest.fn(() => 323 | Promise.resolve({ 324 | _id: new ObjectIdMock(mentorId), 325 | roles: [Role.MENTOR], 326 | } as User), 327 | ); 328 | 329 | mentorshipsService.findMentorshipsByUser = jest.fn(() => 330 | Promise.resolve([ 331 | { 332 | _id: new ObjectIdMock('1'), 333 | mentor: new ObjectIdMock(mentorId), 334 | mentee: new ObjectIdMock(menteeId), 335 | status: Status.NEW, 336 | goals: [], 337 | message: 'MESSAGE', 338 | expectation: 'EXPECTATION', 339 | background: 'BACKGROUND', 340 | reason: 'REASON', 341 | updatedAt: new Date(), 342 | createdAt: new Date(), 343 | } as Mentorship, 344 | ]), 345 | ); 346 | 347 | const response = await mentorshipsController.getMentorshipRequests( 348 | request as Request, 349 | mentorId, 350 | ); 351 | 352 | expect(response.success).toBe(true); 353 | expect(response.data.length).toBe(1); 354 | expect(response.data[0].isMine).toBe(false); 355 | }); 356 | }); 357 | }); 358 | -------------------------------------------------------------------------------- /src/modules/mentorships/dto/mentorship.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelPropertyOptional, ApiModelProperty } from '@nestjs/swagger'; 2 | import { 3 | Length, 4 | IsString, 5 | IsIn, 6 | IsDefined, 7 | IsArray, 8 | IsOptional, 9 | } from 'class-validator'; 10 | import { Status } from '../interfaces/mentorship.interface'; 11 | 12 | export class MentorshipDto { 13 | readonly _id: string; 14 | 15 | readonly mentor: string; 16 | readonly mentee: string; 17 | 18 | @ApiModelPropertyOptional() 19 | @IsOptional() 20 | @IsString() 21 | @IsIn(Object.values(Status)) 22 | readonly status: Status; 23 | 24 | @ApiModelProperty() 25 | @IsDefined() 26 | @IsString() 27 | @Length(30, 5000) 28 | readonly message: string; 29 | 30 | @ApiModelPropertyOptional() 31 | @IsOptional() 32 | @IsArray() 33 | readonly goals: string[]; 34 | 35 | @ApiModelPropertyOptional() 36 | @IsDefined() 37 | @IsString() 38 | @Length(30, 5000) 39 | readonly expectation: string; 40 | 41 | @ApiModelPropertyOptional() 42 | @IsDefined() 43 | @IsString() 44 | @Length(30, 5000) 45 | readonly background: string; 46 | 47 | @ApiModelPropertyOptional() 48 | @IsOptional() 49 | @IsString() 50 | @Length(3, 400) 51 | readonly reason: string; 52 | 53 | constructor(values) { 54 | Object.assign(this, values); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/modules/mentorships/dto/mentorshipSummary.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserDto } from './../../common/dto/user.dto'; 2 | import { Status } from '../interfaces/mentorship.interface'; 3 | 4 | export class MentorshipSummaryDto { 5 | readonly id: string; 6 | readonly status: Status; 7 | readonly message: string; 8 | readonly background: string; 9 | readonly expectation: string; 10 | readonly date: Date; 11 | readonly isMine: boolean; 12 | readonly mentor: UserDto; 13 | readonly mentee: UserDto; 14 | 15 | constructor(values) { 16 | Object.assign(this, values); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/modules/mentorships/dto/mentorshipUpdatePayload.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiModelPropertyOptional, ApiModelProperty } from '@nestjs/swagger'; 2 | import { IsIn, IsOptional, IsString } from 'class-validator'; 3 | import { Status } from '../interfaces/mentorship.interface'; 4 | 5 | export class MentorshipUpdatePayload { 6 | @ApiModelProperty() 7 | @IsIn([Status.VIEWED, Status.APPROVED, Status.REJECTED, Status.CANCELLED]) 8 | status: Status; 9 | 10 | @ApiModelPropertyOptional() 11 | @IsOptional() 12 | @IsString() 13 | reason: string; 14 | } 15 | -------------------------------------------------------------------------------- /src/modules/mentorships/interfaces/mentorship.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { ObjectID } from 'mongodb'; 3 | 4 | export enum Status { 5 | NEW = 'New', 6 | VIEWED = 'Viewed', 7 | APPROVED = 'Approved', 8 | REJECTED = 'Rejected', 9 | CANCELLED = 'Cancelled', 10 | TERMINATED = 'Terminated', 11 | } 12 | 13 | export interface Mentorship extends Document { 14 | readonly _id: ObjectID; 15 | readonly mentor: ObjectID; 16 | readonly mentee: ObjectID; 17 | status: Status; 18 | readonly message: string; 19 | readonly goals: string[]; 20 | readonly expectation: string; 21 | readonly background: string; 22 | reason: string; 23 | readonly createdAt: Date; 24 | readonly updatedAt: Date; 25 | readonly reminderSentAt?: Date; 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/mentorships/mentorships.controller.ts: -------------------------------------------------------------------------------- 1 | import { UserDto } from './../common/dto/user.dto'; 2 | import { 3 | Body, 4 | BadRequestException, 5 | NotFoundException, 6 | Controller, 7 | Param, 8 | Post, 9 | Req, 10 | UsePipes, 11 | ValidationPipe, 12 | Get, 13 | Put, 14 | UnauthorizedException, 15 | } from '@nestjs/common'; 16 | import * as Sentry from '@sentry/node'; 17 | import { 18 | ApiBearerAuth, 19 | ApiOperation, 20 | ApiImplicitParam, 21 | ApiUseTags, 22 | } from '@nestjs/swagger'; 23 | import { Request } from 'express'; 24 | import { 25 | Template, 26 | SendDataMentorshipParams, 27 | SendDataMentorshipApprovalParams, 28 | SendDataMentorshipRejectionParams, 29 | } from '../email/interfaces/email.interface'; 30 | import { EmailService } from '../email/email.service'; 31 | import { MentorsService } from '../common/mentors.service'; 32 | import { UsersService } from '../common/users.service'; 33 | import { ChannelName, User, Role } from '../common/interfaces/user.interface'; 34 | import { MentorshipsService } from './mentorships.service'; 35 | import { MentorshipDto } from './dto/mentorship.dto'; 36 | import { Mentorship, Status } from './interfaces/mentorship.interface'; 37 | import { FindOneParams } from '../common/dto/findOneParams.dto'; 38 | import { MentorshipUpdatePayload } from './dto/mentorshipUpdatePayload.dto'; 39 | import { mentorshipsToDtos } from './mentorshipsToDto'; 40 | 41 | const ALLOWED_OPEN_MENTORSHIPS = 5; 42 | 43 | @ApiUseTags('/mentorships') 44 | @Controller('mentorships') 45 | export class MentorshipsController { 46 | constructor( 47 | private readonly mentorsService: MentorsService, 48 | private readonly usersService: UsersService, 49 | private readonly mentorshipsService: MentorshipsService, 50 | private readonly emailService: EmailService, 51 | ) {} 52 | 53 | @Post(':mentorId/apply') 54 | @ApiOperation({ 55 | title: 'Creates a new mentorship request for the given mentor', 56 | }) 57 | @ApiBearerAuth() 58 | @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) 59 | async applyForMentorship( 60 | @Req() request: Request, 61 | @Param('mentorId') mentorId: string, 62 | @Body() data: MentorshipDto, 63 | ) { 64 | const [current, mentor] = await Promise.all([ 65 | this.usersService.findByAuth0Id(request.user.auth0Id), 66 | this.mentorsService.findById(mentorId), 67 | ]); 68 | 69 | if (!mentor) { 70 | throw new BadRequestException('Mentor not found'); 71 | } 72 | 73 | if (mentor._id.equals(current._id)) { 74 | throw new BadRequestException(`Are you planning to mentor yourself?`); 75 | } 76 | 77 | if (!mentor.available) { 78 | throw new BadRequestException('Mentor is not available'); 79 | } 80 | 81 | const mentorship: Mentorship = await this.mentorshipsService.findMentorship( 82 | mentor._id, 83 | current._id, 84 | ); 85 | 86 | if (mentorship) { 87 | throw new BadRequestException('A mentorship request already exists'); 88 | } 89 | 90 | const openMentorships = await this.mentorshipsService.getOpenRequestsCount( 91 | current._id, 92 | ); 93 | if (openMentorships >= ALLOWED_OPEN_MENTORSHIPS) { 94 | throw new BadRequestException( 95 | 'You reached the maximum of open requests. Please wait a little longer for responses or cancel some of the requests', 96 | ); 97 | } 98 | 99 | await this.mentorshipsService.createMentorship({ 100 | mentor: mentor._id, 101 | mentee: current._id, 102 | status: Status.NEW, 103 | ...data, 104 | }); 105 | 106 | try { 107 | await this.emailService.sendLocalTemplate({ 108 | name: 'mentorship-requested', 109 | to: mentor.email, 110 | subject: 'Mentorship Requested', 111 | data: { 112 | mentorName: mentor.name, 113 | menteeName: current.name, 114 | menteeEmail: current.email, 115 | message: data.message, 116 | background: data.background, 117 | expectation: data.expectation, 118 | }, 119 | }); 120 | } catch (error) { 121 | Sentry.captureException(error); 122 | } 123 | 124 | return { 125 | success: true, 126 | }; 127 | } 128 | 129 | @Get(':userId/requests') 130 | @ApiBearerAuth() 131 | @ApiOperation({ 132 | title: 'Returns the mentorship requests for a mentor or a mentee.', 133 | }) 134 | async getMentorshipRequests( 135 | @Req() request: Request, 136 | @Param('userId') userId: string, 137 | ) { 138 | try { 139 | const [current, user]: [User, User] = await Promise.all([ 140 | this.usersService.findByAuth0Id(request.user.auth0Id), 141 | this.usersService.findById(userId), 142 | ]); 143 | 144 | if (!user) { 145 | throw new BadRequestException('User not found'); 146 | } 147 | 148 | // Only an admin or same user can view the requests 149 | if ( 150 | !current._id.equals(user._id) && 151 | !current.roles.includes(Role.ADMIN) 152 | ) { 153 | throw new UnauthorizedException( 154 | 'You are not authorized to perform this operation', 155 | ); 156 | } 157 | 158 | // Get the mentorship requests from and to to that user 159 | const mentorshipRequests: Mentorship[] = 160 | await this.mentorshipsService.findMentorshipsByUser(userId); 161 | 162 | // Format the response data 163 | const requests = mentorshipsToDtos(mentorshipRequests, current); 164 | return { 165 | success: true, 166 | data: requests, 167 | }; 168 | } catch (error) { 169 | Sentry.captureException(error); 170 | throw error; 171 | } 172 | } 173 | 174 | @Put(':userId/requests/:id') 175 | @ApiOperation({ 176 | title: 'Updates the mentorship status by the mentor or mentee', 177 | }) 178 | @ApiBearerAuth() 179 | @ApiImplicitParam({ name: 'userId', description: `Mentor's id` }) 180 | @ApiImplicitParam({ name: 'id', description: `Mentorship's id` }) 181 | @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) 182 | async updateMentorship( 183 | @Req() request: Request, 184 | @Param() params: FindOneParams, 185 | @Body() data: MentorshipUpdatePayload, 186 | ) { 187 | const { reason, status } = data; 188 | const mentorship = await this.mentorshipsService.findMentorshipById( 189 | params.id, 190 | ); 191 | 192 | if (!mentorship) { 193 | throw new NotFoundException('Mentorship not found'); 194 | } 195 | 196 | const [currentUser, mentee, mentor] = await Promise.all([ 197 | this.usersService.findByAuth0Id(request.user.auth0Id), 198 | this.usersService.findById(mentorship.mentee), 199 | this.usersService.findById(mentorship.mentor), 200 | ]); 201 | 202 | const currentUserIsAdmin = currentUser.roles.includes(Role.ADMIN); 203 | const currentUserIsMentor = currentUser._id.equals(mentorship.mentor); 204 | const currentUserIsMentee = currentUser._id.equals(mentorship.mentee); 205 | 206 | const canAccess = 207 | currentUserIsAdmin || currentUserIsMentee || currentUserIsMentor; 208 | if (!canAccess) { 209 | throw new UnauthorizedException(); 210 | } 211 | 212 | const menteeUpdatableStatuses = [Status.CANCELLED] as string[]; 213 | const mentorUpdatableStatuses = [ 214 | Status.VIEWED, 215 | Status.APPROVED, 216 | Status.REJECTED, 217 | ] as string[]; 218 | 219 | if (currentUserIsMentee && !menteeUpdatableStatuses.includes(status)) { 220 | throw new BadRequestException(); 221 | } 222 | 223 | if (currentUserIsMentor && !mentorUpdatableStatuses.includes(status)) { 224 | throw new BadRequestException(); 225 | } 226 | 227 | mentorship.status = status; 228 | if ([Status.CANCELLED, Status.REJECTED].includes(status) && reason) { 229 | mentorship.reason = reason; 230 | } 231 | 232 | try { 233 | await mentorship.save(); 234 | } catch (error) { 235 | Sentry.captureException(error); 236 | return { 237 | success: false, 238 | error, 239 | }; 240 | } 241 | 242 | try { 243 | const [menteeFirstName] = mentee.name.split(' '); 244 | 245 | if (mentorship.status === Status.APPROVED) { 246 | const slack = currentUser.channels.find( 247 | (channel) => channel.type === ChannelName.SLACK, 248 | ); 249 | const contactURL = slack 250 | ? `https://coding-coach.slack.com/team/${slack.id}` 251 | : `mailto:${currentUser.email}`; 252 | 253 | const openRequests = await this.mentorshipsService.getOpenRequestsCount( 254 | mentee._id, 255 | ); 256 | 257 | await this.emailService.sendLocalTemplate({ 258 | to: mentee.email, 259 | name: 'mentorship-accepted', 260 | subject: 'Mentorship Approved 👏', 261 | data: { 262 | menteeName: menteeFirstName, 263 | mentorName: currentUser.name, 264 | openRequests, 265 | contactURL, 266 | }, 267 | }); 268 | } 269 | 270 | if (mentorship.status === Status.REJECTED) { 271 | await this.emailService.sendLocalTemplate({ 272 | name: 'mentorship-declined', 273 | subject: 'Mentorship Declined', 274 | to: mentee.email, 275 | data: { 276 | menteeName: menteeFirstName, 277 | mentorName: currentUser.name, 278 | reason: reason || '', 279 | bySystem: false, 280 | }, 281 | }); 282 | } 283 | 284 | if (mentorship.status === Status.CANCELLED) { 285 | await this.emailService.sendLocalTemplate({ 286 | name: 'mentorship-cancelled', 287 | to: mentor.email, 288 | subject: 'Mentorship Cancelled', 289 | data: { 290 | menteeName: currentUser.name, 291 | mentorName: mentor.name, 292 | reason: reason || '', 293 | }, 294 | }); 295 | } 296 | 297 | return { 298 | success: true, 299 | mentorship, 300 | }; 301 | } catch (error) { 302 | Sentry.captureException(error); 303 | return { 304 | success: false, 305 | error, 306 | }; 307 | } 308 | } 309 | 310 | //#region Admin 311 | @Get('requests') 312 | @ApiBearerAuth() 313 | @ApiOperation({ 314 | title: 'Returns all the mentorship requests', 315 | }) 316 | async getAllMentorshipRequests(@Req() request: Request) { 317 | try { 318 | const currentUser = await this.usersService.findByAuth0Id( 319 | request.user.auth0Id, 320 | ); 321 | if (!currentUser.roles.includes(Role.ADMIN)) { 322 | throw new UnauthorizedException( 323 | 'You are not authorized to perform this operation', 324 | ); 325 | } 326 | 327 | const requests = await this.mentorshipsService.getAllMentorships({ 328 | from: request.query.from, 329 | }); 330 | const mentorshipRequests = mentorshipsToDtos(requests, currentUser); 331 | return { 332 | success: true, 333 | data: mentorshipRequests, 334 | }; 335 | } catch (error) { 336 | Sentry.captureException(error); 337 | throw error; 338 | } 339 | } 340 | 341 | @Put('requests/:id/reminder') 342 | @ApiBearerAuth() 343 | @ApiOperation({ 344 | title: 'Send mentor a reminder about an open mentorship', 345 | }) 346 | async sendMentorMentorshipReminder( 347 | @Req() request: Request, 348 | @Param() params: FindOneParams, 349 | ) { 350 | try { 351 | const currentUser = await this.usersService.findByAuth0Id( 352 | request.user.auth0Id, 353 | ); 354 | 355 | if (!currentUser.roles.includes(Role.ADMIN)) { 356 | throw new UnauthorizedException( 357 | 'You are not authorized to perform this operation', 358 | ); 359 | } 360 | 361 | const mentorshipRequest = 362 | await this.mentorshipsService.findMentorshipById(params.id, true); 363 | 364 | const { mentor, mentee, message } = mentorshipRequest; 365 | mentorshipRequest.reminderSentAt = new Date(); 366 | this.emailService.sendLocalTemplate({ 367 | name: 'mentorship-reminder', 368 | to: mentor.email, 369 | subject: `Reminder - Mentorship requested`, 370 | data: { 371 | menteeName: mentee.name, 372 | mentorName: mentor.name, 373 | message, 374 | }, 375 | }); 376 | await mentorshipRequest.save(); 377 | 378 | return { 379 | success: true, 380 | }; 381 | } catch (error) { 382 | Sentry.captureException(error); 383 | throw error; 384 | } 385 | } 386 | //#endregion 387 | } 388 | -------------------------------------------------------------------------------- /src/modules/mentorships/mentorships.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MentorshipsController } from './mentorships.controller'; 3 | import { CommonModule } from '../common/common.module'; 4 | import { MentorsService } from '../common/mentors.service'; 5 | import { UsersService } from '../common/users.service'; 6 | import { DatabaseModule } from '../../database/database.module'; 7 | import { EmailService } from '../email/email.service'; 8 | import { MentorshipsService } from './mentorships.service'; 9 | import { mentorshipsProviders } from './mentorships.providers'; 10 | 11 | /** 12 | * Mentorships module, Endpoints in this module are 13 | * defined to allow any user to request a mentorship from 14 | * and existing mentor, it will also host all endpoints to 15 | * allow a successfull mentorship. 16 | */ 17 | @Module({ 18 | imports: [DatabaseModule, CommonModule, EmailService], 19 | controllers: [MentorshipsController], 20 | providers: [ 21 | MentorsService, 22 | EmailService, 23 | UsersService, 24 | MentorshipsService, 25 | ...mentorshipsProviders, 26 | ], 27 | }) 28 | export class MentorshipsModule {} 29 | -------------------------------------------------------------------------------- /src/modules/mentorships/mentorships.providers.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from 'mongoose'; 2 | import { MentorshipSchema } from './schemas/mentorship.schema'; 3 | 4 | export const mentorshipsProviders = [ 5 | { 6 | provide: 'MENTORSHIP_MODEL', 7 | useFactory: (connection: Connection) => 8 | connection.model('Mentorship', MentorshipSchema), 9 | inject: ['DATABASE_CONNECTION'], 10 | }, 11 | ]; 12 | -------------------------------------------------------------------------------- /src/modules/mentorships/mentorships.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Query, Model, Types } from 'mongoose'; 3 | import { Mentorship, Status } from './interfaces/mentorship.interface'; 4 | import { MentorshipDto } from './dto/mentorship.dto'; 5 | import { isObjectId } from '../../utils/objectid'; 6 | 7 | @Injectable() 8 | export class MentorshipsService { 9 | constructor( 10 | @Inject('MENTORSHIP_MODEL') 11 | private readonly mentorshipModel: Model, 12 | ) {} 13 | 14 | /** 15 | * Creates a new mentorship request 16 | * @param mentorshipDto user's mentorship request 17 | */ 18 | async createMentorship(mentorshipDto: MentorshipDto): Promise> { 19 | const mentorship = new this.mentorshipModel(mentorshipDto); 20 | return mentorship.save(); 21 | } 22 | 23 | /** 24 | * Finds a mentorship by id 25 | * @param id 26 | */ 27 | async findMentorshipById(id: string, full = false) { 28 | const { ObjectId } = Types; 29 | 30 | if (!ObjectId.isValid(id)) { 31 | return null; 32 | } 33 | let mentorship = this.mentorshipModel.findById(id); 34 | if (full) { 35 | mentorship = mentorship.populate('mentee').populate('mentor'); 36 | } 37 | return mentorship; 38 | } 39 | 40 | /** 41 | * Retruns all the mentorship reqeusts 42 | */ 43 | async getAllMentorships({ from }: { from?: Date }) { 44 | return this.mentorshipModel 45 | .find({ 46 | createdAt: { 47 | $gte: from || -1, 48 | }, 49 | }) 50 | .populate('mentee') 51 | .populate('mentor'); 52 | } 53 | 54 | /** 55 | * Finds a mentorship between a mentor and mentee 56 | * @param mentorId 57 | * @param menteeId 58 | */ 59 | async findMentorship( 60 | mentorId: string, 61 | menteeId: string, 62 | ): Promise { 63 | if (isObjectId(mentorId) && isObjectId(menteeId)) { 64 | return this.mentorshipModel 65 | .findOne({ 66 | mentor: mentorId, 67 | mentee: menteeId, 68 | }) 69 | .exec(); 70 | } 71 | 72 | return Promise.resolve(null); 73 | } 74 | 75 | /** 76 | * Finds mentorship requests from or to a user 77 | * @param userId 78 | */ 79 | async findMentorshipsByUser(userId: string): Promise { 80 | if (isObjectId(userId)) { 81 | return this.mentorshipModel 82 | .find({ 83 | $or: [ 84 | { 85 | mentor: userId, 86 | }, 87 | { 88 | mentee: userId, 89 | }, 90 | ], 91 | }) 92 | .populate('mentee') 93 | .populate('mentor') 94 | .exec(); 95 | } 96 | 97 | return Promise.resolve([]); 98 | } 99 | 100 | /** 101 | * Count open mentorship requests of a user 102 | * @param userId 103 | */ 104 | getOpenRequestsCount(userId: string): Promise { 105 | if (isObjectId(userId)) { 106 | return this.mentorshipModel 107 | .countDocuments({ 108 | $and: [ 109 | { 110 | mentee: userId, 111 | }, 112 | { 113 | status: { 114 | $in: [Status.NEW, Status.VIEWED], 115 | }, 116 | }, 117 | ], 118 | }) 119 | .exec(); 120 | } 121 | 122 | return Promise.resolve(0); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/modules/mentorships/mentorshipsToDto.ts: -------------------------------------------------------------------------------- 1 | import type { Mentorship } from './interfaces/mentorship.interface'; 2 | import { MentorshipSummaryDto } from './dto/mentorshipSummary.dto'; 3 | import { UserDto } from '../common/dto/user.dto'; 4 | import { Role, User } from '../common/interfaces/user.interface'; 5 | 6 | export function mentorshipsToDtos( 7 | mentorshipRequests: Mentorship[], 8 | current: User, 9 | ): MentorshipSummaryDto[] { 10 | return mentorshipRequests.map((item) => { 11 | const mentorshipSummary = new MentorshipSummaryDto({ 12 | id: item._id, 13 | status: item.status, 14 | message: item.message, 15 | background: item.background, 16 | expectation: item.expectation, 17 | date: item.createdAt, 18 | reminderSentAt: item.reminderSentAt, 19 | isMine: !!item.mentee?.equals(current._id), 20 | mentee: item.mentee 21 | ? new UserDto({ 22 | id: item.mentee._id, 23 | name: item.mentee.name, 24 | avatar: item.mentee.avatar, 25 | title: item.mentee.title, 26 | email: item.mentee.email, 27 | }) 28 | : null, 29 | mentor: item.mentor 30 | ? new UserDto({ 31 | id: item.mentor._id, 32 | name: item.mentor.name, 33 | avatar: item.mentor.avatar, 34 | title: item.mentor.title, 35 | available: item.mentor.available, 36 | ...(current.roles.includes(Role.ADMIN) 37 | ? { channels: item.mentor.channels, email: item.mentor.email } 38 | : {}), 39 | }) 40 | : null, 41 | }); 42 | 43 | return mentorshipSummary; 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/mentorships/schemas/mentorship.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { Status } from '../interfaces/mentorship.interface'; 3 | 4 | export const MentorshipSchema = new mongoose.Schema({ 5 | mentor: { 6 | type: mongoose.Schema.Types.ObjectId, 7 | ref: 'User', 8 | required: true, 9 | }, 10 | mentee: { 11 | type: mongoose.Schema.Types.ObjectId, 12 | ref: 'User', 13 | required: true, 14 | }, 15 | status: { 16 | type: String, 17 | required: true, 18 | enum: Object.values(Status), 19 | }, 20 | message: { 21 | type: String, 22 | required: true, 23 | }, 24 | goals: { 25 | type: Array, 26 | }, 27 | expectation: { 28 | type: String, 29 | }, 30 | background: { 31 | type: String, 32 | }, 33 | // In case the mentorship gets terminated or rejected 34 | // it would be nice to leave a message to the other party 35 | // explaining why the mentorship was terminated/rejected. 36 | reason: { 37 | type: String, 38 | }, 39 | reminderSentAt: { 40 | type: Date, 41 | }, 42 | }); 43 | 44 | MentorshipSchema.set('timestamps', true); 45 | -------------------------------------------------------------------------------- /src/modules/reports/__tests__/reports.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedException, BadRequestException } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import { ReportsController } from '../reports.controller'; 4 | import { UsersService } from '../../common/users.service'; 5 | import { ReportsService } from '../reports.service'; 6 | import { User, Role } from '../../common/interfaces/user.interface'; 7 | import { Totals } from '../interfaces/totals.interface'; 8 | 9 | class ServiceMock {} 10 | 11 | class ObjectIdMock { 12 | current: string; 13 | 14 | constructor(current: string) { 15 | this.current = current; 16 | } 17 | equals(value) { 18 | return this.current === value.current; 19 | } 20 | } 21 | 22 | describe('modules/reports/ReportsController', () => { 23 | let reportsController: ReportsController; 24 | let usersService: UsersService; 25 | let reportsService: ReportsService; 26 | 27 | beforeEach(async () => { 28 | const module = await Test.createTestingModule({ 29 | controllers: [ReportsController], 30 | providers: [ 31 | { 32 | provide: UsersService, 33 | useValue: new ServiceMock(), 34 | }, 35 | { 36 | provide: ReportsService, 37 | useValue: new ServiceMock(), 38 | }, 39 | ], 40 | }).compile(); 41 | 42 | reportsController = module.get(ReportsController); 43 | usersService = module.get(UsersService); 44 | reportsService = module.get(ReportsService); 45 | }); 46 | 47 | describe('users', () => { 48 | let request; 49 | 50 | beforeEach(() => { 51 | request = { user: { auth0Id: '123' } }; 52 | usersService.findByAuth0Id = jest.fn(() => 53 | Promise.resolve({ 54 | _id: new ObjectIdMock('123'), 55 | roles: [Role.MEMBER, Role.ADMIN], 56 | } as User), 57 | ); 58 | }); 59 | 60 | it('should throw an error if is not an admin', async () => { 61 | usersService.findByAuth0Id = jest.fn(() => 62 | Promise.resolve({ 63 | _id: new ObjectIdMock('123'), 64 | roles: [Role.MEMBER], 65 | } as User), 66 | ); 67 | 68 | await expect(reportsController.users(request, '', '')).rejects.toThrow( 69 | UnauthorizedException, 70 | ); 71 | }); 72 | 73 | it('should return total number of users', async () => { 74 | const data: Totals = { 75 | total: 2500, 76 | admins: 2, 77 | members: 2000, 78 | mentors: 500, 79 | }; 80 | const response = { success: true, data }; 81 | 82 | reportsService.totalsByRole = jest.fn(() => Promise.resolve(data)); 83 | 84 | expect(await reportsController.users(request, '', '')).toEqual(response); 85 | }); 86 | 87 | it('should return total number of users by date range', async () => { 88 | const data: Totals = { 89 | total: 2500, 90 | admins: 2, 91 | members: 2000, 92 | mentors: 500, 93 | }; 94 | const response = { success: true, data }; 95 | 96 | reportsService.totalsByRole = jest.fn(() => Promise.resolve(data)); 97 | 98 | expect( 99 | await reportsController.users(request, '2019-01-01', '2019-01-31'), 100 | ).toEqual(response); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/modules/reports/interfaces/totals.interface.ts: -------------------------------------------------------------------------------- 1 | export interface Totals { 2 | readonly total: number; 3 | readonly admins: number; 4 | readonly members: number; 5 | readonly mentors: number; 6 | } 7 | -------------------------------------------------------------------------------- /src/modules/reports/reports.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Query, 5 | Req, 6 | UnauthorizedException, 7 | BadRequestException, 8 | } from '@nestjs/common'; 9 | import { ApiBearerAuth, ApiOperation, ApiUseTags } from '@nestjs/swagger'; 10 | import { Request } from 'express'; 11 | import { User, Role } from '../common/interfaces/user.interface'; 12 | import { UsersService } from '../common/users.service'; 13 | import { ReportsService } from './reports.service'; 14 | import { Totals } from './interfaces/totals.interface'; 15 | 16 | @ApiUseTags('/reports') 17 | @ApiBearerAuth() 18 | @Controller('reports') 19 | export class ReportsController { 20 | constructor( 21 | private readonly usersService: UsersService, 22 | private readonly reportsService: ReportsService, 23 | ) {} 24 | 25 | @ApiOperation({ 26 | title: 'Return total number of users by role for the given date range', 27 | }) 28 | @Get('users') 29 | async users( 30 | @Req() request: Request, 31 | @Query('start') start: string, 32 | @Query('end') end: string, 33 | ) { 34 | const current: User = await this.usersService.findByAuth0Id( 35 | request.user.auth0Id, 36 | ); 37 | 38 | if (!current.roles.includes(Role.ADMIN)) { 39 | throw new UnauthorizedException('Access denied'); 40 | } 41 | 42 | const data: Totals = await this.reportsService.totalsByRole(start, end); 43 | 44 | return { 45 | success: true, 46 | data, 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/reports/reports.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ReportsController } from './reports.controller'; 3 | import { ReportsService } from './reports.service'; 4 | import { DatabaseModule } from '../../database/database.module'; 5 | import { CommonModule } from '../common/common.module'; 6 | 7 | @Module({ 8 | imports: [DatabaseModule, CommonModule], 9 | controllers: [ReportsController], 10 | providers: [ReportsService], 11 | }) 12 | export class ReportsModule {} 13 | -------------------------------------------------------------------------------- /src/modules/reports/reports.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { Model } from 'mongoose'; 3 | import { User, Role } from '../common/interfaces/user.interface'; 4 | import { Totals } from './interfaces/totals.interface'; 5 | 6 | @Injectable() 7 | export class ReportsService { 8 | constructor(@Inject('USER_MODEL') private readonly userModel: Model) {} 9 | 10 | async totalsByRole(start: string, end: string): Promise { 11 | const dates: any = {}; 12 | 13 | if (start || end) { 14 | dates.createdAt = {}; 15 | 16 | if (start) { 17 | dates.createdAt.$gte = start; 18 | } 19 | 20 | if (end) { 21 | dates.createdAt.$lt = end; 22 | } 23 | } 24 | 25 | const total: number = await this.userModel.find().countDocuments(); 26 | const admins: number = await this.userModel 27 | .find({ roles: Role.ADMIN, ...dates }) 28 | .countDocuments(); 29 | const members: number = await this.userModel 30 | .find({ roles: [Role.MEMBER], ...dates }) 31 | .countDocuments(); 32 | const mentors: number = await this.userModel 33 | .find({ roles: Role.MENTOR, ...dates }) 34 | .countDocuments(); 35 | 36 | return { 37 | total, 38 | admins, 39 | members, 40 | mentors, 41 | } as Totals; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersController } from './users.controller'; 3 | import { UsersService } from '../common/users.service'; 4 | import { DatabaseModule } from '../../database/database.module'; 5 | import { CommonModule } from '../common/common.module'; 6 | import { MentorsService } from '../common/mentors.service'; 7 | import { ListsModule } from '../lists/lists.module'; 8 | import { ListsService } from '../lists/lists.service'; 9 | import { EmailService } from '../email/email.service'; 10 | import { MentorshipsService } from '../mentorships/mentorships.service'; 11 | 12 | @Module({ 13 | imports: [DatabaseModule, EmailService, CommonModule, ListsModule], 14 | controllers: [UsersController], 15 | providers: [ 16 | MentorsService, 17 | UsersService, 18 | EmailService, 19 | ListsService, 20 | MentorshipsService, 21 | ], 22 | }) 23 | export class UsersModule {} 24 | -------------------------------------------------------------------------------- /src/utils/mimes.ts: -------------------------------------------------------------------------------- 1 | const imagesTypes: string[] = ['image/jpeg', 'image/png', 'image/svg+xml']; 2 | 3 | interface FileObject { 4 | fieldname: string; 5 | originalname: string; 6 | encoding: string; 7 | mimetype: string; 8 | destination: string; 9 | filename: string; 10 | path: string; 11 | size: number; 12 | } 13 | 14 | /** 15 | * Checks if the given file is an 16 | */ 17 | function checkMime(mimes: string[], file: FileObject): boolean { 18 | return mimes.includes(file.mimetype); 19 | } 20 | 21 | /** 22 | * Filter images when uploading a file 23 | * @param request Request 24 | * @param file FileObject 25 | * @param cb Function 26 | */ 27 | export function filterImages(request, file, cb) { 28 | const result: boolean = checkMime(imagesTypes, file); 29 | 30 | cb(null, result); 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/objectid.ts: -------------------------------------------------------------------------------- 1 | const regexp: RegExp = /^[0-9a-fA-F]{24}$/; 2 | 3 | export function isObjectId(value: string = ''): boolean { 4 | return regexp.test(value); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/request.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../modules/common/interfaces/user.interface'; 2 | 3 | declare module 'express-serve-static-core' { 4 | interface Request { 5 | user?: User; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/api/mentors.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import * as mongoose from 'mongoose'; 4 | import { INestApplication } from '@nestjs/common'; 5 | import * as dotenv from 'dotenv'; 6 | dotenv.config({ path: '.env.test' }); 7 | import { AppModule } from '../../src/app.module'; 8 | import { 9 | Role, 10 | ChannelName, 11 | } from '../../src/modules/common/interfaces/user.interface'; 12 | import { 13 | createUser, 14 | createMentorship, 15 | approveMentorship, 16 | } from '../utils/seeder'; 17 | import { getToken } from '../utils/jwt'; 18 | 19 | describe('Mentors', () => { 20 | let app: INestApplication; 21 | let server; 22 | 23 | beforeAll(async () => { 24 | const module = await Test.createTestingModule({ 25 | imports: [AppModule], 26 | }).compile(); 27 | 28 | app = module.createNestApplication(); 29 | await app.init(); 30 | server = app.getHttpServer(); 31 | }); 32 | 33 | afterAll(async () => { 34 | await app.close(); 35 | await mongoose.connection.close(); 36 | }); 37 | 38 | beforeEach(async () => { 39 | await mongoose.connection.dropDatabase(); 40 | }); 41 | 42 | describe('GET /mentors', () => { 43 | it('contains no channels if there is not an authenticated user', async () => { 44 | await Promise.all([ 45 | createUser({ 46 | name: 'Mentor One', 47 | roles: [Role.MENTOR], 48 | channels: [{ type: ChannelName.EMAIL, id: 'mentor1@codingcoach.io' }], 49 | available: true, 50 | }), 51 | createUser({ 52 | name: 'Mentor Two', 53 | roles: [Role.MENTOR], 54 | channels: [{ type: ChannelName.TWITTER, id: '@cc_mentor1' }], 55 | available: true, 56 | }), 57 | ]); 58 | 59 | const response = await request(server) 60 | .get('/mentors') 61 | .expect(200); 62 | 63 | const { body } = response; 64 | 65 | body.data.forEach(mentor => { 66 | expect(mentor.channels).toEqual([]); 67 | }); 68 | }); 69 | it('contains no channels if none of the mentors are mentoring the requesting user', async () => { 70 | const [mentee, mentor1, mentor2] = await Promise.all([ 71 | createUser(), 72 | createUser({ 73 | name: 'Mentor One', 74 | roles: [Role.MENTOR], 75 | channels: [{ type: ChannelName.EMAIL, id: 'mentor1@codingcoach.io' }], 76 | available: true, 77 | }), 78 | createUser({ 79 | name: 'Mentor Two', 80 | roles: [Role.MENTOR], 81 | channels: [{ type: ChannelName.TWITTER, id: '@cc_mentor1' }], 82 | available: true, 83 | }), 84 | ]); 85 | const token = getToken(mentee); 86 | 87 | const response = await request(server) 88 | .get('/mentors') 89 | .set('Authorization', `Bearer ${token}`) 90 | .expect(200); 91 | 92 | const { body } = response; 93 | 94 | body.data.forEach(mentor => { 95 | expect(mentor.channels).toEqual([]); 96 | }); 97 | }); 98 | 99 | it('contains channels only for the mentors of the requesting user', async () => { 100 | const [mentee, mentor1, mentor2] = await Promise.all([ 101 | createUser(), 102 | createUser({ 103 | name: 'Mentor One', 104 | roles: [Role.MENTOR], 105 | channels: [{ type: ChannelName.EMAIL, id: 'mentor1@codingcoach.io' }], 106 | available: true, 107 | }), 108 | createUser({ 109 | name: 'Mentor Two', 110 | roles: [Role.MENTOR], 111 | channels: [{ type: ChannelName.TWITTER, id: '@cc_mentor1' }], 112 | available: true, 113 | }), 114 | ]); 115 | 116 | const mentorship = await createMentorship({ 117 | mentor: mentor1._id, 118 | mentee: mentee._id, 119 | }); 120 | 121 | await approveMentorship({ mentorship }); 122 | 123 | const token = getToken(mentee); 124 | 125 | const response = await request(server) 126 | .get('/mentors') 127 | .set('Authorization', `Bearer ${token}`) 128 | .expect(200); 129 | 130 | const { body } = response; 131 | const responseMentor1 = body.data.find( 132 | mentor => mentor.name === mentor1.name, 133 | ); 134 | const responseMentor2 = body.data.find( 135 | mentor => mentor.name === mentor2.name, 136 | ); 137 | expect(responseMentor1.channels[0].id).toEqual(mentor1.channels[0].id); 138 | expect(responseMentor1.channels[0].type).toEqual( 139 | mentor1.channels[0].type, 140 | ); 141 | expect(responseMentor2.channels).toEqual([]); 142 | }); 143 | describe('availability filter', () => { 144 | it('returns all mentors if available query param does not exist', async () => { 145 | const [availableMentor, unavailableMentor] = await Promise.all([ 146 | createUser({ roles: [Role.MENTOR], available: true }), 147 | createUser({ roles: [Role.MENTOR], available: false }), 148 | createUser({ roles: [Role.MEMBER] }), 149 | ]); 150 | const { body } = await request(server) 151 | .get('/mentors') 152 | .expect(200); 153 | expect(body.data.length).toBe(2); 154 | const ids = body.data.map(mentor => mentor._id); 155 | expect(ids).toContain(availableMentor._id.toString()); 156 | expect(ids).toContain(unavailableMentor._id.toString()); 157 | }); 158 | 159 | it('returns only available mentors if available is true', async () => { 160 | const [availableMentor] = await Promise.all([ 161 | createUser({ roles: [Role.MENTOR], available: true }), 162 | createUser({ roles: [Role.MENTOR], available: false }), 163 | createUser({ roles: [Role.MEMBER] }), 164 | ]); 165 | const { body } = await request(server) 166 | .get('/mentors?available=true') 167 | .expect(200); 168 | expect(body.data.length).toBe(1); 169 | expect(body.data[0]._id).toBe(availableMentor._id.toString()); 170 | }); 171 | 172 | it('returns only unavailable mentors if available is false', async () => { 173 | const [_, unavailableMentor] = await Promise.all([ 174 | createUser({ roles: [Role.MENTOR], available: true }), 175 | createUser({ roles: [Role.MENTOR], available: false }), 176 | createUser({ roles: [Role.MEMBER] }), 177 | ]); 178 | const { body } = await request(server) 179 | .get('/mentors?available=false') 180 | .expect(200); 181 | expect(body.data.length).toBe(1); 182 | expect(body.data[0]._id).toBe(unavailableMentor._id.toString()); 183 | }); 184 | }); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /test/api/users.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import * as mongoose from 'mongoose'; 4 | import { INestApplication } from '@nestjs/common'; 5 | import * as dotenv from 'dotenv'; 6 | dotenv.config({ path: '.env.test' }); 7 | import { AppModule } from '../../src/app.module'; 8 | import { Role } from '../../src/modules/common/interfaces/user.interface'; 9 | import { createUser } from '../utils/seeder'; 10 | import { getToken } from '../utils/jwt'; 11 | 12 | describe('Users', () => { 13 | let app: INestApplication; 14 | let server; 15 | 16 | beforeAll(async () => { 17 | const module = await Test.createTestingModule({ 18 | imports: [AppModule], 19 | }).compile(); 20 | 21 | app = module.createNestApplication(); 22 | await app.init(); 23 | server = app.getHttpServer(); 24 | }); 25 | 26 | afterAll(async () => { 27 | await app.close(); 28 | await mongoose.connection.close(); 29 | }); 30 | 31 | beforeEach(async () => { 32 | await mongoose.connection.dropDatabase(); 33 | }); 34 | 35 | describe('GET /users', () => { 36 | it('returns a status code of 401 if the user is unauthenticated', async () => { 37 | return request(server) 38 | .get('/users') 39 | .expect(401); 40 | }); 41 | 42 | it('returns a status code of 401 if the user is not an admin', async () => { 43 | const user = await createUser(); 44 | const token = getToken(user); 45 | 46 | return request(server) 47 | .get('/users') 48 | .set('Authorization', `Bearer ${token}`) 49 | .expect(401); 50 | }); 51 | 52 | it('returns an array of users if the user is an admin', async () => { 53 | const [admin] = await Promise.all([ 54 | createUser({ email: 'admin@test.com', roles: [Role.ADMIN] }), 55 | createUser({ email: 'test@test.com' }), 56 | ]); 57 | const token = getToken(admin); 58 | 59 | const { body } = await request(server) 60 | .get('/users') 61 | .set('Authorization', `Bearer ${token}`) 62 | .expect(200); 63 | expect(body.data.map(user => user.email)).toEqual([ 64 | 'admin@test.com', 65 | 'test@test.com', 66 | ]); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | }, 9 | "globalSetup": "./setup.ts", 10 | "globalTeardown": "./teardown.ts" 11 | } 12 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { MongoMemoryServer } from 'mongodb-memory-server'; 2 | 3 | export default async () => { 4 | const mongod = new MongoMemoryServer(); 5 | const uri = await mongod.getUri(); 6 | process.env.MONGO_DATABASE_URL = uri; 7 | // @ts-ignore 8 | global.__MONGOD__ = mongod; 9 | }; 10 | -------------------------------------------------------------------------------- /test/teardown.ts: -------------------------------------------------------------------------------- 1 | export default async () => { 2 | // @ts-ignore 3 | await global.__MONGOD__.stop(); 4 | }; 5 | -------------------------------------------------------------------------------- /test/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | // Ref: https://carterbancroft.com/mocking-json-web-tokens-and-auth0 2 | 3 | import * as jwt from 'jsonwebtoken'; 4 | import * as nock from 'nock'; 5 | import * as faker from 'faker'; 6 | 7 | const testPrivateKey = `-----BEGIN RSA PRIVATE KEY----- 8 | MIIEpAIBAAKCAQEA3hcdO8Q55PC7zy9MLfpR2HrPmFf6GFMO/rQXUwtAI4JveIcT 9 | SuJ4h3AaXWzmmEjz/0WG/+kYwqiMH/aVAW6dG2iQ/Wz902RHNyH44RTek+flDvs3 10 | lwiW/zvUutDfLRoXguSaIdYaJTDurqnMjNyMOXGn9FDA14ArC98nFVTXB8YN04N+ 11 | 1PDNsILyEXxFtG9QIHcZelMgKErPSW7qnofc0VE+1/eJ7ohJQam0+53nCM1mt3Vw 12 | LWXW+h4mM+s8qeITu6YP6jrhkXIm/nlkyfIUOOOWX4PFiaRvTGoLUYp0K0PinCd1 13 | Txp+jERGvkPXv7fHJ7mN8qGAOh9QocxKEz+H6QIDAQABAoIBABitntejpVSVlM5K 14 | kNTRv5h7hRKGQXRvKPe6e+uuu1t2xKYy9DzaT3mqm54NWiOf2k+098b7WCoBNUOJ 15 | UI4OhDH7Jj6oMXL07fOSuHy3p0fuI7DMz3oz6ntwDY0OD/k2BgN1yClsXg6155Uh 16 | qb4ZSmeeWS23xMXtfbBdr+fEkU7786CjpTv2YeLUAl25GywxoKhp3ludxYmuuRq4 17 | 6JgE8mY30ksSr9v2yTY+En8bJcHvT9pLTdcGnc4XeKUg1+Va6WURwQByyM0oxh2F 18 | Zi5Ok/z4JoYiq015N3E+tErLnMn1tuwNir/BJMTP+L2fIoxwUmd0yaT/VvD4bfEa 19 | rcbJkoUCgYEA5pnLVlyeIaQFeaR9n6MwnK+98dqg0uFRDSR/OzssVjMIiw5s62HB 20 | 5OxhYn2NCz7/mieyW4kmwxHMwoeo2/quTl8YIIk0WKg2kjJ1fgAk++pjVAnHz26d 21 | xj9nppL6czPjoT271LEFZDT39wbAzbBjLL12S9iJ1KfbJ4IHrZFBlUcCgYEA9o1a 22 | EXJNPYTBEFtvvcwU2ZebqEHR+EWMcDKHTZTqsQ34iMBU+jUgS/6Adb7FzHtctK7G 23 | EGBcS9vaJV8UfVzbHFgGr2F0c4QoYLNgwKgw67NyCixCCIthywcxL/8Ymgo8WyIC 24 | oXNl5w2yFcBN/JBuP1QJ91B8wCYS7+AVRQGBUU8CgYEAnKR/8Yw8hpGKfpT0GMqb 25 | rPPcTTu730Pa8NiH7M5HUc6c0QjdiA8BzOWdSXALrUYADtFEYNWLlRq0QrgwRi3E 26 | 1cvW8dMB0e+CElFgalTiypTvIBj8t7VmS1KqsAZLRpJK4C61NseA6A7rGcxmj9Jv 27 | q+aPQvo2tlPHlNDJMmfnauUCgYEAhkczz56t/JxJvcvezsLQdDWC3B+E6K+QLicG 28 | 07UQIP/X5TrCzUaT4W+prPcKqTRiqDErxA2HFvWVGJdxBFnHJ+e1NF1iW+uVRh1L 29 | y4GOq0AfEvVJvXeT+kxfeKF5V6PNfWDHiADedflajUgf8TcEJE9z4hMe7lOOKsCj 30 | NOL9+DcCgYAevvM8wrHa2r0EaWV5sWgPWX7zV1pSjKhBlTDtBo1Qek886YxToQoF 31 | 28rg1dZ+SKxefHeKbKSQ1wit1XTDROtd5fT3CPYrD5u1kDwhJORRV8uJZFs2EFpk 32 | Nfhiber3jo0aXin1W0aGvEfb7L6RpSfWu7OkxvFchREbukVsH0+bQg== 33 | -----END RSA PRIVATE KEY-----`; 34 | 35 | const jwks = { 36 | keys: [ 37 | { 38 | alg: 'RS256', 39 | kty: 'RSA', 40 | use: 'sig', 41 | n: 42 | '3hcdO8Q55PC7zy9MLfpR2HrPmFf6GFMO_rQXUwtAI4JveIcTSuJ4h3AaXWzmmEjz_0WG_-kYwqiMH_aVAW6dG2iQ_Wz902RHNyH44RTek-flDvs3lwiW_zvUutDfLRoXguSaIdYaJTDurqnMjNyMOXGn9FDA14ArC98nFVTXB8YN04N-1PDNsILyEXxFtG9QIHcZelMgKErPSW7qnofc0VE-1_eJ7ohJQam0-53nCM1mt3VwLWXW-h4mM-s8qeITu6YP6jrhkXIm_nlkyfIUOOOWX4PFiaRvTGoLUYp0K0PinCd1Txp-jERGvkPXv7fHJ7mN8qGAOh9QocxKEz-H6Q', 43 | e: 'AQAB', 44 | kid: '0', 45 | }, 46 | ], 47 | }; 48 | 49 | nock(`https://${process.env.AUTH0_DOMAIN}`) 50 | .persist() 51 | .get('/.well-known/jwks.json') 52 | .reply(200, jwks); 53 | 54 | export const getToken = (user = { auth0Id: faker.random.uuid() }) => { 55 | const payload = { 56 | sub: user.auth0Id, 57 | }; 58 | 59 | const options = { 60 | header: { kid: '0' }, 61 | algorithm: 'RS256', 62 | expiresIn: '1d', 63 | issuer: `https://${process.env.AUTH0_DOMAIN}/`, 64 | }; 65 | 66 | return jwt.sign(payload, testPrivateKey, options); 67 | }; 68 | -------------------------------------------------------------------------------- /test/utils/seeder.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import * as faker from 'faker'; 3 | import { UserSchema } from '../../src/modules/common/schemas/user.schema'; 4 | import { MentorshipSchema } from '../../src/modules/mentorships/schemas/mentorship.schema'; 5 | import { Role } from '../../src/modules/common/interfaces/user.interface'; 6 | import { Status } from '../../src/modules/mentorships/interfaces/mentorship.interface'; 7 | 8 | export const createUser = ({ 9 | auth0Id = faker.random.uuid(), 10 | email = faker.internet.email(), 11 | name = faker.name.findName(), 12 | avatar = faker.internet.avatar(), 13 | roles = [], 14 | channels = [], 15 | available = true, 16 | } = {}) => { 17 | const User = mongoose.connection.model('User', UserSchema); 18 | return new User({ 19 | auth0Id, 20 | email, 21 | name, 22 | avatar, 23 | roles: [...new Set([...roles, Role.MEMBER])], 24 | channels, 25 | available, 26 | }).save(); 27 | }; 28 | 29 | export const createMentorship = ({ 30 | mentor, 31 | mentee, 32 | message = faker.lorem.sentence(), 33 | status = Status.NEW, 34 | }) => { 35 | const Mentorship = mongoose.connection.model('Mentorship', MentorshipSchema); 36 | return new Mentorship({ 37 | mentor, 38 | mentee, 39 | message, 40 | status, 41 | }).save(); 42 | }; 43 | 44 | export const approveMentorship = async ({ mentorship }) => { 45 | mentorship.status = 'Approved'; 46 | await mentorship.save(); 47 | }; 48 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "scripts", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es6", 9 | "sourceMap": true, 10 | "outDir": "./dist", 11 | "baseUrl": "./", 12 | "resolveJsonModule": true 13 | }, 14 | "files": [ 15 | "src/main.ts", 16 | "src/utils/request.d.ts" 17 | ], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": { 5 | "no-unused-expression": true 6 | }, 7 | "rules": { 8 | "quotemark": [true, "single"], 9 | "member-access": [false], 10 | "no-console": [true], 11 | "no-shadowed-variable": [false], 12 | "max-classes-per-file": [false], 13 | "ordered-imports": [false], 14 | "max-line-length": [true, 150], 15 | "member-ordering": [false], 16 | "interface-name": [false], 17 | "arrow-parens": false, 18 | "object-literal-sort-keys": false, 19 | "variable-name": { 20 | "options": [ 21 | "ban-keywords", 22 | "check-format", 23 | "allow-leading-underscore", 24 | "allow-pascal-case" 25 | ] 26 | } 27 | }, 28 | "rulesDirectory": [] 29 | } 30 | --------------------------------------------------------------------------------