├── .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 | [](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 |
6 |
11 |
12 |
13 |
14 |
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 |
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 |
100 |
101 |
102 |
103 |
104 |
--------------------------------------------------------------------------------
/content/email_templates/mentor-application-declined.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
13 |
14 |
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 |
47 |
48 |
--------------------------------------------------------------------------------
/content/email_templates/mentor-application-received.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
13 |
14 |
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 |
42 |
43 |
--------------------------------------------------------------------------------
/content/email_templates/mentor-freeze.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
12 |
13 |
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 |
72 |
73 |
--------------------------------------------------------------------------------
/content/email_templates/mentor-not-active.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
12 |
13 |
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 |
104 |
105 |
--------------------------------------------------------------------------------
/content/email_templates/mentorship-accepted.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
13 |
14 |
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 |
6 |
11 |
12 |
13 |
14 |
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 |
6 |
11 |
12 |
13 |
14 |
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 |
6 |
10 |
11 |
12 |
13 |
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 |
61 |
62 |
--------------------------------------------------------------------------------
/content/email_templates/mentorship-requested.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
13 |
14 |
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 |
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 |
--------------------------------------------------------------------------------