浏览代码

Merge branch 'owen'

Owen Diffey 3 年之前
父节点
当前提交
f10155028a
共有 100 个文件被更改,包括 30671 次插入4127 次删除
  1. 0 20
      .editorconfig
  2. 21 0
      .env.example
  3. 3 0
      .gitattributes
  4. 42 0
      .github/workflows/build-eslint.yml
  5. 21 6
      .gitignore
  6. 44 0
      .travis.yml
  7. 35 0
      .wiki/Backend_Commands.md
  8. 103 0
      .wiki/Configuration.md
  9. 120 0
      .wiki/Installation.md
  10. 20 0
      .wiki/Technical_Overview.md
  11. 29 0
      .wiki/Utility_Script.md
  12. 59 0
      .wiki/Value_Formats.md
  13. 72 136
      README.md
  14. 15 0
      backend/.devcontainer/devcontainer.json
  15. 1 0
      backend/.eslintignore
  16. 45 0
      backend/.eslintrc
  17. 1 0
      backend/.prettierignore
  18. 9 0
      backend/.prettierrc
  19. 3 6
      backend/Dockerfile
  20. 69 0
      backend/classes/Timer.class.js
  21. 75 25
      backend/config/template.json
  22. 645 0
      backend/core.js
  23. 348 60
      backend/index.js
  24. 5 0
      backend/loadEnvVariables.js
  25. 214 0
      backend/logic/actions/activities.js
  26. 209 36
      backend/logic/actions/apis.js
  27. 102 0
      backend/logic/actions/dataRequests.js
  28. 50 18
      backend/logic/actions/hooks/adminRequired.js
  29. 6 6
      backend/logic/actions/hooks/index.js
  30. 41 14
      backend/logic/actions/hooks/loginRequired.js
  31. 68 28
      backend/logic/actions/hooks/ownerRequired.js
  32. 23 10
      backend/logic/actions/index.js
  33. 216 99
      backend/logic/actions/news.js
  34. 2025 440
      backend/logic/actions/playlists.js
  35. 215 0
      backend/logic/actions/punishments.js
  36. 0 201
      backend/logic/actions/queueSongs.js
  37. 463 143
      backend/logic/actions/reports.js
  38. 1316 181
      backend/logic/actions/songs.js
  39. 3871 461
      backend/logic/actions/stations.js
  40. 2569 489
      backend/logic/actions/users.js
  41. 97 0
      backend/logic/actions/utils.js
  42. 468 0
      backend/logic/activities.js
  43. 279 21
      backend/logic/api.js
  44. 488 143
      backend/logic/app.js
  45. 225 122
      backend/logic/cache/index.js
  46. 4 8
      backend/logic/cache/schemas/officialPlaylist.js
  47. 3 7
      backend/logic/cache/schemas/playlist.js
  48. 7 0
      backend/logic/cache/schemas/punishment.js
  49. 4 0
      backend/logic/cache/schemas/recentActivity.js
  50. 6 9
      backend/logic/cache/schemas/session.js
  51. 1 5
      backend/logic/cache/schemas/song.js
  52. 3 7
      backend/logic/cache/schemas/station.js
  53. 299 41
      backend/logic/db/index.js
  54. 57 0
      backend/logic/db/schemas/activity.js
  55. 7 0
      backend/logic/db/schemas/dataRequest.js
  56. 5 7
      backend/logic/db/schemas/news.js
  57. 21 5
      backend/logic/db/schemas/playlist.js
  58. 10 0
      backend/logic/db/schemas/punishment.js
  59. 5 3
      backend/logic/db/schemas/queueSong.js
  60. 19 8
      backend/logic/db/schemas/report.js
  61. 16 13
      backend/logic/db/schemas/song.js
  62. 28 20
      backend/logic/db/schemas/station.js
  63. 30 12
      backend/logic/db/schemas/user.js
  64. 0 112
      backend/logic/io.js
  65. 0 32
      backend/logic/logger.js
  66. 81 16
      backend/logic/mail/index.js
  67. 34 0
      backend/logic/mail/schemas/dataRequest.js
  68. 30 0
      backend/logic/mail/schemas/passwordRequest.js
  69. 14 14
      backend/logic/mail/schemas/resetPasswordRequest.js
  70. 18 15
      backend/logic/mail/schemas/verifyEmail.js
  71. 136 0
      backend/logic/migration/index.js
  72. 176 0
      backend/logic/migration/migrations/migration1.js
  73. 62 0
      backend/logic/migration/migrations/migration10.js
  74. 59 0
      backend/logic/migration/migrations/migration11.js
  75. 67 0
      backend/logic/migration/migrations/migration12.js
  76. 60 0
      backend/logic/migration/migrations/migration13.js
  77. 65 0
      backend/logic/migration/migrations/migration14.js
  78. 46 0
      backend/logic/migration/migrations/migration15.js
  79. 91 0
      backend/logic/migration/migrations/migration2.js
  80. 75 0
      backend/logic/migration/migrations/migration3.js
  81. 99 0
      backend/logic/migration/migrations/migration4.js
  82. 152 0
      backend/logic/migration/migrations/migration5.js
  83. 45 0
      backend/logic/migration/migrations/migration6.js
  84. 45 0
      backend/logic/migration/migrations/migration7.js
  85. 214 0
      backend/logic/migration/migrations/migration8.js
  86. 85 0
      backend/logic/migration/migrations/migration9.js
  87. 249 51
      backend/logic/notifications.js
  88. 1490 111
      backend/logic/playlists.js
  89. 306 0
      backend/logic/punishments.js
  90. 1200 93
      backend/logic/songs.js
  91. 1625 457
      backend/logic/stations.js
  92. 470 0
      backend/logic/tasks.js
  93. 314 342
      backend/logic/utils.js
  94. 771 0
      backend/logic/ws.js
  95. 430 0
      backend/logic/youtube.js
  96. 6773 0
      backend/package-lock.json
  97. 38 28
      backend/package.json
  98. 0 30
      docker-compose-production.yml
  99. 38 16
      docker-compose.yml
  100. 58 0
      fallback.html

+ 0 - 20
.editorconfig

@@ -1,20 +0,0 @@
-root = true
-
-[*]
-charset = utf-8
-indent_style = tab
-
-[frontend/nginx.conf]
-charset = utf-8
-indent_style = space
-indent_size = 4
-
-[docker-compose.yml]
-charset = utf-8
-indent_style = space
-indent_size = 2
-
-end_of_line = lf
-insert_final_newline = true
-trim_trailing_whitespace = true
-continuation_indent_size = 4

+ 21 - 0
.env.example

@@ -0,0 +1,21 @@
+COMPOSE_PROJECT_NAME=musare
+
+BACKEND_HOST=127.0.0.1
+BACKEND_PORT=8080
+
+FRONTEND_HOST=127.0.0.1
+FRONTEND_PORT=80
+FRONTEND_MODE=dev
+
+MONGO_HOST=127.0.0.1
+MONGO_PORT=27017
+MONGO_ROOT_PASSWORD=PASSWORD_HERE
+MONGO_USER_USERNAME=musare
+MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
+
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+REDIS_PASSWORD=PASSWORD
+
+BACKUP_LOCATION=
+BACKUP_NAME=

+ 3 - 0
.gitattributes

@@ -0,0 +1,3 @@
+* text=auto
+
+*.sh text eol=lf

+ 42 - 0
.github/workflows/build-eslint.yml

@@ -0,0 +1,42 @@
+name: Musare Build and ESLint
+
+on:
+    push:
+        branches: [ owen ]
+    pull_request:
+        branches: [ owen ]
+    workflow_dispatch:
+
+env:
+    COMPOSE_PROJECT_NAME: musare
+    BACKEND_HOST: 127.0.0.1
+    BACKEND_PORT: 8080
+    FRONTEND_HOST: 127.0.0.1
+    FRONTEND_PORT: 80
+    FRONTEND_MODE: dev
+    MONGO_HOST: 127.0.0.1
+    MONGO_PORT: 27017
+    MONGO_ROOT_PASSWORD: PASSWORD_HERE
+    MONGO_USER_USERNAME: musare
+    MONGO_USER_PASSWORD: OTHER_PASSWORD_HERE
+    REDIS_HOST: 127.0.0.1
+    REDIS_PORT: 6379
+    REDIS_PASSWORD: PASSWORD
+
+jobs:
+    build-eslint:
+        runs-on: ubuntu-latest
+        steps:
+            - uses: actions/checkout@v2
+            - name: Build Musare
+              run: |
+                  cp backend/config/template.json backend/config/default.json
+                  cp frontend/dist/config/template.json frontend/dist/config/default.json
+                  docker-compose build
+                  docker-compose pull
+            - name: Start Musare
+              run: docker-compose up -d
+            - name: ESlint Backend
+              run: docker-compose exec -T backend /bin/bash -c "npx eslint app/logic"
+            - name: ESLint Frontend
+              run: docker-compose exec -T frontend /bin/bash -c "cd app && npm run lint"

+ 21 - 6
.gitignore

@@ -2,21 +2,36 @@ Thumbs.db
 .DS_Store
 .DS_Store
 *.swp
 *.swp
 .idea/
 .idea/
+.vscode/
 .vagrant/
 .vagrant/
 
 
+.env
 startRedis.cmd
 startRedis.cmd
 startMongo.cmd
 startMongo.cmd
 .database
 .database
-dump.rdb
+.db
+.redis
+*.rdb
+backups/
+
 npm-debug.log
 npm-debug.log
+lerna-debug.log
 
 
-# Back End
+# Backend
 backend/node_modules/
 backend/node_modules/
 backend/config/default.json
 backend/config/default.json
 
 
-# Front End
+# Frontend
+frontend/bundle-stats.json
+frontend/bundle-report.html
 frontend/node_modules/
 frontend/node_modules/
-frontend/build/bundle.js
-frontend/build/config/default.json
+frontend/dist/build/
+frontend/dist/index.html
+frontend/dist/config/default.json
+
+npm
+node_modules
+package-lock.json
 
 
-npm
+# Logs
+log/

+ 44 - 0
.travis.yml

@@ -0,0 +1,44 @@
+# .travis.yml
+
+
+language: minimal
+sudo: required
+services:
+  - docker
+
+env:
+  global:
+    - COMPOSE_PROJECT_NAME=musare
+    - BACKEND_HOST=127.0.0.1
+    - BACKEND_PORT=8080
+    - FRONTEND_HOST=127.0.0.1
+    - FRONTEND_PORT=80
+    - FRONTEND_MODE=dev
+    - MONGO_HOST=127.0.0.1
+    - MONGO_PORT=27017
+    - MONGO_ROOT_PASSWORD=PASSWORD_HERE
+    - MONGO_USER_USERNAME=musare
+    - MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
+    - REDIS_HOST=127.0.0.1
+    - REDIS_PORT=6379
+    - REDIS_PASSWORD=PASSWORD
+
+before_install:
+  # create config files from template
+  - cp backend/config/template.json backend/config/default.json
+  - cp frontend/dist/config/template.json frontend/dist/config/default.json
+
+jobs:
+  include:
+    - stage: frontend
+      script:
+        - docker-compose build frontend # build frontend
+        - docker-compose up -d frontend # start frontend
+        - docker-compose exec frontend /bin/bash -c "cd app && npm run lint" # using eslint to check for formatting/linting issues
+    - stage: backend # This will eventually be used for proper unit tests etc.
+      script:
+        - docker-compose up -d mongo # start mongo (users automatically setup)
+        - docker-compose up -d redis # start redis
+        - docker-compose build backend # build backend
+        - docker-compose up -d backend # start backend
+        # - docker-compose exec backend /bin/bash -c "npx eslint app/logic" # using eslint to check for formatting/linting issues

+ 35 - 0
.wiki/Backend_Commands.md

@@ -0,0 +1,35 @@
+# Backend Commands
+Backend commands are inputted via STDIN or if using the Utility Script by using `./musare.sh attach backend`.
+
+## Commands
+| Command | Parameters | Description |
+| --- | --- | --- |
+| `rs` | | Restart backend. |
+| `status` | | Returns all modules and a sample of information including, state, jobs queued, running and paused, concurrency (amount of jobs that can run simultaneously), and (startup) stage. |
+| `queued` | `module` | Returns all jobs queued for specified module. |
+| `running` | `module` | Returns all jobs running for specified module. |
+| `paused` | `module` | Returns all jobs paused for specified module. |
+| `jobinfo` | `UUID` | Returns a detailed overview of a specified job. |
+| `runjob` | `module job_name json_encoded_payload` | Run a specified job in a specified module including a JSON encoded payload, and return response. |
+| `eval` | `some_javascript` | Execute JavaScript within the index.js context and return response. |
+| `lockdown` | | Lockdown backend. |
+| `stats` | `module` | Returns job statistics for a specified module. |
+
+## Modules
+When specifying a module please use all lowercase. The available modules are as follows:
+
+- Cache
+- DB
+- Mail
+- Activities
+- API
+- App
+- WS
+- Notifications
+- Playlists
+- Punishments
+- Songs
+- Stations
+- Tasks
+- Utils
+- YouTube

+ 103 - 0
.wiki/Configuration.md

@@ -0,0 +1,103 @@
+# Configuration
+
+## Backend
+Location: `backend/config/default.json`
+
+| Property | Description |
+| --- | --- |
+| `mode` | Should be either `development` or `production`. |
+| `migration` | Should be set to `true` if you need to update MongoDB documents to a newer version after an update. Should be false at all other times. |
+| `secret` | Set to something unique and secure - used by express's session module. |
+| `domain` | Should be the url where the site will be accessible from, usually `http://localhost` for non-Docker. |
+| `serverDomain` | Should be the url where the backend will be accessible from, usually `http://localhost/backend` for docker or `http://localhost:8080` for non-Docker. |
+| `serverPort` | Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker. |
+| `registrationDisabled` | If set to true, users can't register accounts. |
+| `hideAutomaticallyRequestedSongs` | If `true` any automatically requested songs will be hidden. |
+| `hideAnonymousSongs` | If `true` any anonymously requested songs will be hidden. |
+| `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. |
+| `apis.youtube.key` | YouTube Data API v3 key, obtained from [here](https://developers.google.com/youtube/v3/getting-started). |
+| `apis.youtube.rateLimit` | Minimum interval between YouTube API requests in milliseconds. |
+| `apis.youtube.requestTimeout` | YouTube API requests timeout in milliseconds. |
+| `apis.youtube.retryAmount` | The amount of retries to perform of a failed YouTube API request. |
+| `apis.recaptcha.secret` | ReCaptcha Site v3 secret, obtained from [here](https://www.google.com/recaptcha/admin). |
+| `apis.recaptcha.enabled` | Whether to enable ReCaptcha at email registration. |
+| `apis.github.client` | GitHub OAuth Application client, obtained from [here](https://github.com/settings/developers). |
+| `apis.github.secret` | GitHub OAuth Application secret, obtained with client. |
+| `apis.github.redirect_uri` | The authorization callback url is the backend url with `/auth/github/authorize/callback` appended, for example `http://localhost/backend/auth/github/authorize/callback`. |
+| `apis.discogs.client` | Discogs Application client, obtained from [here](https://www.discogs.com/settings/developers). |
+| `apis.discogs.secret` | Discogs Application secret, obtained with client. |
+| `apis.discogs.enabled` | Whether to enable Discogs API usage. |
+| `cors.origin` | Array of allowed request origin urls, for example `http://localhost`. |
+| `smtp.host` | SMTP Host |
+| `smtp.port` | SMTP Port |
+| `smtp.auth.user` | SMTP Username |
+| `smtp.auth.pass` | SMTP Password |
+| `smtp.secure` | Whether SMTP is secured. |
+| `smtp.enabled` | Whether SMTP and sending emails is enabled. |
+| `redis.url` | Should be left as default for Docker installations, else changed to `redis://localhost:6379/0`. |
+| `redis.password` | Redis password. |
+| `mongo.url` | For Docker replace temporary MongoDB musare user password with one specified in `.env`, and for non-Docker replace `@musare:27017` with `@localhost:27017`. |
+| `cookie.domain` | The ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
+| `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
+| `cookie.SIDname` | Name of the cookie stored for sessions. |
+| `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
+| `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
+| `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capure all jobs specified in `debug.captureJobs`. 
+| `debug.traceUnhandledPromises` | Enables the trace-unhandled package, which provides detailed information when a promise is unhandled. |
+| `debug.captureJobs` | Array of jobs to capture for `debug.stationIssue`. |
+| `defaultLogging.hideType` | Filters out specified message types from log, for example `INFO`, `SUCCESS`, `ERROR` and `STATION_ISSUE`. |
+| `defaultLogging.blacklistedTerms` | Filters out messages containing specified terms from log, for example `success`. |
+| `customLoggingPerModule.[module].hideType` | Where `[module]` is a module name specify hideType as you would `defaultLogging.hideType` to overwrite default. |
+| `customLoggingPerModule.[module].blacklistedTerms` | Where `[module]` is a module name specify blacklistedTerms as you would `defaultLogging.blacklistedTerms` to overwrite default. |
+| `configVersion` | Version of the config. Every time the template changes, you should change your config accordingly and update the configVersion. |
+
+## Frontend
+Location: `frontend/dist/config/default.json`
+
+| Property | Description |
+| --- | --- |
+| `mode` | Should be either `development` or `production`. |
+| `backend.apiDomain` | Should be the url where the backend will be accessible from, usually `http://localhost/backend` for docker or `http://localhost:8080` for non-Docker. |
+| `backend.websocketsDomain` | Should be the same as the `apiDomain`, except using the `ws://` protocol instead of `http://` and with `/ws` at the end. |
+| `devServer.webSocketURL` | Should be the webpack-dev-server websocket URL, usually `ws://localhost/ws`. |
+| `devServer.port` | Should be the port where webpack-dev-server will be accessible from, should always be port `81` for Docker since nginx listens on port 80, and is recommended to be port `80` for non-Docker. |
+| `frontendDomain` | Should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker. |
+| `recaptcha.key` | ReCaptcha Site v3 key, obtained from [here](https://www.google.com/recaptcha/admin). |
+| `recaptcha.enabled` | Whether to enable ReCaptcha at email registration. |
+| `cookie.domain` | Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`. |
+| `cookie.secure` | Should be `true` for SSL connections, and `false` for normal http connections. |
+| `cookie.SIDname` | Name of the cookie stored for sessions. |
+| `siteSettings.logo_white` | Path to the white logo image, by default it is `/assets/white_wordmark.png`. |
+| `siteSettings.logo_blue` | Path to the blue logo image, by default it is `/assets/blue_wordmark.png`. |
+| `siteSettings.sitename` | Should be the name of the site. |
+| `siteSettings.github` | URL of GitHub repository, defaults to `https://github.com/Musare/MusareNode`. |
+| `messages.accountRemoval` | Message to return to users on account removal. |
+| `shortcutOverrides` | Overwrite keyboard shortcuts, for example `"editSong.useAllDiscogs": { "keyCode": 68, "ctrl": true, "alt": true, "shift": false, "preventDefault": true }`. |
+| `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
+| `configVersion` | Version of the config. Every time the template changes, you should change your config accordingly and update the configVersion. |
+
+## Docker Environment
+Location: `.env`
+
+In the table below the container host refers to the IP address that the docker container listens on, setting this to `127.0.0.1` for example will only expose the configured port to localhost, whereas setting to `0.0.0.0` will expose the port on all interfaces.
+
+The container port refers to the external docker container port, used to access services within the container. Changing this does not require any changes to configuration within container. For example setting the `MONGO_PORT` to `21018` will allow you to access the mongo service through that port, even though the application within the container is listening on `21017`.
+
+| Property | Description |
+| --- | --- |
+| `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
+| `BACKEND_HOST` | Backend container host. |
+| `BACKEND_PORT` | Backend container port. |
+| `FRONTEND_HOST` | Frontend container host. |
+| `FRONTEND_PORT` | Frontend container port. |
+| `FRONTEND_MODE` | Should be either `dev` or `prod`. |
+| `MONGO_HOST` | Mongo container host. |
+| `MONGO_PORT` | Mongo container port. |
+| `MONGO_ROOT_PASSWORD` | Password of the root/admin user for MongoDB. |
+| `MONGO_USER_USERNAME` | Application username for MongoDB. |
+| `MONGO_USER_PASSWORD` | Application password for MongoDB. |
+| `REDIS_HOST` | Redis container host. |
+| `REDIS_PORT` | Redis container port. |
+| `REDIS_PASSWORD` | Redis password. |
+| `BACKUP_LOCATION` | Directory to store musare.sh backups. Defaults to `/backups` in script location. |
+| `BACKUP_NAME` | Name of musare.sh backup files. Defaults to `musare-$(date +"%Y-%m-%d-%s").dump`. |

+ 120 - 0
.wiki/Installation.md

@@ -0,0 +1,120 @@
+# Installation
+Musare can be installed with Docker (recommended) or without, guides for both installations can be found below.
+
+## Docker
+
+### Dependencies
+- [Git](https://github.com/git-guides/install-git)
+- [Docker](https://docs.docker.com/get-docker/)
+- [docker-compose](https://docs.docker.com/compose/install/)
+
+### Instructions
+1. `git clone https://github.com/Musare/MusareNode.git`
+2. `cd MusareNode`
+3. `cp backend/config/template.json backend/config/default.json` and configure as per [Configuration](./Configuration.md#Backend)
+4. `cp frontend/dist/config/template.json frontend/dist/config/default.json` and configure as per [Configuration](./Configuration.md#Frontend)
+5. `cp .env.example .env` and configure as per [Configuration](./Configuration.md#Docker-Environment).
+6. `./musare.sh build`
+7. `./musare.sh start`
+8. **(optional)** Register a new user on the website and grant the admin role by running `./musare.sh admin add USERNAME`.
+
+### Fixing the "couldn't connect to docker daemon" error
+
+**Windows Only**
+
+Some people have had issues while trying to execute the `docker-compose` command.
+To fix this, you will have to run `docker-machine env default`.
+This command will print various variables.
+At the bottom, it will say something similar to `@FOR /f "tokens=*" %i IN ('docker-machine env default') DO @%i`.
+Run this command in your shell. You will have to do this command for every shell you want to run `docker-compose` in (every session).
+
+---
+
+## Non-Docker
+
+### Dependencies
+- [Git](https://github.com/git-guides/install-git)
+- [Redis](http://redis.io/download)
+- [MongoDB](https://www.mongodb.com/try/download/community)
+- [NodeJS](https://nodejs.org/en/download/)
+    - [npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
+    - [nodemon](https://github.com/remy/nodemon#installation)
+    - [node-gyp](https://github.com/nodejs/node-gyp#installation)
+    - [webpack](https://webpack.js.org/guides/installation/#global-installation)
+
+### Instructions
+1. `git clone https://github.com/Musare/MusareNode.git`
+2. `cd MusareNode`
+3. [Setup MongoDB](#Setting-up-MongoDB)
+4. [Setup Redis](#Setting-up-Redis)
+5. `cp backend/config/template.json backend/config/default.json` and configure as per [Configuration](./Configuration.md#Backend)
+6. `cp frontend/dist/config/template.json frontend/dist/config/default.json` and configure as per [Configuration](./Configuration.md#Frontend)
+7. Start services
+    - **Linux**
+        1. Execute `systemctl start redis mongod`
+        2. Execute `cd frontend && npm run dev` and `cd backend && npm run dev` separately.
+    - **Windows**
+        - **Automatic** Run `windows-start.cmd` or just double click the `windows-start.cmd` file and all servers will automatically start up.
+        - **Manual**
+            1. Run `startRedis.cmd` and `startMongo.cmd` to start Redis and Mongo.
+            2. Execute `cd frontend && npm run dev` and `cd backend && npm run dev` separately.
+8. **(optional)** Register a new user on the website and grant the admin role by running the following in the mongodb shell.
+    ```bash
+    use musare
+    db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
+    db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})
+    ```
+
+### Setting up MongoDB
+- **Windows Only**
+    1. In the root directory, create a folder called `.database`
+    2. Create a file called `startMongo.cmd` in the root directory with the contents:
+
+        `"C:\Program Files\MongoDB\Server\4.0\bin\mongod.exe" --dbpath "C:\Path\To\MusareNode\.database"`
+
+        Make sure to adjust your paths accordingly.
+    3. Start the database by executing the script `startMongo.cmd` you just made
+- Set up the MongoDB database itself
+    1. Start MongoDB
+        - **Linux** Execute `systemctl start mongod`
+        - **Windows** Execute the `startMongo.cmd` script you just made
+    2. Connect to Mongo from a command prompt
+
+        `mongo admin`
+    3. Create an admin user
+
+        `db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
+    4. Connect to the Musare database
+
+        `use musare`
+    5. Create the "musare" user
+
+        `db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
+    6. Exit
+
+        `exit`
+    7. Add the authentication
+        - **Linux**
+            1. Add `auth=true` to `/etc/mongod.conf`
+            2. Restart MongoDB `systemctl restart mongod`
+        - **Windows**
+            1. In `startMongo.cmd` add `--auth` at the end of the first line
+            2. Restart MongoDB
+
+### Setting up Redis
+- **Windows**
+    1. In the folder where you installed Redis, edit the `redis.windows.conf` file
+        1. In there, look for the property `notify-keyspace-events`.
+        2. Make sure that property is uncommented and has the value `Ex`.
+            
+            It should look like `notify-keyspace-events Ex` when done.
+    2. Create a file called `startRedis.cmd` in the main folder with the contents:
+
+        `"C:\Path\To\Redis\redis-server.exe" "C:\Path\To\Redis\redis.windows.conf" "--requirepass" "PASSWORD"`
+
+        And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
+- **Linux**
+    1. In `/etc/redis/redis.conf`
+        1. Uncomment `notify-keyspace-events` and set its value to `Ex`.
+        2. Uncomment `requirepass foobared` and replace foobared with your Redis password.
+    2. Restart Redis `systemctl restart redis`

+ 20 - 0
.wiki/Technical_Overview.md

@@ -0,0 +1,20 @@
+# Technical Overview
+
+## Our Stack
+
+- NodeJS
+- MongoDB
+- Redis
+- Nginx (not required)
+- VueJS
+
+### Frontend
+
+The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated, [vue-loader](https://github.com/vuejs/vue-loader) single page app, that's served over Nginx or Express. The Nginx server not only serves the frontend, but can also serve as a load balancer for requests going to the backend.
+
+### Backend
+
+The backend is a scalable NodeJS / Redis / MongoDB app. User sessions are stored in a central Redis server. All data is stored in a central MongoDB server. The Redis and MongoDB servers are replicated to several secondary nodes, which can become the primary node if the current primary node goes down.
+
+We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
+

+ 29 - 0
.wiki/Utility_Script.md

@@ -0,0 +1,29 @@
+# Utility Script
+The utility script is a tool that allows for the simple management of a Musare Docker instance.
+
+Please follow the [Docker Installation Guide](./Installation.md#Docker) before using this script.
+
+## Usage
+Linux (Bash):
+```bash
+./musare.sh command [parameters]
+```
+
+## Commands
+| Command | Parameters | Description |
+| --- | --- | --- |
+| `start` | `[frontend backend redis mongo]` | Start service(s). |
+| `stop` | `[frontend backend redis mongo]` | Stop service(s). |
+| `restart` | `[frontend backend redis mongo]` | Restart service(s). |
+| `logs` | `[frontend backend redis mongo]` | View logs for service(s). |
+| `update` | `[auto]` | Update Musare. When auto is specified the update will be cancelled if there are any changes requiring manual intervention, allowing you to run this unattended. |
+| `attach` | `<backend,mongo>` | Attach to backend server or mongodb shell. |
+| `build` | `[frontend backend]` | Build service(s). |
+| `eslint` | `[frontend backend] [fix]` | Run eslint on frontend and/or backend. Specify fix to auto fix issues where possible. |
+| `backup` | | Backup database data to file. Configured in .env file. |
+| `restore` | `[file]` | Restore database from file. If file is not specified you will be prompted. |
+| `reset` | `[frontend backend redis mongo]` | Reset all data for service(s). |
+| `admin` | `<add,remove> [username]` | Assign/unassign admin role to/from user. If the username is not specified you will be prompted. |
+
+### Services
+There are currently 4 services; frontend, backend, redis and mongo. Where services is a parameter you can specify any of these, or multiple seperated by spaces, for example `./musare.sh restart frontend backend` to restart the frontend and backend. If no services are specified all will be selected.

+ 59 - 0
.wiki/Value_Formats.md

@@ -0,0 +1,59 @@
+# Value Formats
+
+Every input needs validation, below is the required formatting of each value.
+
+- **User**
+    - Username
+        - Description: Any letter from a-z in any case, numbers, underscores and dashes. Must contain at least 1 letter or number.
+        - Length: From 2 to 32 characters.
+        - Regex: ```/^[A-Za-z0-9_]+$/```
+    - Name
+        - Description: Any letter from any language in any case, numbers, underscores, dashes, periods, apostrophes and spaces. Must contain at least 1 letter or number.
+        - Length: From 2 to 64 characters.
+        - Regex: ```/^[\p{L}0-9 .'_-]+$/u```
+    - Email
+        - Description: Standard email address.
+        - Length: From 3 to 254 characters.
+        - Regex: ```/^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/```
+    - Password
+        - Description: Must include at least one lowercase letter, one uppercase letter, one number and one special character.
+        - Length: From 6 to 200 characters.
+        - Regex: ```/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~])[A-Za-z\d!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/```
+    - Ban Reason
+        - Description: Any ASCII character.
+        - Length: From 1 to 64 characters.
+        - Regex: ```/^[\x00-\x7F]+$/```
+- **Station**
+    - Name
+        - Description: Any letter from a-z lowercase, numbers and underscores.
+        - Length: From 2 to 16 characters.
+        - Regex: ```/^[a-z0-9_]+$/```
+    - Display Name
+        - Description: Any ASCII character.
+        - Length: From 2 to 32 characters.
+        - Regex: ```/^[\x00-\x7F]+$/```
+    - Description
+        - Description: Any character.
+        - Length: From 2 to 200 characters.
+- **Playlist**
+    - Display Name
+        - Description: Any ASCII character.
+        - Length: From 1 to 32 characters.
+        - Regex: ```/^[\x00-\x7F]+$/```
+- **Song**
+    - Title
+        - Description: Any ASCII character.
+        - Length: From 1 to 32 characters.
+        - Regex: ```/^[\x00-\x7F]+$/```
+    - Artists
+        - Description: Any character and not NONE.
+        - Length: From 1 to 64 characters.
+        - Quantity: Min 1, max 10.
+    - Genres
+        - Description: Any ASCII character.
+        - Length: From 1 to 32 characters.
+        - Quantity: Min 1, max 16.
+        - Regex: ```/^[\x00-\x7F]+$/```
+    - Thumbnail
+        - Description: Valid url. If site is secure only https prepended urls are valid.
+        - Length: From 1 to 256 characters.

+ 72 - 136
README.md

@@ -1,136 +1,72 @@
-# MusareNode
-This is a rewrite of the original [Musare](https://github.com/Musare/Musare)
-in NodeJS, Express, SocketIO and VueJS. Everything is ran in it's own docker container.
-
-### Our Stack
-
-   * NodeJS
-   * MongoDB
-   * Redis
-   * Nginx
-   * VueJS
-
-### Frontend
-The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated,
-[vue-loader](https://github.com/vuejs/vue-loader) single page app, that's
-served over Nginx. The Nginx server not only serves the frontend, but
-also serves as a load balancer for requests going to the backend.
-
-### Backend
-The backend is a scalable NodeJS / Redis / MongoDB app. Each backend
-server handles a group of SocketIO connections. User sessions are stored
-in a central Redis server. All data is stored in a central MongoDB server.
-The Redis and MongoDB servers are replicated to several secondary nodes,
-which can become the primary node if the current primary node goes down.
-
-## Requirements
- * [Docker](https://www.docker.com/)
-
-## Getting Started
-Once you've installed the required tools:
-
-1. `git clone https://github.com/MusareNode/MusareNode.git`
-
-2. `cd MusareNode`
-
-3. `cp backend/config/template.json backend/config/default.json`
-
-   > The `secret` key can be whatever. It's used by express's session module.
-   The `apis.youtube.key` value can be obtained by setting up a
-   [YouTube API Key](https://developers.google.com/youtube/v3/getting-started).
-  
-4. Build the backend and frontend Docker images
-
-   `docker-compose build`
-
-5. Start the databases and tools in the background, as we usually don't need to monitor these for errors
-
-   `docker-compose up -d mongo mongoclient redis`
-
-6. Start the backend and frontend in the foreground, so we can watch for errors during development
-
-   `docker-compose up backend frontend`
-
-7. You should now be able to begin development! The backend is auto reloaded when
-   you make changes and the frontend is auto compiled and live reloaded by webpack
-   when you make changes. You should be able to access Musare in your local browser
-   at `http://<docker-machine-ip>:8080/` where `<docker-machine-ip>` can be found below:
-
-   * Docker for Windows / Mac: This is just `localhost`
-   
-   * Docker ToolBox: The output of `docker-machine ip default`
-   
-## Extra
-
-Below is a list of helpful tips / solutions we've collected while developing MusareNode.
-
-### Mounting a non-standard directory in Docker Toolbox on Windows
-
-Docker Toolbox usually only gives VirtualBox access to `C:/Users` of your
-local machine. So if your code is located elsewere on your machine,
-you'll need to tell Docker Toolbox how to find it. You can use variations
-of the following commands to give Docker Toolbox access to those files.
-
-1. First lets ensure the machine isn't running
-
-   `docker-machine stop default`
-
-1. Next we'll want to tell the machine about the folder we want to share.
-
-   `"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe" sharedfolder add default --name "d/Projects/MusareNode" --hostpath "D:\Projects\MusareNode" --automount`
-
-2. Now start the machine back up and ssh into it
-
-   `docker-machine start default && docker-machine ssh default`
-   
-3. Tell boot2docker to mount our volume at startup, by appending to its startup script
-	```bash
-	sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
-
-	mkdir -p /d/Projects/MusareNode
-	mount -t vboxsf -o uid=1000,gid=50 d/Projects/MusareNode /d/Projects/MusareNode
-	EOF
-	```
-
-4. Restart the docker machine so that it uses the new shared folder
-
-   `docker-machine restart default`
-   
-5. You now should be good to go!
-
-### Fixing the "couldn't connect to docker daemon" error
-
-Some people have had issues while trying to execute the `docker-compose` command.
-To fix this, you will have to run `docker-machine env default`.
-This command will print various variables.
-At the bottom, it will say something similar to `@FOR /f "tokens=*" %i IN ('docker-machine env default') DO @%i`.
-Run this command in your shell. You will have to do this command for every shell you want to run `docker-compose` in (every session).
-
-### Running Musare locally without using Docker
-
-1. Install [Redis](http://redis.io/download) and [MongoDB](https://www.mongodb.com/download-center#community)
-
-2. Install nodemon globally
-
-   `npm install nodemon -g`
-
-3. Install webpack globally
-
-   `npm install webpack -g`
-
-4. Install node-gyp globally (first check out https://github.com/nodejs/node-gyp#installation)
-
-   `npm install node-gyp -g`.
-
-5. In both `frontend` and `backend` folders, do `npm install`.
-
-6. `nodemon backend/index.js`
-
-### Calling Toasts
-
-You can call Toasts using our custom package, [`vue-roaster`](https://github.com/atjonathan/vue-roaster), using the following code:
-
-```js
-import { Toast } from 'vue-roaster';
-Toast.methods.addToast('', 0);
-```
+![Musare](frontend/dist/assets/blue_wordmark.png)
+
+# Musare
+
+Musare is an open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.
+
+---
+
+## Documentation
+- [Installation](./.wiki/Installation.md)
+- [Configuration](./.wiki/Configuration.md)
+- [Utility Script](./.wiki/Utility_Script.md)
+- [Backend Commands](./.wiki/Backend_Commands.md)
+- [Technical Overview](./.wiki/Technical_Overview.md)
+- [Value Formats](./.wiki/Value_Formats.md)
+
+---
+
+## Features
+- **Playlists**
+    - User created playlists
+    - Automatically generated playlists for genres
+    - Privacy configuration
+    - Liked and Disliked songs playlists per user
+    - Bulk import songs from YouTube playlist
+    - Add songs from verified catalogue or YouTube
+    - Ability to download in JSON format
+- **Stations**
+    - Playlist mode to listen to selected playlists
+    - Party mode to allow other users to add songs to queue
+    - Ability to blacklist playlists to prevent songs within from playing
+    - Themes
+    - Privacy configuration
+    - Favoriting
+    - Official stations controlled by admins (playlist mode only)
+    - User created and controlled stations
+    - Pause playback just in local session
+    - Station-wide pausing by admins or owners
+    - Vote to skip songs
+    - Force skipping song by admins or owners
+    - Add songs to queue from verified catalogue or YouTube (party mode only)
+- **Song Management**
+    - Verify songs to allow them to be searched for and played in official stations
+    - Hide songs to remove from unverified catalogue
+    - Import Album (WIP) to import songs in bulk
+    - Discogs integration to import metadata
+    - Ability for users to report issues with songs and admins to resolve
+    - Configurable skip duration and song duration to cut intros and outros
+    - Request songs from YouTube in official stations or admin area
+    - Any song added to playlists or stations will be automatically requested
+- **Users**
+    - Activity logs
+    - Profile page showing public playlists and activity logs
+    - Text or gravatar profile pictures
+    - Email or Github login/registration
+    - Preferences to tailor site usage
+    - Password reset
+    - Data deletion management
+    - ActivityWatch integration
+- **Punishments**
+    - Ban users
+    - Ban IPs
+- **News**
+    - Admins can add/edit/remove news items
+    - Markdown editor
+- **Dark Mode**
+
+---
+
+## Contact
+
+Get in touch with us via email at [core@musare.com](mailto:core@musare.com).

+ 15 - 0
backend/.devcontainer/devcontainer.json

@@ -0,0 +1,15 @@
+{
+	// Sets the run context to one level up instead of the .devcontainer folder.
+	"context": "..",
+
+	"workspaceFolder": "/opt",
+
+	"dockerFile": "..\\Dockerfile",
+
+	// Set *default* container specific settings.json values on container create.
+	"settings": {
+		"terminal.integrated.shell.linux": null
+	},
+
+	"extensions": []
+}

+ 1 - 0
backend/.eslintignore

@@ -0,0 +1 @@
+node_modules

+ 45 - 0
backend/.eslintrc

@@ -0,0 +1,45 @@
+{
+	"env": {
+		"browser": false,
+        "es2021": true,
+        "node": true
+	},
+	"parserOptions": {
+		"ecmaVersion": 2021,
+		"sourceType": "module"
+	},
+	"extends": [
+		"eslint:recommended",
+		"airbnb-base",
+		"prettier",
+		"plugin:jsdoc/recommended"
+    ],
+    "plugins": [ "prettier", "jsdoc" ],
+	"rules": {
+		"no-console": 0,
+		"no-control-regex": 0,
+		"no-var": 2,
+		"no-underscore-dangle": 0,
+		"radix": 0,
+		"no-multi-assign": 0,
+		"no-shadow": 0,
+		"no-new": 0,
+        "import/no-unresolved": 0,
+		"prettier/prettier": ["error"], // end of copied frontend rules
+		"max-classes-per-file": 0,
+		"max-len": ["error", { "code": 140, "ignoreComments": true, "ignoreUrls": true, "ignoreTemplateLiterals": true }],
+		"no-param-reassign": 0,
+		"implicit-arrow-linebreak": 0,
+		"import/extensions": 0,
+		"class-methods-use-this": 0,
+		"require-jsdoc": [2, {
+			"require": {
+				"FunctionDeclaration": true,
+				"MethodDefinition": true,
+				"ClassDeclaration": false,
+				"ArrowFunctionExpression": false,
+				"FunctionExpression": false
+			}
+		}]
+    }
+}

+ 1 - 0
backend/.prettierignore

@@ -0,0 +1 @@
+node_modules/

+ 9 - 0
backend/.prettierrc

@@ -0,0 +1,9 @@
+{
+    "singleQuote": false,
+    "tabWidth": 4,
+    "useTabs": true,
+    "trailingComma": "none",
+    "arrowParens": "avoid",
+    "endOfLine":"auto",
+    "printWidth": 120
+}

+ 3 - 6
backend/Dockerfile

@@ -1,15 +1,12 @@
-FROM node
-
-RUN apt-get update
+FROM node:15
 
 
 RUN npm install -g nodemon
 RUN npm install -g nodemon
 
 
 RUN mkdir -p /opt
 RUN mkdir -p /opt
 WORKDIR /opt
 WORKDIR /opt
 ADD package.json /opt/package.json
 ADD package.json /opt/package.json
+ADD package-lock.json /opt/package-lock.json
 
 
 RUN npm install
 RUN npm install
 
 
-EXPOSE 80
-
-CMD npm run development
+CMD npm run docker:dev

+ 69 - 0
backend/classes/Timer.class.js

@@ -0,0 +1,69 @@
+export default class Timer {
+	// eslint-disable-next-line require-jsdoc
+	constructor(callback, delay, paused) {
+		this.callback = callback;
+		this.timerId = undefined;
+		this.start = undefined;
+		this.paused = paused;
+		this.remaining = delay;
+		this.timeWhenPaused = 0;
+		this.timePaused = Date.now();
+
+		if (!paused) {
+			this.resume();
+		}
+	}
+
+	/**
+	 * Pauses the timer
+	 *
+	 */
+	pause() {
+		clearTimeout(this.timerId);
+		this.remaining -= Date.now() - this.start;
+		this.timePaused = Date.now();
+		this.paused = true;
+	}
+
+	/**
+	 * Ensures the timer's resume function is called if it is paused
+	 *
+	 */
+	ifNotPaused() {
+		if (!this.paused) {
+			this.resume();
+		}
+	}
+
+	/**
+	 * Resumes the timer
+	 *
+	 */
+	resume() {
+		this.start = Date.now();
+		clearTimeout(this.timerId);
+		this.timerId = setTimeout(this.callback, this.remaining);
+		this.timeWhenPaused = Date.now() - this.timePaused;
+		this.paused = false;
+	}
+
+	/**
+	 * Resets the time when paused
+	 *
+	 */
+	resetTimeWhenPaused() {
+		this.timeWhenPaused = 0;
+	}
+
+	/**
+	 * Gets the amount of time the timer has been paused
+	 *
+	 * @returns {Date} - the amount of time the timer has been paused
+	 */
+	getTimePaused() {
+		if (!this.paused) {
+			return this.timeWhenPaused;
+		}
+		return Date.now() - this.timePaused;
+	}
+}

+ 75 - 25
backend/config/template.json

@@ -1,45 +1,95 @@
 {
 {
-	"secret": "",
-	"domain": "",
-	"serverDomain": "",
-  	"serverPort": 8080,
-  	"isDocker": true,
+	"mode": "development",
+	"migration": false,
+	"secret": "default",
+	"domain": "http://localhost",
+	"frontendPort": 80,
+	"serverDomain": "http://localhost/backend",
+	"serverPort": 8080,
+	"registrationDisabled": true,
+	"hideAutomaticallyRequestedSongs": false,
+    "hideAnonymousSongs": false,
+	"sendDataRequestEmails": true,
 	"apis": {
 	"apis": {
 		"youtube": {
 		"youtube": {
-			"key": ""
+			"key": "",
+			"rateLimit": 500,
+			"requestTimeout": 5000,
+			"retryAmount": 2
 		},
 		},
 		"recaptcha": {
 		"recaptcha": {
-			"secret": ""
+			"secret": "",
+			"enabled": false
 		},
 		},
 		"github": {
 		"github": {
 			"client": "",
 			"client": "",
 			"secret": "",
 			"secret": "",
 			"redirect_uri": ""
 			"redirect_uri": ""
 		},
 		},
-		"discord": {
+		"discogs": {
 			"client": "",
 			"client": "",
-			"secret": ""
-		},
-		"mailgun": {
-			"key": "",
-			"domain": ""
+			"secret": "",
+			"enabled": false
 		}
 		}
 	},
 	},
 	"cors": {
 	"cors": {
 		"origin": [
 		"origin": [
-			"http://localhost",
-			"http://192.168.99.100",
-			"http://dev.musare.com"
+			"http://localhost"
 		]
 		]
 	},
 	},
-  	"redis": {
-	  	"url": "redis://redis:6379/0"
+	"smtp": {
+		"host": "smtp.mailgun.org",
+		"port": 587,
+		"auth": {
+			"user": "",
+			"pass": ""
+		},
+		"secure": false,
+		"enabled": false
+	},
+	"redis": {
+		"url": "redis://redis:6379/0",
+		"password": "PASSWORD"
+	},
+	"mongo": {
+		"url": "mongodb://musare:OTHER_PASSWORD_HERE@mongo:27017/musare"
+	},
+	"cookie": {
+		"domain": "localhost",
+		"secure": false,
+		"SIDname": "SID"
 	},
 	},
-  	"mongo": {
-	  	"url": "mongodb://mongo:27017/musare"
+	"skipConfigVersionCheck": false,
+	"skipDbDocumentsVersionCheck": false,
+	"debug": {
+		"stationIssue": false,
+		"traceUnhandledPromises": false,
+		"captureJobs": []
+	},
+	"defaultLogging": {
+		"hideType": [
+			"INFO"
+		],
+		"blacklistedTerms": []
+	},
+	"customLoggingPerModule": {
+		// "cache": {
+		//     "hideType": [
+		//     ],
+		//     "blacklistedTerms": []
+		// },
+		"migration": {
+			"hideType": [],
+			"blacklistedTerms": [
+				"Ran job",
+				"Running job",
+				"Queuing job",
+				"Pausing job",
+				"is queued",
+				"is re-queued",
+				"Requeing"
+			]
+		}
 	},
 	},
-  	"cookie": {
-	  	"domain": "",
-	  	"secure": false
-	}
-}
+	"configVersion": 6
+}

+ 645 - 0
backend/core.js

@@ -0,0 +1,645 @@
+import config from "config";
+
+class DeferredPromise {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.promise = new Promise((resolve, reject) => {
+			this.reject = reject;
+			this.resolve = resolve;
+		});
+	}
+}
+
+class QueueTask {
+	// eslint-disable-next-line require-jsdoc
+	constructor(job, options, priority) {
+		this.job = job;
+		this.options = options;
+		this.priority = priority;
+		this.job.setTask(this);
+	}
+}
+
+class Queue {
+	// eslint-disable-next-line require-jsdoc
+	constructor(handleTaskFunction, concurrency) {
+		this.handleTaskFunction = handleTaskFunction;
+		this.concurrency = concurrency;
+		this.queue = [];
+		this.runningTasks = [];
+		this.pausedTasks = [];
+		this.paused = false;
+	}
+
+	/**
+	 * Pauses the queue, meaning no new jobs can be started. Jobs can still be added to the queue, and already running tasks won't be paused.
+	 */
+	pause() {
+		this.paused = true;
+	}
+
+	/**
+	 * Resumes the queue.
+	 */
+	resume() {
+		this.paused = false;
+		setTimeout(() => {
+			this._handleQueue();
+		}, 0);
+	}
+
+	/**
+	 * Returns the amount of jobs in the queue.
+	 *
+	 * @returns {number} - amount of jobs in queue
+	 */
+	lengthQueue() {
+		return this.queue.length;
+	}
+
+	/**
+	 * Returns the amount of running jobs.
+	 *
+	 * @returns {number} - amount of running jobs
+	 */
+	lengthRunning() {
+		return this.runningTasks.length;
+	}
+
+	/**
+	 * Returns the amount of running jobs.
+	 *
+	 * @returns {number} - amount of running jobs
+	 */
+	lengthPaused() {
+		return this.pausedTasks.length;
+	}
+
+	/**
+	 * Adds a job to the queue, with a given priority.
+	 *
+	 * @param {object} job - the job that is to be added
+	 * @param {object} options - custom options e.g. isQuiet. Optional.
+	 * @param {number} priority - the priority of the to be added job
+	 */
+	push(job, options, priority) {
+		this.queue.push(new QueueTask(job, options, priority));
+		setTimeout(() => {
+			this._handleQueue();
+		}, 0);
+	}
+
+	/**
+	 * Removes a job currently running from the queue.
+	 *
+	 * @param {object} job - the job to be removed
+	 */
+	removeRunningJob(job) {
+		this.runningTasks.remove(this.runningTasks.find(task => task.job.toString() === job.toString()));
+	}
+
+	/**
+	 * Pauses a job currently running from the queue.
+	 *
+	 * @param {object} job - the job to be pauses
+	 */
+	pauseRunningJob(job) {
+		const task = this.runningTasks.find(task => task.job.toString() === job.toString());
+		if (!task) {
+			console.log(
+				`Attempted to pause job ${job.name} (${job.toString()}), but couldn't find it in running tasks.`
+			);
+			return;
+		}
+		this.runningTasks.remove(task);
+		this.pausedTasks.push(task);
+	}
+
+	/**
+	 * Resumes a job currently paused, adding the job back to the front of the queue
+	 *
+	 * @param {object} job - the job to be pauses
+	 */
+	resumeRunningJob(job) {
+		const task = this.pausedTasks.find(task => task.job.toString() === job.toString());
+		if (!task) {
+			console.log(
+				`Attempted to resume job ${job.name} (${job.toString()}), but couldn't find it in paused tasks.`
+			);
+			return;
+		}
+		this.pausedTasks.remove(task);
+		this.queue.unshift(task);
+		setTimeout(() => {
+			this._handleQueue();
+		}, 0);
+	}
+
+	/**
+	 * Check if there's room for a job to be processed, and if there is, run it.
+	 */
+	_handleQueue() {
+		if (this.queue.length > 0) {
+			const task = this.queue.reduce((a, b) => (a.priority < b.priority ? a : b));
+			if (task) {
+				if ((!this.paused && this.runningTasks.length < this.concurrency) || task.priority === -1) {
+					this.queue.remove(task);
+					this.runningTasks.push(task);
+					this._handleTask(task);
+					setTimeout(() => {
+						this._handleQueue();
+					}, 0);
+				}
+			}
+		}
+	}
+
+	/**
+	 * Handles a task, calling the handleTaskFunction provided in the constructor
+	 *
+	 * @param {object} task - the task to be handled
+	 */
+	_handleTask(task) {
+		this.handleTaskFunction(task.job, task.options).finally(() => {
+			this.runningTasks.remove(task);
+			this._handleQueue();
+		});
+	}
+}
+
+class Job {
+	// eslint-disable-next-line require-jsdoc
+	constructor(name, payload, onFinish, module, parentJob) {
+		this.name = name;
+		this.payload = payload;
+		this.response = null;
+		this.responseType = null;
+		this.onFinish = onFinish;
+		this.module = module;
+		this.parentJob = parentJob;
+		this.childJobs = [];
+		/* eslint-disable no-bitwise, eqeqeq */
+		this.uniqueId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
+			const r = (Math.random() * 16) | 0;
+			const v = c == "x" ? r : (r & 0x3) | 0x8;
+			return v.toString(16);
+		});
+		this.status = "INITIALIZED";
+		this.task = null;
+	}
+
+	/**
+	 * Adds a child job to this job
+	 *
+	 * @param {object} childJob - the child job
+	 */
+	addChildJob(childJob) {
+		this.childJobs.push(childJob);
+	}
+
+	/**
+	 * Sets the job status
+	 *
+	 * @param {string} status - the new status
+	 */
+	setStatus(status) {
+		this.status = status;
+	}
+
+	/**
+	 * Sets the task for a job
+	 *
+	 * @param {string} task - the job task
+	 */
+	setTask(task) {
+		this.task = task;
+	}
+
+	/**
+	 * Returns the UUID of the job, allowing you to compare jobs with toString
+	 *
+	 * @returns {string} - the job's UUID/uniqueId
+	 */
+	toString() {
+		return this.uniqueId;
+	}
+
+	/**
+	 * Sets the response that will be provided to the onFinish DeferredPromise resolve/reject function, as soon as the job is done if it has no parent, or when the parent job is resumed
+	 *
+	 * @param {object} response - the response
+	 */
+	setResponse(response) {
+		this.response = response;
+	}
+
+	/**
+	 * Sets the response type that is paired with the response. If it is RESOLVE/REJECT, then it will resolve/reject with the response. If it is RESOLVED/REJECTED, then it has already resolved/rejected with the response.
+	 *
+	 * @param {string} responseType - the response type, so RESOLVE/REJECT/RESOLVED/REJECTED
+	 */
+	setResponseType(responseType) {
+		this.responseType = responseType;
+	}
+
+	/**
+	 * Removes child jobs to prevent memory leak
+	 */
+	cleanup() {
+		this.childJobs = this.childJobs.map(() => null);
+	}
+
+	/**
+	 * Logs to the module of the job
+	 *
+	 * @param  {any} args - Anything to be added to the log e.g. log type, log message
+	 */
+	log(...args) {
+		args.splice(1, 0, this.name); // Adds the name of the job as the first argument (after INFO/SUCCESS/ERROR).
+		this.module.log(...args);
+	}
+}
+
+class MovingAverageCalculator {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.count = 0;
+		this._mean = 0;
+	}
+
+	/**
+	 * Updates the mean average
+	 *
+	 * @param {number} newValue - the new time it took to complete a job
+	 */
+	update(newValue) {
+		this.count += 1;
+		const differential = (newValue - this._mean) / this.count;
+		this._mean += differential;
+	}
+
+	/**
+	 * Returns the mean average
+	 *
+	 * @returns {number} - returns the mean average
+	 */
+	get mean() {
+		this.validate();
+		return this._mean;
+	}
+
+	/**
+	 * Checks that the mean is valid
+	 */
+	validate() {
+		if (this.count === 0) throw new Error("Mean is undefined");
+	}
+}
+
+export default class CoreClass {
+	/**
+	 *
+	 * @param {string} name - the name of the class
+	 * @param {object} options - optional options
+	 * @param {number} options.concurrency - how many jobs can run at the same time
+	 * @param {object} options.priorities - custom priorities for jobs
+	 */
+	constructor(name, options) {
+		this.name = name;
+		this.status = "UNINITIALIZED";
+		this.concurrency = options && options.concurrency ? options.concurrency : 10;
+		this.jobQueue = new Queue((job, options) => this._runJob(job, options), this.concurrency);
+		this.jobQueue.pause();
+		this.priorities = options && options.priorities ? options.priorities : {};
+		this.stage = 0;
+		this.jobStatistics = {};
+
+		this.logRules = config.get("customLoggingPerModule")[name]
+			? config.get("customLoggingPerModule")[name]
+			: config.get("defaultLogging");
+
+		this.registerJobs();
+	}
+
+	/**
+	 * Sets the status of a module
+	 *
+	 * @param {string} status - the new status of a module
+	 */
+	setStatus(status) {
+		this.status = status;
+		this.log("INFO", `Status changed to: ${status}`);
+		if (this.status === "READY") this.jobQueue.resume();
+		else if (this.status === "FAIL" || this.status === "LOCKDOWN") this.jobQueue.pause();
+	}
+
+	/**
+	 * Returns the status of a module
+	 *
+	 * @returns {string} - the status of a module
+	 */
+	getStatus() {
+		return this.status;
+	}
+
+	/**
+	 * Changes the current stage of a module
+	 *
+	 * @param {string} stage - the new stage of a module
+	 */
+	setStage(stage) {
+		this.stage = stage;
+	}
+
+	/**
+	 * Returns the current stage of a module
+	 *
+	 * @returns {string} - the current stage of a module
+	 */
+	getStage() {
+		return this.stage;
+	}
+
+	/**
+	 * Initialises a module and handles initialise successes and failures
+	 */
+	_initialize() {
+		this.setStatus("INITIALIZING");
+
+		this.initialize()
+			.then(() => {
+				this.setStatus("READY");
+				this.moduleManager.onInitialize(this);
+			})
+			.catch(err => {
+				console.error(err);
+				this.setStatus("FAILED");
+				this.moduleManager.onFail(this);
+			});
+	}
+
+	/**
+	 * Creates a new log message
+	 *
+	 * @param {...any} args - anything to be included in the log message, the first argument is the type of log
+	 */
+	log(...args) {
+		const _arguments = Array.from(args);
+		const type = _arguments[0];
+
+		if (config.debug && config.debug.stationIssue === true && type === "STATION_ISSUE") {
+			this.moduleManager.debugLogs.stationIssue.push(_arguments);
+			return;
+		}
+
+		if (this.logRules.hideType.indexOf(type) !== -1) return;
+
+		_arguments.splice(0, 1);
+		const start = `|${this.name.toUpperCase()}|`;
+		const numberOfSpacesNeeded = 20 - start.length;
+		_arguments.unshift(`${start}${Array(numberOfSpacesNeeded).join(" ")}`);
+
+		if (this.logRules.blacklistedTerms.some(blacklistedTerm => _arguments.join().indexOf(blacklistedTerm) !== -1))
+			return;
+
+		if (type === "INFO" || type === "SUCCESS") {
+			_arguments[0] += "\x1b[36m";
+			_arguments.push("\x1b[0m");
+			console.log.apply(null, _arguments);
+		} else if (type === "ERROR") {
+			_arguments[0] += "\x1b[31m";
+			_arguments.push("\x1b[0m");
+			console.error.apply(null, _arguments);
+		}
+	}
+
+	/**
+	 * Sets up each job with the statistics service (includes mean average for job completion)
+	 */
+	registerJobs() {
+		let props = [];
+		let obj = this;
+		do {
+			props = props.concat(Object.getOwnPropertyNames(obj));
+			// eslint-disable-next-line no-cond-assign
+		} while ((obj = Object.getPrototypeOf(obj)));
+
+		const jobNames = props.sort().filter(prop => typeof this[prop] === "function" && prop === prop.toUpperCase());
+
+		jobNames.forEach(jobName => {
+			this.jobStatistics[jobName] = {
+				successful: 0,
+				failed: 0,
+				total: 0,
+				averageTiming: new MovingAverageCalculator()
+			};
+		});
+	}
+
+	/**
+	 * Runs a job
+	 *
+	 * @param {string} name - the name of the job e.g. GET_PLAYLIST
+	 * @param {object} payload - any expected payload for the job itself
+	 * @param {object} parentJob - the parent job, if any
+	 * @param {number} priority - custom priority. Optional.
+	 * @param {object} options - custom options e.g. isQuiet. Optional.
+	 * @returns {Promise} - returns a promise
+	 */
+	runJob(name, payload, parentJob, priority, options) {
+		/** Allows for any combination of optional parameters (parentJob, priority, options) */
+
+		let _options;
+		let _priority;
+		let _parentJob;
+
+		if (parentJob) {
+			if (typeof parentJob === "object")
+				if (!parentJob.name) _options = parentJob;
+				else _parentJob = parentJob;
+			else if (typeof parentJob === "number") _priority = parentJob;
+		}
+
+		if (options) {
+			if (typeof options === "object")
+				if (options.name) _parentJob = options;
+				else _options = options;
+			if (typeof options === "number") _priority = options;
+		}
+
+		if (priority && typeof priority === "object") {
+			if (!priority.name) _options = priority;
+			else _parentJob = priority;
+		} else _priority = priority;
+
+		if (!_options) _options = { isQuiet: false };
+
+		const deferredPromise = new DeferredPromise();
+		const job = new Job(name, payload, deferredPromise, this, _parentJob);
+
+		this.log("INFO", `Queuing job ${name} (${job.toString()})`);
+
+		if (_parentJob) {
+			_parentJob.addChildJob(job);
+			if (_parentJob.status === "RUNNING") {
+				this.log(
+					"INFO",
+					`Pausing job ${_parentJob.name} (${_parentJob.toString()}) since a child job has to run first`
+				);
+				_parentJob.setStatus("WAITING_ON_CHILD_JOB");
+				_parentJob.module.jobQueue.pauseRunningJob(_parentJob);
+			} else {
+				this.log(
+					"INFO",
+					`Not pausing job ${_parentJob.name} (${_parentJob.toString()}) since it's already paused`
+				);
+			}
+		}
+
+		job.setStatus("QUEUED");
+
+		let calculatedPriority = null;
+		if (_priority) calculatedPriority = _priority;
+		else if (this.priorities[name]) calculatedPriority = this.priorities[name];
+		else if (_parentJob) calculatedPriority = _parentJob.task.priority;
+		else calculatedPriority = 10;
+
+		this.jobQueue.push(job, _options, calculatedPriority);
+
+		if (
+			config.debug &&
+			config.debug.stationIssue === true &&
+			config.debug.captureJobs &&
+			config.debug.captureJobs.indexOf(name) !== -1
+		) {
+			this.moduleManager.debugJobs.all.push({ job, _priority });
+		}
+
+		return deferredPromise.promise;
+	}
+
+	/**
+	 * UNKNOWN
+	 *
+	 * @param {object} moduleManager - UNKNOWN
+	 */
+	setModuleManager(moduleManager) {
+		this.moduleManager = moduleManager;
+	}
+
+	/**
+	 * Actually runs the job? UNKNOWN
+	 *
+	 * @param {object} job - object containing details of the job
+	 * @param {string} job.name - the name of the job e.g. GET_PLAYLIST
+	 * @param {string} job.payload - any expected payload for the job itself
+	 * @param {Promise} job.onFinish - deferred promise when the job is complete
+	 * @param {object} options - custom options e.g. isQuiet. Optional.
+	 * @returns {Promise} - returns a promise
+	 */
+	_runJob(job, options) {
+		if (!options.isQuiet) this.log("INFO", `Running job ${job.name} (${job.toString()})`);
+		return new Promise(resolve => {
+			const startTime = Date.now();
+
+			const previousStatus = job.status;
+			job.setStatus("RUNNING");
+			this.moduleManager.jobManager.addJob(job);
+
+			if (previousStatus === "QUEUED") {
+				if (!options.isQuiet) this.log("INFO", `Job ${job.name} (${job.toString()}) is queued, so calling it`);
+				this[job.name]
+					.apply(job, [job.payload])
+					.then(response => {
+						if (!options.isQuiet) this.log("INFO", `Ran job ${job.name} (${job.toString()}) successfully`);
+						job.setStatus("FINISHED");
+						job.setResponse(response);
+						this.jobStatistics[job.name].successful += 1;
+						job.setResponseType("RESOLVE");
+						if (
+							config.debug &&
+							config.debug.stationIssue === true &&
+							config.debug.captureJobs &&
+							config.debug.captureJobs.indexOf(job.name) !== -1
+						) {
+							this.moduleManager.debugJobs.completed.push({
+								status: "success",
+								job,
+								priority: job.task.priority,
+								response
+							});
+						}
+					})
+					.catch(error => {
+						this.log("INFO", `Running job ${job.name} (${job.toString()}) failed`);
+						job.setStatus("FINISHED");
+						job.setResponse(error);
+						job.setResponseType("REJECT");
+						this.jobStatistics[job.name].failed += 1;
+						if (
+							config.debug &&
+							config.debug.stationIssue === true &&
+							config.debug.captureJobs &&
+							config.debug.captureJobs.indexOf(job.name) !== -1
+						) {
+							this.moduleManager.debugJobs.completed.push({
+								status: "error",
+								job,
+								error
+							});
+						}
+					})
+					.finally(() => {
+						const endTime = Date.now();
+						const executionTime = endTime - startTime;
+						this.jobStatistics[job.name].total += 1;
+						this.jobStatistics[job.name].averageTiming.update(executionTime);
+						this.moduleManager.jobManager.removeJob(job);
+						job.cleanup();
+
+						if (!job.parentJob) {
+							if (job.responseType === "RESOLVE") {
+								job.onFinish.resolve(job.response);
+								job.responseType = "RESOLVED";
+							} else if (job.responseType === "REJECT") {
+								job.onFinish.reject(job.response);
+								job.responseType = "REJECTED";
+							}
+						} else if (
+							job.parentJob &&
+							job.parentJob.childJobs.find(childJob =>
+								childJob ? childJob.status !== "FINISHED" : true
+							) === undefined
+						) {
+							if (job.parentJob.status !== "WAITING_ON_CHILD_JOB") {
+								this.log(
+									"ERROR",
+									`Job ${
+										job.parentJob.name
+									} (${job.parentJob.toString()}) had a child job complete even though it is not waiting on a child job. This should never happen.`
+								);
+							} else {
+								job.parentJob.setStatus("REQUEUED");
+								job.parentJob.module.jobQueue.resumeRunningJob(job.parentJob);
+							}
+						}
+						resolve();
+					});
+			} else {
+				this.log(
+					"INFO",
+					`Job ${job.name} (${job.toString()}) is re-queued, so resolving/rejecting all child jobs.`
+				);
+				job.childJobs.forEach(childJob => {
+					if (childJob.responseType === "RESOLVE") {
+						childJob.onFinish.resolve(childJob.response);
+						childJob.responseType = "RESOLVED";
+					} else if (childJob.responseType === "REJECT") {
+						childJob.onFinish.reject(childJob.response);
+						childJob.responseType = "REJECTED";
+					}
+				});
+			}
+		});
+	}
+}

+ 348 - 60
backend/index.js

@@ -1,78 +1,366 @@
-'use strict';
-
-process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
-
-const async = require('async');
-
-const db = require('./logic/db');
-const app = require('./logic/app');
-const mail = require('./logic/mail');
-const api = require('./logic/api');
-const io = require('./logic/io');
-const stations = require('./logic/stations');
-const songs = require('./logic/songs');
-const playlists = require('./logic/playlists');
-const cache = require('./logic/cache');
-const notifications = require('./logic/notifications');
-const config = require('config');
-
-process.on('uncaughtException', err => {
-	//console.log(`ERROR: ${err.message}`);
-	console.log(`ERROR: ${err.stack}`);
+import "./loadEnvVariables.js";
+
+import util from "util";
+import config from "config";
+
+const REQUIRED_CONFIG_VERSION = 6;
+
+// eslint-disable-next-line
+Array.prototype.remove = function (item) {
+	this.splice(this.indexOf(item), 1);
+};
+
+process.on("uncaughtException", err => {
+	if (err.code === "ECONNREFUSED" || err.code === "UNCERTAIN_STATE") return;
+	console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
 });
 });
 
 
-async.waterfall([
+const blacklistedConsoleLogs = [];
+
+const oldConsole = {};
+oldConsole.log = console.log;
+
+console.log = (...args) => {
+	const string = util.format.apply(null, args);
+	let blacklisted = false;
+	blacklistedConsoleLogs.forEach(blacklistedConsoleLog => {
+		if (string.indexOf(blacklistedConsoleLog) !== -1) blacklisted = true;
+	});
+	if (!blacklisted) oldConsole.log.apply(null, args);
+};
+
+if (
+	(!config.has("configVersion") || config.get("configVersion") !== REQUIRED_CONFIG_VERSION) &&
+	!(config.has("skipConfigVersionCheck") && config.get("skipConfigVersionCheck"))
+) {
+	console.log(
+		"CONFIG VERSION IS WRONG. PLEASE UPDATE YOUR CONFIG WITH THE HELP OF THE TEMPLATE FILE AND THE README FILE."
+	);
+	process.exit();
+}
+
+if (config.debug && config.debug.traceUnhandledPromises === true) {
+	console.log("Enabled trace-unhandled/register");
+	import("trace-unhandled/register");
+}
 
 
-	// setup our Redis cache
-	(next) => {
-		cache.init(config.get('redis').url, () => {
-			next();
+class JobManager {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.runningJobs = {};
+	}
+
+	/**
+	 * Adds a job to the list of running jobs
+	 *
+	 * @param {object} job - the job object
+	 */
+	addJob(job) {
+		if (!this.runningJobs[job.module.name]) this.runningJobs[job.module.name] = {};
+		this.runningJobs[job.module.name][job.toString()] = job;
+	}
+
+	/**
+	 * Removes a job from the list of running jobs (after it's completed)
+	 *
+	 * @param {object} job - the job object
+	 */
+	removeJob(job) {
+		if (!this.runningJobs[job.module.name]) this.runningJobs[job.module.name] = {};
+		delete this.runningJobs[job.module.name][job.toString()];
+	}
+
+	/**
+	 * Returns detail about a job via a identifier
+	 *
+	 * @param {string} uuid - the job identifier
+	 * @returns {object} - the job object
+	 */
+	getJob(uuid) {
+		let job = null;
+		Object.keys(this.runningJobs).forEach(moduleName => {
+			if (this.runningJobs[moduleName][uuid]) job = this.runningJobs[moduleName][uuid];
 		});
 		});
-	},
+		return job;
+	}
+}
+
+class ModuleManager {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		this.modules = {};
+		this.modulesNotInitialized = [];
+		this.jobManager = new JobManager();
+		this.i = 0;
+		this.lockdown = false;
+		this.debugLogs = {
+			stationIssue: []
+		};
+		this.debugJobs = {
+			all: [],
+			completed: []
+		};
+		this.name = "MODULE_MANAGER";
+	}
+
+	/**
+	 * Adds a new module to the backend server/module manager
+	 *
+	 * @param {string} moduleName - the name of the module (also needs to be the same as the filename of a module located in the logic folder or "logic/moduleName/index.js")
+	 */
+	async addModule(moduleName) {
+		this.log("INFO", "Adding module", moduleName);
+
+		this.modules[moduleName] = import(`./logic/${moduleName}`);
+	}
 
 
-	// setup our MongoDB database
-	(next) => db.init(config.get("mongo").url, next),
+	/**
+	 * Initialises a new module to the backend server/module manager
+	 *
+	 */
+	async initialize() {
+		this.reservedLines = Object.keys(this.modules).length + 5;
 
 
-	// setup the express server
-	(next) => app.init(next),
+		await Promise.all(Object.values(this.modules)).then(modules => {
+			for (let module = 0; module < modules.length; module += 1) {
+				this.modules[modules[module].default.name] = modules[module].default;
+				this.modulesNotInitialized.push(modules[module].default);
+			}
+		}); // ensures all modules are imported, then converts promise to the default export of the import
 
 
-	// setup the mail
-	(next) => mail.init(next),
+		Object.keys(this.modules).every(moduleKey => {
+			const module = this.modules[moduleKey];
 
 
-	// setup the socket.io server (all client / server communication is done over this)
-	(next) => io.init(next),
+			module.setModuleManager(this);
 
 
-	// setup the notifications
-	(next) => notifications.init(config.get('redis').url, next),
+			if (this.lockdown) return false;
 
 
-	// setup the stations
-	(next) => stations.init(next),
+			module._initialize();
 
 
-	// setup the songs
-	(next) => songs.init(next),
+			return true;
+		});
+	}
 
 
-	// setup the playlists
-	(next) => playlists.init(next),
+	/**
+	 * Called when a module is initialised
+	 *
+	 * @param {object} module - the module object/class
+	 */
+	onInitialize(module) {
+		if (this.modulesNotInitialized.indexOf(module) !== -1) {
+			this.modulesNotInitialized.splice(this.modulesNotInitialized.indexOf(module), 1);
 
 
-	// setup the API
-	(next) => api.init(next),
+			this.log(
+				"INFO",
+				`Initialized: ${Object.keys(this.modules).length - this.modulesNotInitialized.length}/${
+					Object.keys(this.modules).length
+				}.`
+			);
+
+			if (this.modulesNotInitialized.length === 0) this.onAllModulesInitialized();
+		}
+	}
 
 
-	// setup the frontend for local setups
-	(next) => {
-		if (!config.get("isDocker")) {
-			const express = require('express');
-			const app = express();
-			const server = app.listen(80);
-			app.use(express.static(__dirname + "/../frontend/build/"));
+	/**
+	 * Called when a module fails to initialise
+	 *
+	 * @param {object} module - the module object/class
+	 */
+	onFail(module) {
+		if (this.modulesNotInitialized.indexOf(module) !== -1) {
+			this.log("ERROR", "A module failed to initialize!");
 		}
 		}
-		next();
 	}
 	}
-], (err) => {
-	if (err && err !== true) {
-		console.error('An error occurred while initializing the backend server');
-		console.error(err);
-		process.exit();
-	} else {
-		console.info('Backend server has been successfully started');
+
+	/**
+	 * Called when every module has initialised
+	 *
+	 */
+	onAllModulesInitialized() {
+		this.log("INFO", "All modules initialized!");
+	}
+
+	/**
+	 * Creates a new log message
+	 *
+	 * @param {...any} args - anything to be included in the log message, the first argument is the type of log
+	 */
+	log(...args) {
+		const _arguments = Array.from(args);
+		const type = _arguments[0];
+
+		_arguments.splice(0, 1);
+		const start = `|${this.name.toUpperCase()}|`;
+		const numberOfSpacesNeeded = 20 - start.length;
+		_arguments.unshift(`${start}${Array(numberOfSpacesNeeded).join(" ")}`);
+
+		if (type === "INFO") {
+			_arguments[0] += "\x1b[36m";
+			_arguments.push("\x1b[0m");
+			console.log.apply(null, _arguments);
+		} else if (type === "ERROR") {
+			_arguments[0] += "\x1b[31m";
+			_arguments.push("\x1b[0m");
+			console.error.apply(null, _arguments);
+		}
+	}
+
+	/**
+	 * Locks down all modules
+	 */
+	_lockdown() {
+		this.lockdown = true;
+		Object.keys(this.modules).every(moduleKey => {
+			const module = this.modules[moduleKey];
+			module.setStatus("LOCKDOWN");
+			return true;
+		});
+	}
+}
+
+const moduleManager = new ModuleManager();
+
+if (!config.get("migration")) {
+	moduleManager.addModule("cache");
+	moduleManager.addModule("db");
+	moduleManager.addModule("mail");
+	moduleManager.addModule("activities");
+	moduleManager.addModule("api");
+	moduleManager.addModule("app");
+	moduleManager.addModule("ws");
+	moduleManager.addModule("notifications");
+	moduleManager.addModule("playlists");
+	moduleManager.addModule("punishments");
+	moduleManager.addModule("songs");
+	moduleManager.addModule("stations");
+	moduleManager.addModule("tasks");
+	moduleManager.addModule("utils");
+	moduleManager.addModule("youtube");
+} else {
+	moduleManager.addModule("migration");
+}
+
+moduleManager.initialize();
+
+/**
+ * Prints a job
+ *
+ * @param {object} job - the job
+ * @param {number} layer - the layer
+ */
+function printJob(job, layer) {
+	const tabs = Array(layer).join("\t");
+	if (job) {
+		console.log(`${tabs}${job.name} (${job.toString()}) ${job.status}`);
+		job.childJobs.forEach(childJob => {
+			printJob(childJob, layer + 1);
+		});
+	} else console.log(`${tabs}JOB WAS REMOVED`);
+}
+
+/**
+ * Prints a task
+ *
+ * @param {object} task - the task
+ * @param {number} layer - the layer
+ */
+function printTask(task, layer) {
+	const tabs = Array(layer).join("\t");
+	console.log(`${tabs}${task.job.name} (${task.job.toString()}) ${task.job.status} (priority: ${task.priority})`);
+	task.job.childJobs.forEach(childJob => {
+		printJob(childJob, layer + 1);
+	});
+}
+
+process.stdin.on("data", data => {
+	const command = data.toString().replace(/\r?\n|\r/g, "");
+	if (command === "lockdown") {
+		console.log("Locking down.");
+		moduleManager._lockdown();
+	}
+	if (command === "status") {
+		console.log("Status:");
+
+		Object.keys(moduleManager.modules).forEach(moduleName => {
+			const module = moduleManager.modules[moduleName];
+			const tabsNeeded = 4 - Math.ceil((moduleName.length + 1) / 8);
+			console.log(
+				`${moduleName.toUpperCase()}${Array(tabsNeeded).join(
+					"\t"
+				)}${module.getStatus()}. Jobs in queue: ${module.jobQueue.lengthQueue()}. Jobs in progress: ${module.jobQueue.lengthRunning()}. Jobs paused: ${module.jobQueue.lengthPaused()} Concurrency: ${
+					module.jobQueue.concurrency
+				}. Stage: ${module.getStage()}`
+			);
+		});
+	}
+	if (command.startsWith("running")) {
+		const parts = command.split(" ");
+
+		moduleManager.modules[parts[1]].jobQueue.runningTasks.forEach(task => {
+			printTask(task, 1);
+		});
+	}
+	if (command.startsWith("queued")) {
+		const parts = command.split(" ");
+
+		moduleManager.modules[parts[1]].jobQueue.queue.forEach(task => {
+			printTask(task, 1);
+		});
+	}
+	if (command.startsWith("paused")) {
+		const parts = command.split(" ");
+
+		moduleManager.modules[parts[1]].jobQueue.pausedTasks.forEach(task => {
+			printTask(task, 1);
+		});
+	}
+	if (command.startsWith("stats")) {
+		const parts = command.split(" ");
+
+		console.log(moduleManager.modules[parts[1]].jobStatistics);
+	}
+	if (command.startsWith("jobinfo")) {
+		const parts = command.split(" ");
+
+		const uuid = parts[1];
+		const jobFound = moduleManager.jobManager.getJob(uuid);
+
+		if (jobFound) {
+			let topParent = jobFound;
+			let levelsDeep = 0;
+			while (topParent.parentJob && topParent !== topParent.parentJob) {
+				topParent = jobFound.parentJob;
+				levelsDeep += 1;
+			}
+			console.log(
+				`Found job, displaying that job and the full tree from the top parent job. The job is ${levelsDeep} levels deep from the top parent.`
+			);
+			console.log(jobFound);
+			printJob(topParent, 1);
+		} else console.log("Could not find job in job manager.");
+	}
+	if (command.startsWith("runjob")) {
+		const parts = command.split(" ");
+		const module = parts[1];
+		const jobName = parts[2];
+		const payload = JSON.parse(parts[3]);
+
+		moduleManager.modules[module]
+			.runJob(jobName, payload)
+			.then(response => {
+				console.log("runjob success", response);
+			})
+			.catch(err => {
+				console.log("runjob error", err);
+			});
+	}
+	if (command.startsWith("eval")) {
+		const evalCommand = command.replace("eval ", "");
+		console.log(`Running eval command: ${evalCommand}`);
+		// eslint-disable-next-line no-eval
+		const response = eval(evalCommand);
+		console.log(`Eval response: `, response);
 	}
 	}
 });
 });
+
+export default moduleManager;

+ 5 - 0
backend/loadEnvVariables.js

@@ -0,0 +1,5 @@
+import { dirname } from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+process.env.NODE_CONFIG_DIR = `${__dirname}/config`;

+ 214 - 0
backend/logic/actions/activities.js

@@ -0,0 +1,214 @@
+import async from "async";
+
+import { isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const CacheModule = moduleManager.modules.cache;
+const WSModule = moduleManager.modules.ws;
+const UtilsModule = moduleManager.modules.utils;
+
+CacheModule.runJob("SUB", {
+	channel: "activity.removeAllForUser",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }, this).then(sockets =>
+			sockets.forEach(socket => socket.dispatch("event:activity.removeAllForUser"))
+		);
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "activity.hide",
+	cb: res => {
+		const { activityId, userId } = res;
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }, this).then(sockets =>
+			sockets.forEach(socket => socket.dispatch("event:activity.hidden", { data: { activityId } }))
+		);
+	}
+});
+
+export default {
+	/**
+	 * Returns how many activities there are for a user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the id of the user in question
+	 * @param {Function} cb - callback
+	 */
+	async length(session, userId, cb) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					activityModel.countDocuments({ userId, hidden: false }, next);
+				}
+			],
+			async (err, count) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_LENGTH",
+						`Failed to get length of activities for user ${userId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "ACTIVITIES_LENGTH", `Got length of activities for user ${userId} successfully.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully obtained length of activities.",
+					data: { length: count }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Gets a set of activities
+	 *
+	 * @param {object} session - user session
+	 * @param {string} userId - the user whose activities we are looking for
+	 * @param {number} set - the set number to return
+	 * @param {number} offset - how many activities to skip (keeps frontend and backend in sync)
+	 * @param {Function} cb - callback
+	 */
+	async getSet(session, userId, set, offset, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					// activities should only be viewed if public/owned by the user
+					if (session.userId !== userId) {
+						return userModel
+							.findById(userId)
+							.then(user => {
+								if (user) {
+									if (user.preferences.activityLogPublic) return next();
+									return next("User's activity log isn't public.");
+								}
+
+								return next("User does not exist.");
+							})
+							.catch(next);
+					}
+
+					return next();
+				},
+
+				next => {
+					activityModel
+						.find({ userId, hidden: false })
+						.skip(15 * (set - 1) + offset)
+						.limit(15)
+						.sort({ createdAt: -1 })
+						.exec(next);
+				}
+			],
+			async (err, activities) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "ACTIVITIES_GET_SET", `Failed to get set ${set} from activities. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "ACTIVITIES_GET_SET", `Set ${set} from activities obtained successfully.`);
+				return cb({ status: "success", data: { activities } });
+			}
+		);
+	},
+
+	/**
+	 * Hides an activity for a user
+	 *
+	 * @param session
+	 * @param {string} activityId - the activity which should be hidden
+	 * @param cb
+	 */
+	hideActivity: isLoginRequired(async function hideActivity(session, activityId, cb) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					activityModel.updateOne({ _id: activityId }, { $set: { hidden: true } }, next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "ACTIVITIES_HIDE_ACTIVITY", `Failed to hide activity ${activityId}. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "activity.hide",
+					value: {
+						userId: session.userId,
+						activityId
+					}
+				});
+
+				this.log("SUCCESS", "ACTIVITIES_HIDE_ACTIVITY", `Successfully hid activity ${activityId}.`);
+
+				return cb({ status: "success", message: "Successfully hid activity." });
+			}
+		);
+	}),
+
+	/**
+	 * Removes all activities logged for a logged-in user
+	 *
+	 * @param session
+	 * @param cb
+	 */
+	removeAllForUser: isLoginRequired(async function removeAllForUser(session, cb) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					activityModel.deleteMany({ userId: session.userId }, next);
+				},
+
+				(res, next) => {
+					CacheModule.runJob("HDEL", { table: "recentActivities", key: session.userId }, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"ACTIVITIES_REMOVE_ALL_FOR_USER",
+						`Failed to delete activities for user ${session.userId}. "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "activity.removeAllForUser",
+					value: session.userId
+				});
+
+				this.log(
+					"SUCCESS",
+					"ACTIVITIES_REMOVE_ALL_FOR_USER",
+					`Successfully removed activities for user ${session.userId}.`
+				);
+
+				return cb({ status: "success", message: "Successfully removed your activity logs." });
+			}
+		);
+	})
+};

+ 209 - 36
backend/logic/actions/apis.js

@@ -1,57 +1,230 @@
-'use strict';
+import config from "config";
+import async from "async";
+import axios from "axios";
 
 
-const request = require('request'),
-	  config  = require('config'),
-		utils = require('../utils'),
-		hooks = require('./hooks');
+import { isAdminRequired } from "./hooks";
 
 
-module.exports = {
+import moduleManager from "../../index";
 
 
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const YouTubeModule = moduleManager.modules.youtube;
+
+export default {
 	/**
 	/**
-	 * Fetches a list of songs from Youtubes API
+	 * Fetches a list of songs from Youtube's API
 	 *
 	 *
-	 * @param session
-	 * @param query - the query we'll pass to youtubes api
-	 * @param cb
-	 * @return {{ status: String, data: Object }}
+	 * @param {object} session - user session
+	 * @param {string} query - the query we'll pass to youtubes api
+	 * @param {Function} cb - callback
+	 * @returns {{status: string, data: object}} - returns an object
 	 */
 	 */
-	searchYoutube: (session, query, cb) => {
+	searchYoutube(session, query, cb) {
+		return YouTubeModule.runJob("SEARCH", { query }, this)
+			.then(data => {
+				this.log("SUCCESS", "APIS_SEARCH_YOUTUBE", `Searching YouTube successful with query "${query}".`);
+				return cb({ status: "success", data });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
+				return cb({ status: "error", message: err });
+			});
+	},
 
 
-		const params = [
-			'part=snippet',
-			`q=${encodeURIComponent(query)}`,
-			`key=${config.get('apis.youtube.key')}`,
-			'type=video',
-			'maxResults=15'
-		].join('&');
+	/**
+	 * Fetches a specific page of search results from Youtube's API
+	 *
+	 * @param {object} session - user session
+	 * @param {string} query - the query we'll pass to youtubes api
+	 * @param {string} pageToken - identifies a specific page in the result set that should be retrieved
+	 * @param {Function} cb - callback
+	 * @returns {{status: string, data: object}} - returns an object
+	 */
+	searchYoutubeForPage(session, query, pageToken, cb) {
+		return YouTubeModule.runJob("SEARCH", { query, pageToken }, this)
+			.then(data => {
+				this.log(
+					"SUCCESS",
+					"APIS_SEARCH_YOUTUBE_FOR_PAGE",
+					`Searching YouTube successful with query "${query}".`
+				);
+				return cb({ status: "success", data });
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log(
+					"ERROR",
+					"APIS_SEARCH_YOUTUBE_FOR_PAGE",
+					`Searching youtube failed with query "${query}". "${err}"`
+				);
+				return cb({ status: "error", message: err });
+			});
+	},
 
 
-		request(`https://www.googleapis.com/youtube/v3/search?${params}`, (err, res, body) => {
+	/**
+	 * Gets Discogs data
+	 *
+	 * @param session
+	 * @param query - the query
+	 * @param {Function} cb
+	 */
+	searchDiscogs: isAdminRequired(function searchDiscogs(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					const options = {
+						params: { q: query, per_page: 20, page },
+						headers: {
+							"User-Agent": "Request",
+							Authorization: `Discogs key=${config.get("apis.discogs.client")}, secret=${config.get(
+								"apis.discogs.secret"
+							)}`
+						}
+					};
 
 
-			if (err) {
-				console.error(err);
-				return cb({ status: 'error', message: 'Failed to search youtube with the requested query' });
+					axios
+						.get("https://api.discogs.com/database/search", options)
+						.then(res => next(null, res.data))
+						.catch(err => next(err));
+				}
+			],
+			async (err, body) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"APIS_SEARCH_DISCOGS",
+						`Searching discogs failed with query "${query}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"APIS_SEARCH_DISCOGS",
+					`User "${session.userId}" searched Discogs succesfully for query "${query}".`
+				);
+				return cb({
+					status: "success",
+					data: {
+						results: body.results,
+						pages: body.pagination.pages
+					}
+				});
 			}
 			}
+		);
+	}),
 
 
-			cb({ status: 'success', data: JSON.parse(body) });
-		});
+	/**
+	 * Joins a room
+	 *
+	 * @param {object} session - user session
+	 * @param {string} room - the room to join
+	 * @param {Function} cb - callback
+	 */
+	joinRoom(session, room, cb) {
+		if (
+			room === "home" ||
+			room === "news" ||
+			room.startsWith("profile.") ||
+			room.startsWith("manage-station.") ||
+			room.startsWith("edit-song.") ||
+			room.startsWith("view-report.") ||
+			room.startsWith("edit-user.") ||
+			room === "import-album"
+		) {
+			WSModule.runJob("SOCKET_JOIN_ROOM", {
+				socketId: session.socketId,
+				room
+			})
+				.then(() => {})
+				.catch(err => {
+					this.log("ERROR", `Joining room failed: ${err.message}`);
+				});
+		}
+
+		cb({ status: "success", message: "Successfully joined room." });
 	},
 	},
 
 
-	joinRoom: (session, page, cb) => {
-		if (page === 'home') {
-			utils.socketJoinRoom(session.socketId, page);
+	/**
+	 * Leaves a room
+	 *
+	 * @param {object} session - user session
+	 * @param {string} room - the room to leave
+	 * @param {Function} cb - callback
+	 */
+	leaveRoom(session, room, cb) {
+		if (
+			room === "home" ||
+			room.startsWith("profile.") ||
+			room.startsWith("manage-station.") ||
+			room.startsWith("edit-song.") ||
+			room.startsWith("view-report.") ||
+			room === "import-album"
+		) {
+			WSModule.runJob("SOCKET_LEAVE_ROOM", {
+				socketId: session.socketId,
+				room
+			})
+				.then(() => {})
+				.catch(err => {
+					this.log("ERROR", `Leaving room failed: ${err.message}`);
+				});
 		}
 		}
-		cb({});
+
+		cb({ status: "success", message: "Successfully left room." });
 	},
 	},
 
 
-	joinAdminRoom: hooks.adminRequired((session, page, cb) => {
-		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news' || page === 'users') {
-			utils.socketJoinRoom(session.socketId, `admin.${page}`);
+	/**
+	 * Joins an admin room
+	 *
+	 * @param {object} session - user session
+	 * @param {string} page - the admin room to join
+	 * @param {Function} cb - callback
+	 */
+	joinAdminRoom: isAdminRequired((session, page, cb) => {
+		if (
+			page === "unverifiedSongs" ||
+			page === "songs" ||
+			page === "hiddenSongs" ||
+			page === "stations" ||
+			page === "reports" ||
+			page === "news" ||
+			page === "playlists" ||
+			page === "users" ||
+			page === "statistics" ||
+			page === "punishments"
+		) {
+			WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId }).then(() => {
+				WSModule.runJob("SOCKET_JOIN_ROOM", {
+					socketId: session.socketId,
+					room: `admin.${page}`
+				});
+			});
 		}
 		}
-		cb({});
+
+		cb({ status: "success", message: "Successfully joined admin room." });
 	}),
 	}),
 
 
-	ping: (session, cb) => {
-		cb({date: Date.now()});
-	}
+	/**
+	 * Leaves all rooms
+	 *
+	 * @param {object} session - user session
+	 * @param {Function} cb - callback
+	 */
+	leaveRooms(session, cb) {
+		WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session.socketId });
+
+		cb({ status: "success", message: "Successfully left all rooms." });
+	},
 
 
+	/**
+	 * Returns current date
+	 *
+	 * @param {object} session - user session
+	 * @param {Function} cb - callback
+	 */
+	ping(session, cb) {
+		cb({ status: "success", message: "Successfully pinged.", data: { date: Date.now() } });
+	}
 };
 };

+ 102 - 0
backend/logic/actions/dataRequests.js

@@ -0,0 +1,102 @@
+import async from "async";
+
+import { isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+
+CacheModule.runJob("SUB", {
+	channel: "dataRequest.resolve",
+	cb: dataRequestId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.users",
+			args: ["event:admin.dataRequests.resolved", { data: { dataRequestId } }]
+		});
+	}
+});
+
+export default {
+	/**
+	 * Gets all unresolved data requests
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					dataRequestModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next);
+				}
+			],
+			async (err, requests) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "DATA_REQUESTS_INDEX", `Indexing data requests failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "DATA_REQUESTS_INDEX", `Indexing data requests successful.`, false);
+
+				return cb({ status: "success", data: { requests } });
+			}
+		);
+	}),
+
+	/**
+	 * Resolves a data request
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} dataRequestId - the id of the data request to resolve
+	 * @param {Function} cb - gets called with the result
+	 */
+	resolve: isAdminRequired(async function update(session, dataRequestId, cb) {
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!dataRequestId || typeof dataRequestId !== "string")
+						return next("Please provide a data request id.");
+					return next();
+				},
+
+				next => {
+					dataRequestModel.updateOne({ _id: dataRequestId }, { resolved: true }, { upsert: true }, err =>
+						next(err)
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"DATA_REQUESTS_RESOLVE",
+						`Resolving data request ${dataRequestId} failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "dataRequest.resolve", value: dataRequestId });
+
+				this.log(
+					"SUCCESS",
+					"DATA_REQUESTS_RESOLVE",
+					`Resolving data request "${dataRequestId}" successful for user ${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully resolved data request."
+				});
+			}
+		);
+	})
+};

+ 50 - 18
backend/logic/actions/hooks/adminRequired.js

@@ -1,19 +1,51 @@
-const cache = require('../../cache');
-const db = require('../../db');
+import async from "async";
 
 
-module.exports = function(next) {
-	return function(session) {
-		let args = [];
-		for (let prop in arguments) args.push(arguments[prop]);
-		let cb = args[args.length - 1];
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session || !session.userId) return cb({ status: 'failure', message: 'Login required.' });
-			db.models.user.findOne({_id: session.userId}, (err, user) => {
-				if (err || !user) return cb({ status: 'failure', message: 'Login required.' });
-				if (user.role !== 'admin') return cb({ status: 'failure', message: 'Admin required.' });
-				args.push(session.userId);
-				next.apply(null, args);
-			});
-		});
-	}
-};
+import moduleManager from "../../../index";
+
+const DBModule = moduleManager.modules.db;
+const CacheModule = moduleManager.modules.cache;
+const UtilsModule = moduleManager.modules.utils;
+
+export default destination =>
+	async function adminRequired(session, ...args) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const cb = args[args.length - 1];
+
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "sessions",
+							key: session.sessionId
+						},
+						this
+					)
+						.then(session => {
+							next(null, session);
+						})
+						.catch(next);
+				},
+				(session, next) => {
+					if (!session || !session.userId) return next("Login required.");
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+				(user, next) => {
+					if (!user) return next("Login required.");
+					if (user.role !== "admin") return next("Insufficient permissions.");
+					return next();
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("INFO", "ADMIN_REQUIRED", `User failed to pass admin required check. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("INFO", "ADMIN_REQUIRED", `User "${session.userId}" passed admin required check.`, false);
+				return destination.apply(this, [session].concat(args));
+			}
+		);
+	};

+ 6 - 6
backend/logic/actions/hooks/index.js

@@ -1,7 +1,7 @@
-'use strict';
+import loginRequired from "./loginRequired";
+import adminRequired from "./adminRequired";
+import ownerRequired from "./ownerRequired";
 
 
-module.exports = {
-	loginRequired: require('./loginRequired'),
-	adminRequired: require('./adminRequired'),
-	ownerRequired: require('./ownerRequired')
-};
+export const isLoginRequired = loginRequired;
+export const isAdminRequired = adminRequired;
+export const isOwnerRequired = ownerRequired;

+ 41 - 14
backend/logic/actions/hooks/loginRequired.js

@@ -1,14 +1,41 @@
-const cache = require('../../cache');
-
-module.exports = function(next) {
-	return function(session) {
-		let args = [];
-		for (let prop in arguments) args.push(arguments[prop]);
-		let cb = args[args.length - 1];
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session || !session.userId) return cb({ status: 'failure', message: 'Login required.' });
-			args.push(session.userId);
-			next.apply(null, args);
-		});
-	}
-};
+import async from "async";
+
+import moduleManager from "../../../index";
+
+const CacheModule = moduleManager.modules.cache;
+const UtilsModule = moduleManager.modules.utils;
+
+export default destination =>
+	function loginRequired(session, ...args) {
+		const cb = args[args.length - 1];
+
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "sessions",
+							key: session.sessionId
+						},
+						this
+					)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+				(session, next) => {
+					if (!session || !session.userId) return next("Login required.");
+					return next();
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("LOGIN_REQUIRED", `User failed to pass login required check.`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("LOGIN_REQUIRED", `User "${session.userId}" passed login required check.`);
+				return destination.apply(this, [session].concat(args));
+			}
+		);
+	};

+ 68 - 28
backend/logic/actions/hooks/ownerRequired.js

@@ -1,30 +1,70 @@
-const cache = require('../../cache');
-const db = require('../../db');
-const stations = require('../../stations');
-
-module.exports = function(next) {
-	return function(session, stationId) {
-		let args = [];
-		for (let prop in arguments) args.push(arguments[prop]);
-		let cb = args[args.length - 1];
-		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session || !session.userId) return cb({ status: 'failure', message: 'Login required.' });
-			db.models.user.findOne({_id: session.userId}, (err, user) => {
-				if (err || !user) return cb({ status: 'failure', message: 'Login required.' });
-				if (user.role === 'admin') pushArgs();
-				else {
-					stations.getStation(stationId, (err, station) => {
-						if (err || !station) return cb({ status: 'failure', message: 'Something went wrong when getting the station.' });
-						else if (station.type === 'community' && station.owner === session.userId) pushArgs();
-						else return cb({ status: 'failure', message: 'Invalid permissions.' });
-					});
-				}
+import async from "async";
+
+import moduleManager from "../../../index";
+
+const DBModule = moduleManager.modules.db;
+const CacheModule = moduleManager.modules.cache;
+const UtilsModule = moduleManager.modules.utils;
+const StationsModule = moduleManager.modules.stations;
+
+export default destination =>
+	async function ownerRequired(session, stationId, ...args) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const cb = args[args.length - 1];
 
 
-				function pushArgs() {
-					args.push(session.userId);
-					next.apply(null, args);
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "sessions",
+							key: session.sessionId
+						},
+						this
+					)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+				(session, next) => {
+					if (!session || !session.userId) return next("Login required.");
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+				(user, next) => {
+					if (!user) return next("Login required.");
+					if (user.role === "admin") return next(true);
+
+					if (!stationId) return next("Please provide a stationId.");
+
+					return StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.type === "community" && station.owner === session.userId) return next(true);
+					return next("Invalid permissions.");
 				}
 				}
-			});
-		});
-	}
-};
+			],
+			async err => {
+				if (err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"INFO",
+						"OWNER_REQUIRED",
+						`User failed to pass owner required check for station "${stationId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"INFO",
+					"OWNER_REQUIRED",
+					`User "${session.userId}" passed owner required check for station "${stationId}"`,
+					false
+				);
+
+				return destination.apply(this, [session, stationId].concat(args));
+			}
+		);
+	};

+ 23 - 10
backend/logic/actions/index.js

@@ -1,12 +1,25 @@
-'use strict';
+import apis from "./apis";
+import songs from "./songs";
+import stations from "./stations";
+import playlists from "./playlists";
+import users from "./users";
+import dataRequests from "./dataRequests";
+import activities from "./activities";
+import reports from "./reports";
+import news from "./news";
+import punishments from "./punishments";
+import utils from "./utils";
 
 
-module.exports = {
-	apis: require('./apis'),
-	songs: require('./songs'),
-	queueSongs: require('./queueSongs'),
-	stations: require('./stations'),
-	playlists: require('./playlists'),
-	users: require('./users'),
-	reports: require('./reports'),
-	news: require('./news')
+export default {
+	apis,
+	songs,
+	stations,
+	playlists,
+	users,
+	dataRequests,
+	activities,
+	reports,
+	news,
+	punishments,
+	utils
 };
 };

+ 216 - 99
backend/logic/actions/news.js

@@ -1,149 +1,266 @@
-'use strict';
+import async from "async";
 
 
-const async = require('async');
+import { isAdminRequired } from "./hooks";
 
 
-const db = require('../db');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
-const hooks = require('./hooks');
+import moduleManager from "../../index";
 
 
-cache.sub('news.create', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.created', news);
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+
+CacheModule.runJob("SUB", {
+	channel: "news.create",
+	cb: news => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.news",
+			args: ["event:admin.news.created", { data: { news } }]
 		});
 		});
-	});
+
+		if (news.status === "published")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "news",
+				args: ["event:news.created", { data: { news } }]
+			});
+	}
 });
 });
 
 
-cache.sub('news.remove', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.removed', news);
+CacheModule.runJob("SUB", {
+	channel: "news.remove",
+	cb: newsId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.news",
+			args: ["event:admin.news.deleted", { data: { newsId } }]
 		});
 		});
-	});
-});
 
 
-cache.sub('news.update', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.updated', news);
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "news",
+			args: ["event:news.deleted", { data: { newsId } }]
 		});
 		});
-	});
+	}
 });
 });
 
 
-module.exports = {
+CacheModule.runJob("SUB", {
+	channel: "news.update",
+	cb: news => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.news",
+			args: ["event:admin.news.updated", { data: { news } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "news",
+			args: ["event:news.updated", { data: { news } }]
+		});
+	}
+});
 
 
+export default {
 	/**
 	/**
-	 * Gets all news items
+	 * Gets all news items that are published
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	index: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
-			}
-		], (err, news) => {
-			if (err) {
-				logger.error("NEWS_INDEX", `Indexing news failed. "${err.message}"`);
-				return cb({status: 'failure', message: 'Something went wrong.'});
+	async index(session, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+		async.waterfall(
+			[
+				next => {
+					newsModel.find({ status: "published" }).sort({ createdAt: "desc" }).exec(next);
+				}
+			],
+			async (err, news) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "NEWS_INDEX", `Indexing news failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "NEWS_INDEX", `Indexing news successful.`, false);
+
+				return cb({ status: "success", data: { news } });
 			}
 			}
-			logger.success("NEWS_INDEX", `Indexing news successful.`);
-			return cb({ status: 'success', data: news });
-		});
+		);
 	},
 	},
 
 
 	/**
 	/**
-	 * Creates a news item
+	 * Gets a news item by id
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} data - the object of the news data
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} newsId - the news item id
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	create: hooks.adminRequired((session, data, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				data.createdBy = userId;
-				data.createdAt = Date.now();
-				db.models.news.create(data, next);
+	async getNewsFromId(session, newsId, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					newsModel.findById(newsId, next);
+				}
+			],
+			async (err, news) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_NEWS_FROM_ID", `Getting news item ${newsId} failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "GET_NEWS_FROM_ID", `Got news item ${newsId} successfully.`, false);
+
+				return cb({ status: "success", data: { news } });
 			}
 			}
-		], (err, news) => {
-			if (err) {
-				logger.error("NEWS_CREATE", `Creating news failed. "${err.message}"`);
-				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
+		);
+	},
+	/**
+	 * Creates a news item
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} data - the object of the news data
+	 * @param {Function} cb - gets called with the result
+	 */
+	create: isAdminRequired(async function create(session, data, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+		async.waterfall(
+			[
+				next => {
+					data.createdBy = session.userId;
+					data.createdAt = Date.now();
+					newsModel.create(data, next);
+				}
+			],
+			async (err, news) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "NEWS_CREATE", `Creating news failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "news.create", value: news });
+
+				this.log("SUCCESS", "NEWS_CREATE", `Creating news successful.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully created News"
+				});
 			}
 			}
-			cache.pub('news.create', news);
-			logger.success("NEWS_CREATE", `Creating news successful.`);
-			return cb({ 'status': 'success', 'message': 'Successfully created News' });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Gets the latest news item
 	 * Gets the latest news item
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	newest: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
-			}
-		], (err, news) => {
+	async newest(session, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+		async.waterfall([next => newsModel.findOne({}).sort({ createdAt: "desc" }).exec(next)], async (err, news) => {
 			if (err) {
 			if (err) {
-				logger.error("NEWS_NEWEST", `Getting the latest news failed. "${err.message}"`);
-				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
+				return cb({ status: "error", message: err });
 			}
 			}
-			logger.success("NEWS_NEWEST", `Successfully got the latest news.`);
-			return cb({ status: 'success', data: news });
+
+			this.log("SUCCESS", "NEWS_NEWEST", `Successfully got the latest news.`, false);
+			return cb({ status: "success", data: { news } });
 		});
 		});
 	},
 	},
 
 
 	/**
 	/**
 	 * Removes a news item
 	 * Removes a news item
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} news - the news object
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} newsId - the id of the news item we want to remove
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	//TODO Pass in an id, not an object
-	//TODO Fix this
-	remove: hooks.adminRequired((session, news, cb, userId) => {
-		db.models.news.remove({ _id: news._id }, err => {
-			if (err) {
-				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${userId}". "${err.message}"`);
-				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
-			} else {
-				cache.pub('news.remove', news);
-				logger.success("NEWS_REMOVE", `Removing news "${news._id}" successful by user "${userId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully removed News' });
+	remove: isAdminRequired(async function remove(session, newsId, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!newsId) return next("Please provide a news item id to update.");
+					return next();
+				},
+
+				next => {
+					newsModel.deleteOne({ _id: newsId }, err => next(err));
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"NEWS_REMOVE",
+						`Removing news "${newsId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", { channel: "news.remove", value: newsId });
+
+				this.log("SUCCESS", "NEWS_REMOVE", `Removing news "${newsId}" successful by user "${session.userId}".`);
+
+				return cb({
+					status: "success",
+					message: "Successfully removed News"
+				});
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Removes a news item
+	 * Updates a news item
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} _id - the news id
-	 * @param {Object} news - the news object
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} newsId - the id of the news item
+	 * @param {object} item - the news item object
+	 * @param {string} item.status - the status of the news e.g. published
+	 * @param {string} item.title - taken from a level-1 heading at the top of the markdown
+	 * @param {string} item.markdown - the markdown that forms the content of the news
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	//TODO Fix this
-	update: hooks.adminRequired((session, _id, news, cb, userId) => {
-		db.models.news.update({ _id }, news, { upsert: true }, err => {
-			if (err) {
-				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${userId}". "${err.message}"`);
-				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
-			} else {
-				cache.pub('news.update', news);
-				logger.success("NEWS_UPDATE", `Updating news "${_id}" successful for user "${userId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully updated News' });
-			}
-		});
-	}),
+	update: isAdminRequired(async function update(session, newsId, item, cb) {
+		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!newsId) return next("Please provide a news item id to update.");
+					return next();
+				},
+
+				next => {
+					newsModel.updateOne({ _id: newsId }, item, { upsert: true }, err => next(err));
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"NEWS_UPDATE",
+						`Updating news item "${newsId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
 
 
-};
+				CacheModule.runJob("PUB", { channel: "news.update", value: { ...item, _id: newsId } });
+
+				this.log(
+					"SUCCESS",
+					"NEWS_UPDATE",
+					`Updating news item "${newsId}" successful for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					message: "Successfully updated news item"
+				});
+			}
+		);
+	})
+};

+ 2025 - 440
backend/logic/actions/playlists.js

@@ -1,571 +1,2156 @@
-'use strict';
-
-const db = require('../db');
-const io = require('../io');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
-const hooks = require('./hooks');
-const async = require('async');
-const playlists = require('../playlists');
-const songs = require('../songs');
-
-cache.sub('playlist.create', playlistId => {
-	playlists.getPlaylist(playlistId, (err, playlist) => {
-		if (!err) {
-			utils.socketsFromUser(playlist.createdBy, (sockets) => {
-				sockets.forEach((socket) => {
-					socket.emit('event:playlist.create', playlist);
-				});
+import async from "async";
+
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const SongsModule = moduleManager.modules.songs;
+const StationsModule = moduleManager.modules.stations;
+const CacheModule = moduleManager.modules.cache;
+const PlaylistsModule = moduleManager.modules.playlists;
+const YouTubeModule = moduleManager.modules.youtube;
+const ActivitiesModule = moduleManager.modules.activities;
+
+CacheModule.runJob("SUB", {
+	channel: "playlist.create",
+	cb: playlist => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: playlist.createdBy }, this).then(sockets =>
+			sockets.forEach(socket => socket.dispatch("event:playlist.created", { data: { playlist } }))
+		);
+
+		if (playlist.privacy === "public")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${playlist.createdBy}.playlists`,
+				args: ["event:playlist.created", { data: { playlist } }]
 			});
 			});
-		}
-	});
-});
 
 
-cache.sub('playlist.delete', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:playlist.delete', res.playlistId);
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.created", { data: { playlist } }]
 		});
 		});
-	});
+	}
 });
 });
 
 
-cache.sub('playlist.moveSongToTop', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:playlist.moveSongToTop', {playlistId: res.playlistId, songId: res.songId});
+CacheModule.runJob("SUB", {
+	channel: "playlist.delete",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.deleted", { data: { playlistId: res.playlistId } });
+			});
 		});
 		});
-	});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `profile.${res.userId}.playlists`,
+			args: ["event:playlist.deleted", { data: { playlistId: res.playlistId } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.deleted", { data: { playlistId: res.playlistId } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "playlist.repositionSong",
+	cb: res => {
+		const { userId, playlistId, song } = res;
+
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }, this).then(sockets =>
+			sockets.forEach(socket =>
+				socket.dispatch("event:playlist.song.repositioned", {
+					data: { playlistId, song }
+				})
+			)
+		);
+	}
 });
 });
 
 
-cache.sub('playlist.moveSongToBottom', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:playlist.moveSongToBottom', {playlistId: res.playlistId, songId: res.songId});
+CacheModule.runJob("SUB", {
+	channel: "playlist.addSong",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.song.added", {
+					data: {
+						playlistId: res.playlistId,
+						song: res.song
+					}
+				});
+			});
 		});
 		});
-	});
+
+		if (res.privacy === "public")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${res.userId}.playlists`,
+				args: [
+					"event:playlist.song.added",
+					{
+						data: {
+							playlistId: res.playlistId,
+							song: res.song
+						}
+					}
+				]
+			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: ["event:admin.playlist.song.added", { data: { playlistId: res.playlistId, song: res.song } }]
+		});
+	}
 });
 });
 
 
-cache.sub('playlist.addSong', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:playlist.addSong', {playlistId: res.playlistId, song: res.song});
+CacheModule.runJob("SUB", {
+	channel: "playlist.removeSong",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.song.removed", {
+					data: {
+						playlistId: res.playlistId,
+						youtubeId: res.youtubeId
+					}
+				});
+			});
 		});
 		});
-	});
+
+		if (res.privacy === "public")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${res.userId}.playlists`,
+				args: [
+					"event:playlist.song.removed",
+					{
+						data: {
+							playlistId: res.playlistId,
+							youtubeId: res.youtubeId
+						}
+					}
+				]
+			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.song.removed",
+				{ data: { playlistId: res.playlistId, youtubeId: res.youtubeId } }
+			]
+		});
+	}
 });
 });
 
 
-cache.sub('playlist.removeSong', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:playlist.removeSong', {playlistId: res.playlistId, songId: res.songId});
+CacheModule.runJob("SUB", {
+	channel: "playlist.updateDisplayName",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.displayName.updated", {
+					data: {
+						playlistId: res.playlistId,
+						displayName: res.displayName
+					}
+				});
+			});
 		});
 		});
-	});
+
+		if (res.privacy === "public")
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${res.userId}.playlists`,
+				args: [
+					"event:playlist.displayName.updated",
+					{
+						data: {
+							playlistId: res.playlistId,
+							displayName: res.displayName
+						}
+					}
+				]
+			});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.displayName.updated",
+				{ data: { playlistId: res.playlistId, displayName: res.displayName } }
+			]
+		});
+	}
 });
 });
 
 
-cache.sub('playlist.updateDisplayName', res => {
-	utils.socketsFromUser(res.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:playlist.updateDisplayName', {playlistId: res.playlistId, displayName: res.displayName});
+CacheModule.runJob("SUB", {
+	channel: "playlist.updatePrivacy",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:playlist.privacy.updated", {
+					data: {
+						playlist: res.playlist
+					}
+				});
+			});
 		});
 		});
-	});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.playlists",
+			args: [
+				"event:admin.playlist.privacy.updated",
+				{ data: { playlistId: res.playlist._id, privacy: res.playlist.privacy } }
+			]
+		});
+
+		if (res.playlist.privacy === "public")
+			return WSModule.runJob("EMIT_TO_ROOM", {
+				room: `profile.${res.userId}.playlists`,
+				args: [
+					"event:playlist.created",
+					{
+						data: {
+							playlist: res.playlist
+						}
+					}
+				]
+			});
+
+		return WSModule.runJob("EMIT_TO_ROOM", {
+			room: `profile.${res.userId}.playlists`,
+			args: [
+				"event:playlist.deleted",
+				{
+					data: {
+						playlistId: res.playlist._id
+					}
+				}
+			]
+		});
+	}
 });
 });
 
 
-let lib = {
+export default {
+	/**
+	 * Gets all playlists
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					playlistModel.find({}).sort({ createdAt: "desc" }).exec(next);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_INDEX", `Indexing playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_INDEX", "Indexing playlists successful.");
+				return cb({ status: "success", data: { playlists } });
+			}
+		);
+	}),
 
 
 	/**
 	/**
-	 * Gets the first song from a private playlist
+	 * Searches through all playlists that can be included in a community station
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are getting the first song from
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} query - the page
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	getFirstSong: hooks.loginRequired((session, playlistId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
-				next(null, playlist.songs[0]);
+	searchCommunity: isLoginRequired(async function searchCommunity(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					PlaylistsModule.runJob("SEARCH", {
+						query,
+						includeUser: true,
+						includeGenre: true,
+						includeArtist: true,
+						includeOwn: true,
+						includeSongs: true,
+						userId: session.userId,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_SEARCH_COMMUNITY", `Searching playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_SEARCH_COMMUNITY", "Searching playlists successful.");
+				return cb({ status: "success", data });
 			}
 			}
-		], (err, song) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: 'Something went wrong when getting the playlist'});
+		);
+	}),
+
+	/**
+	 * Searches through all playlists that can be included in an official station
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} query - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	searchOfficial: isAdminRequired(async function searchOfficial(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					PlaylistsModule.runJob("SEARCH", {
+						query,
+						includeGenre: true,
+						includePrivate: true,
+						includeSongs: true,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_SEARCH_OFFICIAL", `Searching playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_SEARCH_OFFICIAL", "Searching playlists successful.");
+				return cb({ status: "success", data });
 			}
 			}
-			logger.success("PLAYLIST_GET_FIRST_SONG", `Successfully got the first song of playlist "${playlistId}" for user "${userId}".`);
-			cb({
-				status: 'success',
-				song: song
-			});
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Gets all playlists for the user requesting it
+	 * Gets the first song from a private playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are getting the first song from
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	indexForUser: hooks.loginRequired((session, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.find({ createdBy: userId }, next);
+	getFirstSong: isLoginRequired(function getFirstSong(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found.");
+					playlist.songs.sort((a, b) => a.position - b.position);
+					return next(null, playlist.songs[0]);
+				}
+			],
+			async (err, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_GET_FIRST_SONG",
+						`Getting the first song of playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_GET_FIRST_SONG",
+					`Successfully got the first song of playlist "${playlistId}" for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					data: { song }
+				});
 			}
 			}
-		], (err, playlists) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${userId}" failed. "${error}"`);
-				return cb({ status: 'failure', message: error});
+		);
+	}),
+
+	/**
+	 * Gets a list of all the playlists for a specific user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the user id in question
+	 * @param {Function} cb - gets called with the result
+	 */
+	indexForUser: async function indexForUser(session, userId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findById(userId).select({ "preferences.orderOfPlaylists": -1 }).exec(next);
+				},
+
+				({ preferences }, next) => {
+					const { orderOfPlaylists } = preferences;
+
+					const match = {
+						createdBy: userId,
+						type: "user"
+					};
+
+					// if a playlist order exists
+					if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
+
+					playlistModel
+						.aggregate()
+						.match(match)
+						.addFields({
+							weight: { $indexOfArray: [orderOfPlaylists, "$_id"] }
+						})
+						.sort({ weight: 1 })
+						.exec(next);
+				},
+
+				(playlists, next) => {
+					if (session.userId === userId) return next(null, playlists); // user requesting playlists is the owner of the playlists
+
+					const filteredPlaylists = [];
+
+					return async.each(
+						playlists,
+						(playlist, nextPlaylist) => {
+							if (playlist.privacy === "public") filteredPlaylists.push(playlist);
+							return nextPlaylist();
+						},
+						() => next(null, filteredPlaylists)
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_INDEX_FOR_USER",
+						`Indexing playlists for user "${userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "PLAYLIST_INDEX_FOR_USER", `Successfully indexed playlists for user "${userId}".`);
+
+				return cb({
+					status: "success",
+					data: { playlists }
+				});
 			}
 			}
-			logger.success("PLAYLIST_INDEX_FOR_USER", `Successfully indexed playlists for user "${userId}".`);
-			cb({
-				status: 'success',
-				data: playlists
-			});
-		});
+		);
+	},
+
+	/**
+	 * Gets all playlists for the user requesting it
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {boolean} showNonModifiablePlaylists - whether or not to show non modifiable playlists e.g. liked songs
+	 * @param {Function} cb - gets called with the result
+	 */
+	indexMyPlaylists: isLoginRequired(async function indexMyPlaylists(session, showNonModifiablePlaylists, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findById(session.userId).select({ "preferences.orderOfPlaylists": -1 }).exec(next);
+				},
+
+				({ preferences }, next) => {
+					const { orderOfPlaylists } = preferences;
+
+					const match = {
+						createdBy: session.userId,
+						type: "user"
+					};
+
+					// if non modifiable playlists should be shown as well
+					if (!showNonModifiablePlaylists) match.isUserModifiable = true;
+
+					// if a playlist order exists
+					if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
+
+					playlistModel
+						.aggregate()
+						.match(match)
+						.addFields({
+							weight: { $indexOfArray: [orderOfPlaylists, "$_id"] }
+						})
+						.sort({ weight: 1 })
+						.exec(next);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_INDEX_FOR_ME",
+						`Indexing playlists for user "${session.userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_INDEX_FOR_ME",
+					`Successfully indexed playlists for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					data: { playlists }
+				});
+			}
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Creates a new private playlist
 	 * Creates a new private playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} data - the data for the new private playlist
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} data - the data for the new private playlist
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	create: hooks.loginRequired((session, data, cb, userId) => {
-		async.waterfall([
-
-			(next) => {
-				return (data) ? next() : cb({ 'status': 'failure', 'message': 'Invalid data' });
-			},
-
-			(next) => {
-				const { displayName, songs } = data;
-				db.models.playlist.create({
-					_id: utils.generateRandomString(17),//TODO Check if exists
-					displayName,
-					songs,
-					createdBy: userId,
-					createdAt: Date.now()
-				}, next);
-			}
+	create: isLoginRequired(async function create(session, data, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 
-		], (err, playlist) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+		const blacklist = ["liked songs", "likedsongs", "disliked songs", "dislikedsongs"];
+
+		async.waterfall(
+			[
+				next => (data ? next() : cb({ status: "error", message: "Invalid data" })),
+
+				next => {
+					const { displayName, songs, privacy } = data;
+
+					if (blacklist.indexOf(displayName.toLowerCase()) !== -1)
+						return next("That playlist name is blacklisted. Please use a different name.");
+
+					return playlistModel.create(
+						{
+							displayName,
+							songs,
+							privacy,
+							createdBy: session.userId,
+							createdAt: Date.now(),
+							createdFor: null,
+							type: "user"
+						},
+						next
+					);
+				},
+
+				(playlist, next) => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $push: { "preferences.orderOfPlaylists": playlist._id } },
+						err => {
+							if (err) return next(err);
+							return next(null, playlist);
+						}
+					);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_CREATE",
+						`Creating private playlist failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.create",
+					value: playlist
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: playlist.createdBy,
+					type: "playlist__create",
+					payload: {
+						message: `Created playlist <playlistId>${playlist.displayName}</playlistId>`,
+						playlistId: playlist._id
+					}
+				});
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CREATE",
+					`Successfully created private playlist for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully created playlist",
+					data: {
+						playlistId: playlist._id
+					}
+				});
 			}
 			}
-			cache.pub('playlist.create', playlist._id);
-			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${userId}".`);
-			cb({ 'status': 'success', 'message': 'Successfully created playlist' });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Gets a playlist from id
 	 * Gets a playlist from id
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are getting
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are getting
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	getPlaylist: hooks.loginRequired((session, playlistId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
-				next(null, playlist);
+	getPlaylist: function getPlaylist(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist) return next("Playlist not found");
+					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId) {
+						if (session)
+							// check if user requested to get a playlist is an admin
+							return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+								userModel.findOne({ _id: session.userId }, (err, user) => {
+									if (user && user.role === "admin") return next(null, playlist);
+									return next("User unauthorised to view playlist.");
+								});
+							});
+						return next("User unauthorised to view playlist.");
+					}
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_GET",
+						`Getting private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_GET",
+					`Successfully got private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					data: { playlist }
+				});
 			}
 			}
-		], (err, playlist) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+		);
+	},
+
+	/**
+	 * Gets a playlist from station id
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} stationId - the id of the station we are getting
+	 * @param {string} includeSongs - include songs
+	 * @param {Function} cb - gets called with the result
+	 */
+	getPlaylistForStation: function getPlaylist(session, stationId, includeSongs, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_STATION_PLAYLIST", { stationId, includeSongs }, this)
+						.then(response => next(null, response.playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist) return next("Playlist not found");
+					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId) {
+						if (session)
+							// check if user requested to get a playlist is an admin
+							return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+								userModel.findOne({ _id: session.userId }, (err, user) => {
+									if (user && user.role === "admin") return next(null, playlist);
+									return next("User unauthorised to view playlist.");
+								});
+							});
+						return next("User unauthorised to view playlist.");
+					}
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_GET",
+						`Getting playlist for station "${stationId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_GET",
+					`Successfully got playlist for station "${stationId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					data: { playlist }
+				});
 			}
 			}
-			logger.success("PLAYLIST_GET", `Successfully got private playlist "${playlistId}" for user "${userId}".`);
-			console.log(playlist);
-			cb({
-				status: 'success',
-				data: playlist
-			});
-		});
-	}),
+		);
+	},
 
 
-	//TODO Remove this
 	/**
 	/**
-	 * Updates a private playlist
+	 * Shuffles songs in a private playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are updating
-	 * @param {Object} playlist - the new private playlist object
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	update: hooks.loginRequired((session, playlistId, playlist, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.update({ _id: playlistId, createdBy: userId }, playlist, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next)
+	shuffle: isLoginRequired(async function shuffle(session, playlistId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) return next("No playlist id.");
+					return playlistModel.findById(playlistId, next);
+				},
+
+				(playlist, next) => {
+					if (!playlist.isUserModifiable) return next("Playlist cannot be shuffled.");
+
+					return UtilsModule.runJob("SHUFFLE_SONG_POSITIONS", { array: playlist.songs }, this)
+						.then(result => next(null, result.array))
+						.catch(next);
+				},
+
+				(songs, next) => {
+					playlistModel.updateOne({ _id: playlistId }, { $set: { songs } }, { runValidators: true }, next);
+				},
+
+				(res, next) => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_SHUFFLE",
+						`Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_SHUFFLE",
+					`Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully shuffled playlist.",
+					data: { playlist }
+				});
 			}
 			}
-		], (err, playlist) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+		);
+	}),
+
+	/**
+	 * Changes the order (position) of a song in a playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are targeting
+	 * @param {object} song - the song to be repositioned
+	 * @param {string} song.youtubeId - the youtube id of the song being repositioned
+	 * @param {string} song.newIndex - the new position of the song in the playlist
+	 * @param {...any} song.args - any other elements that would be included with a song item in a playlist
+	 * @param {Function} cb - gets called with the result
+	 */
+	repositionSong: isLoginRequired(async function repositionSong(session, playlistId, song, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) return next("Please provide a playlist.");
+					if (!song || !song.youtubeId) return next("You must provide a song to reposition.");
+					return next();
+				},
+
+				// remove song from playlist
+				next => {
+					playlistModel.updateOne(
+						{ _id: playlistId },
+						{ $pull: { songs: { youtubeId: song.youtubeId } } },
+						next
+					);
+				},
+
+				// add song back to playlist (in new position)
+				(res, next) => {
+					playlistModel.updateOne(
+						{ _id: playlistId },
+						{ $push: { songs: { $each: [song], $position: song.newIndex } } },
+						err => next(err)
+					);
+				},
+
+				// update the cache with the new songs positioning
+				next => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_REPOSITION_SONG",
+						`Repositioning song ${song.youtubeId}  for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REPOSITION_SONG",
+					`Successfully repositioned song ${song.youtubeId} for private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.repositionSong",
+					value: {
+						userId: session.userId,
+						playlistId,
+						song
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully repositioned song"
+				});
 			}
 			}
-			logger.success("PLAYLIST_UPDATE", `Successfully updated private playlist "${playlistId}" for user "${userId}".`);
-			cb({
-				status: 'success',
-				data: playlist
-			});
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Adds a song to a private playlist
 	 * Adds a song to a private playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the song we are trying to add
-	 * @param {String} playlistId - the id of the playlist we are adding the song to
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {boolean} isSet - is the song part of a set of songs to be added
+	 * @param {string} youtubeId - the youtube id of the song we are trying to add
+	 * @param {string} playlistId - the id of the playlist we are adding the song to
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	addSongToPlaylist: hooks.loginRequired((session, songId, playlistId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, (err, playlist) => {
-					if (err || !playlist || playlist.createdBy !== userId) return next('Something went wrong when trying to get the playlist');
-
-					async.each(playlist.songs, (song, next) => {
-						if (song._id === songId) return next('That song is already in the playlist');
-						next();
-					}, next);
-				});
-			},
-			(next) => {
-				songs.getSong(songId, (err, song) => {
-					if (err) {
-						utils.getSongFromYouTube(songId, (song) => {
-							next(null, song);
+	addSongToPlaylist: isLoginRequired(async function addSongToPlaylist(session, isSet, youtubeId, playlistId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => {
+							if (!playlist || playlist.createdBy !== session.userId)
+								return next("Something went wrong when trying to get the playlist");
+
+							return async.each(
+								playlist.songs,
+								(song, nextSong) => {
+									if (song.youtubeId === youtubeId)
+										return next("That song is already in the playlist");
+									return nextSong();
+								},
+								err => next(err)
+							);
+						})
+						.catch(next);
+				},
+
+				next => {
+					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+						.then(UserModel => {
+							UserModel.findOne(
+								{ _id: session.userId },
+								{ "preferences.anonymousSongRequests": 1 },
+								next
+							);
+						})
+						.catch(next);
+				},
+
+				(user, next) => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId,
+							userId: user.preferences.anonymousSongRequests ? null : session.userId,
+							automaticallyRequested: true
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, artists, thumbnail, duration, status } = song;
+							next(null, {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								status
+							});
+						})
+						.catch(next);
+				},
+				(newSong, next) => {
+					playlistModel.updateOne(
+						{ _id: playlistId },
+						{ $push: { songs: newSong } },
+						{ runValidators: true },
+						err => {
+							if (err) return next(err);
+							return PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+								.then(playlist => next(null, playlist, newSong))
+								.catch(next);
+						}
+					);
+				}
+			],
+			async (err, playlist, newSong) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_ADD_SONG",
+						`Adding song "${youtubeId}" to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_ADD_SONG",
+					`Successfully added song "${youtubeId}" to private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				if (!isSet && playlist.displayName !== "Liked Songs" && playlist.displayName !== "Disliked Songs") {
+					const songName = newSong.artists
+						? `${newSong.title} by ${newSong.artists.join(", ")}`
+						: newSong.title;
+
+					if (playlist.privacy === "public")
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "playlist__add_song",
+							payload: {
+								message: `Added <youtubeId>${songName}</youtubeId> to playlist <playlistId>${playlist.displayName}</playlistId>`,
+								thumbnail: newSong.thumbnail,
+								playlistId,
+								youtubeId
+							}
 						});
 						});
-					} else {
-						next(null, {
-							_id: songId,
-							title: song.title,
-							duration: song.duration
+				}
+
+				StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
+					.then(response => {
+						response.stationIds.forEach(stationId => {
+							PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
 						});
 						});
+					})
+					.catch();
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.addSong",
+					value: {
+						playlistId: playlist._id,
+						song: newSong,
+						userId: session.userId,
+						privacy: playlist.privacy
 					}
 					}
 				});
 				});
-			},
-			(newSong, next) => {
-				db.models.playlist.update({ _id: playlistId }, { $push: { songs: newSong } }, (err) => {
-					if (err) return next(err);
-					playlists.updatePlaylist(playlistId, (err, playlist) => {
-						next(err, playlist, newSong);
-					});
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully added to the playlist",
+					data: { songs: playlist.songs }
 				});
 				});
 			}
 			}
-		],
-		(err, playlist, newSong) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
-			}
-			logger.success("PLAYLIST_ADD_SONG", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId: userId });
-			return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Adds a set of songs to a private playlist
 	 * Adds a set of songs to a private playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} url - the url of the the YouTube playlist
-	 * @param {String} playlistId - the id of the playlist we are adding the set of songs to
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} url - the url of the the YouTube playlist
+	 * @param {string} playlistId - the id of the playlist we are adding the set of songs to
+	 * @param {boolean} musicOnly - whether to only add music to the playlist
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	addSetToPlaylist: hooks.loginRequired((session, url, playlistId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				utils.getPlaylistFromYouTube(url, songs => {
-					next(null, songs);
-				});
-			},
-			(songs, next) => {
-				let processed = 0;
-				function checkDone() {
-					if (processed === songs.length) next();
-				}
-				for (let s = 0; s < songs.length; s++) {
-					lib.addSongToPlaylist(session, songs[s].contentDetails.videoId, playlistId, () => {
-						processed++;
-						checkDone();
-					});
+	addSetToPlaylist: isLoginRequired(function addSetToPlaylist(session, url, playlistId, musicOnly, cb) {
+		let videosInPlaylistTotal = 0;
+		let songsInPlaylistTotal = 0;
+		let addSongsStats = null;
+
+		const addedSongs = [];
+
+		async.waterfall(
+			[
+				next => {
+					YouTubeModule.runJob("GET_PLAYLIST", { url, musicOnly }, this)
+						.then(res => {
+							if (res.filteredSongs) {
+								videosInPlaylistTotal = res.songs.length;
+								songsInPlaylistTotal = res.filteredSongs.length;
+							} else {
+								songsInPlaylistTotal = videosInPlaylistTotal = res.songs.length;
+							}
+							next(null, res.songs);
+						})
+						.catch(err => {
+							next(err);
+						});
+				},
+				(youtubeIds, next) => {
+					let successful = 0;
+					let failed = 0;
+					let alreadyInPlaylist = 0;
+
+					if (youtubeIds.length === 0) next();
+
+					async.eachLimit(
+						youtubeIds,
+						1,
+						(youtubeId, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "playlists",
+									action: "addSongToPlaylist",
+									args: [true, youtubeId, playlistId]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "success") {
+										successful += 1;
+										addedSongs.push(youtubeId);
+									} else failed += 1;
+									if (res.message === "That song is already in the playlist") alreadyInPlaylist += 1;
+								})
+								.catch(() => {
+									failed += 1;
+								})
+								.finally(() => next());
+						},
+						() => {
+							addSongsStats = { successful, failed, alreadyInPlaylist };
+							next(null);
+						}
+					);
+				},
+
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found.");
+					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_IMPORT",
+						`Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
 				}
 				}
-			},
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
 
 
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
-				next(null, playlist);
-			}
-		], (err, playlist) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+				if (playlist.privacy === "public")
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "playlist__import_playlist",
+						payload: {
+							message: `Imported ${addSongsStats.successful} songs to playlist <playlistId>${playlist.displayName}</playlistId>`,
+							playlistId
+						}
+					});
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_IMPORT",
+					`Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${addSongsStats.successful}, songs failed: ${addSongsStats.failed}, already in playlist: ${addSongsStats.alreadyInPlaylist}.`
+				);
+
+				return cb({
+					status: "success",
+					message: `Playlist has been imported. ${addSongsStats.successful} were added successfully, ${addSongsStats.failed} failed (${addSongsStats.alreadyInPlaylist} were already in the playlist)`,
+					data: {
+						songs: playlist.songs,
+						stats: {
+							videosInPlaylistTotal,
+							songsInPlaylistTotal
+						}
+					}
+				});
 			}
 			}
-			logger.success("PLAYLIST_IMPORT", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${userId}".`);
-			cb({ status: 'success', message: 'Playlist has been successfully imported.', data: playlist.songs });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Removes a song from a private playlist
 	 * Removes a song from a private playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the song we are removing from the private playlist
-	 * @param {String} playlistId - the id of the playlist we are removing the song from
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} youtubeId - the youtube id of the song we are removing from the private playlist
+	 * @param {string} playlistId - the id of the playlist we are removing the song from
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	removeSongFromPlaylist: hooks.loginRequired((session, songId, playlistId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: songId}}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], (err, playlist) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+	removeSongFromPlaylist: isLoginRequired(async function removeSongFromPlaylist(session, youtubeId, playlistId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!youtubeId || typeof youtubeId !== "string") return next("Invalid song id.");
+					if (!playlistId || typeof youtubeId !== "string") return next("Invalid playlist id.");
+					return next();
+				},
+
+				// remove song from playlist
+				next => playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { youtubeId } } }, next),
+
+				// update cache representation of the playlist
+				(res, next) => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
+						.then(response => {
+							response.stationIds.forEach(stationId => {
+								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+							});
+						})
+						.catch();
+
+					SongsModule.runJob("GET_SONG_FROM_YOUTUBE_ID", { youtubeId }, this)
+						.then(res =>
+							next(null, playlist, {
+								title: res.song.title,
+								thumbnail: res.song.thumbnail,
+								artists: res.song.artists
+							})
+						)
+						.catch(() => {
+							YouTubeModule.runJob("GET_SONG", { youtubeId }, this)
+								.then(response => next(null, playlist, response.song))
+								.catch(next);
+						});
+				},
+
+				(playlist, youtubeSong, next) => {
+					const songName = youtubeSong.artists
+						? `${youtubeSong.title} by ${youtubeSong.artists.join(", ")}`
+						: youtubeSong.title;
+
+					if (
+						playlist.displayName !== "Liked Songs" &&
+						playlist.displayName !== "Disliked Songs" &&
+						playlist.privacy === "public"
+					) {
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "playlist__remove_song",
+							payload: {
+								message: `Removed <youtubeId>${songName}</youtubeId> from playlist <playlistId>${playlist.displayName}</playlistId>`,
+								thumbnail: youtubeSong.thumbnail,
+								playlistId,
+								youtubeId
+							}
+						});
+					}
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_REMOVE_SONG",
+						`Removing song "${youtubeId}" from private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REMOVE_SONG",
+					`Successfully removed song "${youtubeId}" from private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.removeSong",
+					value: {
+						playlistId: playlist._id,
+						youtubeId,
+						userId: session.userId,
+						privacy: playlist.privacy
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully removed from playlist",
+					data: { songs: playlist.songs }
+				});
 			}
 			}
-			logger.success("PLAYLIST_REMOVE_SONG", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.removeSong', {playlistId: playlist._id, songId: songId, userId: userId});
-			return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Updates the displayName of a private playlist
 	 * Updates the displayName of a private playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are updating the displayName for
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating the displayName for
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateDisplayName: isLoginRequired(async function updateDisplayName(session, playlistId, displayName, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+					return next(null);
+				},
+
+				next => {
+					playlistModel.updateOne(
+						{ _id: playlistId, createdBy: session.userId },
+						{ $set: { displayName } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_UPDATE_DISPLAY_NAME",
+						`Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_UPDATE_DISPLAY_NAME",
+					`Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updateDisplayName",
+					value: {
+						playlistId,
+						displayName,
+						userId: session.userId,
+						privacy: playlist.privacy
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "playlist__edit_display_name",
+					payload: {
+						message: `Changed display name of playlist <playlistId>${displayName}</playlistId>`,
+						playlistId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes a user's own modifiable user playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are removing
+	 * @param {Function} cb - gets called with the result
+	 */
+	remove: isLoginRequired(async function remove(session, playlistId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (playlist.createdBy !== session.userId) return next("You do not own this playlist.");
+					if (!playlist.isUserModifiable) return next("Playlist cannot be removed.");
+					return next(null, playlist);
+				},
+
+				(playlist, next) => {
+					userModel.updateOne(
+						{ _id: playlist.createdBy },
+						{ $pull: { "preferences.orderOfPlaylists": playlist._id } },
+						err => next(err, playlist)
+					);
+				},
+
+				(playlist, next) => {
+					PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId }, this)
+						.then(() => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_REMOVE",
+						`Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REMOVE",
+					`Successfully removed private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.delete",
+					value: {
+						userId: session.userId,
+						playlistId
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: playlist.createdBy,
+					type: "playlist__remove",
+					payload: {
+						message: `Removed playlist ${playlist.displayName}`
+					}
+				});
+
+				ActivitiesModule.runJob("REMOVE_ACTIVITY_REFERENCES", { type: "playlistId", playlistId });
+
+				return cb({
+					status: "success",
+					message: "Playlist successfully removed"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes a user's modifiable user playlist as an admin
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are removing
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	updateDisplayName: hooks.loginRequired((session, playlistId, displayName, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.update({ _id: playlistId, createdBy: userId }, { $set: { displayName } }, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
+	removeAdmin: isAdminRequired(async function removeAdmin(session, playlistId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist.isUserModifiable) return next("Playlist cannot be removed.");
+					return next(null, playlist);
+				},
+
+				(playlist, next) => {
+					userModel.updateOne(
+						{ _id: playlist.createdBy },
+						{ $pull: { "preferences.orderOfPlaylists": playlist._id } },
+						err => next(err, playlist, playlist.createdBy)
+					);
+				},
+
+				(playlist, playlistCreator, next) => {
+					PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId }, this)
+						.then(() => next(null, playlistCreator))
+						.catch(next);
+				}
+			],
+			async (err, playlistCreator) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_REMOVE_ADMIN",
+						`Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REMOVE_ADMIN",
+					`Successfully removed private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.delete",
+					value: {
+						userId: playlistCreator,
+						playlistId
+					}
+				});
+
+				ActivitiesModule.runJob("REMOVE_ACTIVITY_REFERENCES", { type: "playlistId", playlistId });
+
+				return cb({
+					status: "success",
+					message: "Playlist successfully removed"
+				});
 			}
 			}
-		], (err, playlist) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+		);
+	}),
+
+	/**
+	 * Updates the privacy of a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating the privacy for
+	 * @param {string} privacy - what the new privacy of the playlist should be e.g. public
+	 * @param {Function} cb - gets called with the result
+	 */
+	updatePrivacy: isLoginRequired(async function updatePrivacy(session, playlistId, privacy, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					playlistModel.updateOne(
+						{ _id: playlistId, createdBy: session.userId },
+						{ $set: { privacy } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					if (res.n === 0) next("No user playlist found with that id and owned by you.");
+					else if (res.nModified === 0) next(`Nothing changed, the playlist was already ${privacy}.`);
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					}
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_UPDATE_PRIVACY",
+						`Updating privacy to "${privacy}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_UPDATE_PRIVACY",
+					`Successfully updated privacy to "${privacy}" for private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updatePrivacy",
+					value: {
+						userId: session.userId,
+						playlist
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "playlist__edit_privacy",
+					payload: {
+						message: `Changed privacy of playlist <playlistId>${playlist.displayName}</playlistId> to ${privacy}`,
+						playlistId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully updated"
+				});
 			}
 			}
-			logger.success("PLAYLIST_UPDATE_DISPLAY_NAME", `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.updateDisplayName', {playlistId: playlistId, displayName: displayName, userId: userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Moves a song to the top of the list in a private playlist
+	 * Updates the privacy of a playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are moving the song to the top from
-	 * @param {String} songId - the id of the song we are moving to the top of the list
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating the privacy for
+	 * @param {string} privacy - what the new privacy of the playlist should be e.g. public
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	moveSongToTop: hooks.loginRequired((session, playlistId, songId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
-				async.each(playlist.songs, (song, next) => {
-					if (song._id === songId) return next(song);
-					next();
-				}, (err) => {
-					if (err && err._id) return next(null, err);
-					next('Song not found');
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {_id: songId}}}, (err) => {
-					if (err) return next(err);
-					return next(null, song);
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
-					$push: {
-						songs: {
-							$each: [song],
-							$position: 0
+	updatePrivacyAdmin: isAdminRequired(async function updatePrivacyAdmin(session, playlistId, privacy, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					playlistModel.updateOne({ _id: playlistId }, { $set: { privacy } }, { runValidators: true }, next);
+				},
+
+				(res, next) => {
+					if (res.n === 0) next("No playlist found with that id.");
+					else if (res.nModified === 0) next(`Nothing changed, the playlist was already ${privacy}.`);
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					}
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_UPDATE_PRIVACY_ADMIN",
+						`Updating privacy to "${privacy}" for playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_UPDATE_PRIVACY_ADMIn",
+					`Successfully updated privacy to "${privacy}" for playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				if (playlist.type === "user") {
+					CacheModule.runJob("PUB", {
+						channel: "playlist.updatePrivacy",
+						value: {
+							userId: playlist.createdBy,
+							playlist
 						}
 						}
+					});
+				}
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Deletes all orphaned station playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	deleteOrphanedStationPlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("DELETE_ORPHANED_STATION_PLAYLISTS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
+						`Deleting orphaned station playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLISTS_DELETE_ORPHANED_STATION_PLAYLISTS",
+					"Deleting orphaned station playlists successful."
+				);
+				return cb({ status: "success", message: "Successfully deleted orphaned station playlists." });
+			}
+		);
+	}),
+
+	/**
+	 * Deletes all orphaned genre playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	deleteOrphanedGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("DELETE_ORPHANED_GENRE_PLAYLISTS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
+						`Deleting orphaned genre playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLISTS_DELETE_ORPHANED_GENRE_PLAYLISTS",
+					"Deleting orphaned genre playlists successful."
+				);
+				return cb({ status: "success", message: "Successfully deleted orphaned genre playlists." });
+			}
+		);
+	}),
+
+	/**
+	 * Deletes all orphaned artist playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	deleteOrphanedArtistPlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("DELETE_ORPHANED_ARTIST_PLAYLISTS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLISTS_DELETE_ORPHANED_ARTIST_PLAYLISTS",
+						`Deleting orphaned artist playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"PLAYLISTS_DELETE_ORPHANED_ARTIST_PLAYLISTS",
+					"Deleting orphaned artist playlists successful."
+				);
+				return cb({ status: "success", message: "Successfully deleted orphaned artist playlists." });
+			}
+		);
+	}),
+
+	/**
+	 * Requests orpahned playlist songs
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestOrphanedPlaylistSongs: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("REQUEST_ORPHANED_PLAYLIST_SONGS", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REQUEST_ORPHANED_PLAYLIST_SONGS",
+						`Requesting orphaned playlist songs failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"REQUEST_ORPHANED_PLAYLIST_SONGS",
+					"Requesting orphaned playlist songs was successful."
+				);
+				return cb({ status: "success", message: "Successfully requested orphaned playlist songs." });
+			}
+		);
+	}),
+
+	/**
+	 * Clears and refills a station playlist
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} playlistId - the id of the playlist we are clearing and refilling
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillStationPlaylist: isAdminRequired(async function index(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) next("Please specify a playlist id");
+					else {
+						PlaylistsModule.runJob("CLEAR_AND_REFILL_STATION_PLAYLIST", { playlistId }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
 					}
 					}
-				}, next);
-			},
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 
 
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_STATION_PLAYLIST",
+						`Clearing and refilling station playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_STATION_PLAYLIST",
+					`Successfully cleared and refilled station playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully cleared and refilled"
+				});
 			}
 			}
-		], (err, playlist) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_MOVE_SONG_TO_TOP", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+		);
+	}),
+
+	/**
+	 * Clears and refills a genre playlist
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} playlistId - the id of the playlist we are clearing and refilling
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillGenrePlaylist: isAdminRequired(async function index(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) next("Please specify a playlist id");
+					else {
+						PlaylistsModule.runJob("CLEAR_AND_REFILL_GENRE_PLAYLIST", { playlistId }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_GENRE_PLAYLIST",
+						`Clearing and refilling genre playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_GENRE_PLAYLIST",
+					`Successfully cleared and refilled genre playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully cleared and refilled"
+				});
 			}
 			}
-			logger.success("PLAYLIST_MOVE_SONG_TO_TOP", `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.moveSongToTop', {playlistId, songId, userId: userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Moves a song to the bottom of the list in a private playlist
+	 * Clears and refills a artist playlist
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are moving the song to the bottom from
-	 * @param {String} songId - the id of the song we are moving to the bottom of the list
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} playlistId - the id of the playlist we are clearing and refilling
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	moveSongToBottom: hooks.loginRequired((session, playlistId, songId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
-				async.each(playlist.songs, (song, next) => {
-					if (song._id === songId) return next(song);
-					next();
-				}, (err) => {
-					if (err && err._id) return next(null, err);
-					next('Song not found');
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {_id: songId}}}, (err) => {
-					if (err) return next(err);
-					return next(null, song);
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
-					$push: {
-						songs: song
+	clearAndRefillArtistPlaylist: isAdminRequired(async function index(session, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!playlistId) next("Please specify a playlist id");
+					else {
+						PlaylistsModule.runJob("CLEAR_AND_REFILL_ARTIST_PLAYLIST", { playlistId }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
 					}
 					}
-				}, next);
-			},
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_ARTIST_PLAYLIST",
+						`Clearing and refilling artist playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
 
 
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_ARTIST_PLAYLIST",
+					`Successfully cleared and refilled artist playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully cleared and refilled"
+				});
 			}
 			}
-		], (err, playlist) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+		);
+	}),
+
+	/**
+	 * Clears and refills all station playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillAllStationPlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_ALL_STATION_PLAYLISTS", {}, this)
+						.then(response => {
+							next(null, response.playlists);
+						})
+						.catch(err => {
+							next(err);
+						});
+				},
+
+				(playlists, next) => {
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob(
+								"CLEAR_AND_REFILL_STATION_PLAYLIST",
+								{ playlistId: playlist._id },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
+						`Clearing and refilling all station playlists failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_ALL_STATION_PLAYLISTS",
+					`Successfully cleared and refilled all station playlists for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlists have been successfully cleared and refilled"
+				});
 			}
 			}
-			logger.success("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.moveSongToBottom', {playlistId, songId, userId: userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Removes a song from a private playlist
+	 * Clears and refills all genre playlists
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} playlistId - the id of the playlist we are moving the song to the top from
+	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	remove: hooks.loginRequired((session, playlistId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				playlists.deletePlaylist(playlistId, next);
+	clearAndRefillAllGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_ALL_GENRE_PLAYLISTS", {}, this)
+						.then(response => {
+							next(null, response.playlists);
+						})
+						.catch(err => {
+							next(err);
+						});
+				},
+
+				(playlists, next) => {
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob(
+								"CLEAR_AND_REFILL_GENRE_PLAYLIST",
+								{ playlistId: playlist._id },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_ALL_GENRE_PLAYLISTS",
+						`Clearing and refilling all genre playlists failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_ALL_GENRE_PLAYLISTS",
+					`Successfully cleared and refilled all genre playlists for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlists have been successfully cleared and refilled"
+				});
 			}
 			}
-		], (err) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
-				return cb({ status: 'failure', message: error});
+		);
+	}),
+
+	/**
+	 * Clears and refills all artist playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillAllArtistPlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_ALL_ARTIST_PLAYLISTS", {}, this)
+						.then(response => {
+							next(null, response.playlists);
+						})
+						.catch(err => {
+							next(err);
+						});
+				},
+
+				(playlists, next) => {
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob(
+								"CLEAR_AND_REFILL_ARTIST_PLAYLIST",
+								{ playlistId: playlist._id },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						next
+					);
+				}
+				// next => {
+				// 	// PlaylistsModule.runJob("CREATE_MISSING_ARTIST_PLAYLISTS", {}, null)
+				// 	// 	.then()
+				// 	// 	.catch()
+				// 	// 	.finally(() => {
+				// 	// 		SongsModule.runJob("GET_ALL_ARTISTS", {}, null)
+				// 	// 			.then(response => {
+				// 	// 				const { artists } = response;
+				// 	// 				artists.forEach(artist => {
+				// 	// 					PlaylistsModule.runJob("AUTOFILL_ARTIST_PLAYLIST", { artist }, null).then().catch();
+				// 	// 				});
+				// 	// 			})
+				// 	// 			.catch();
+				// 	// 	});
+				// 	PlaylistsModule.runJob("GET_MISSING_ARTIST_PLAYLISTS", {}, this).then(response => {
+				// 		console.log(response);
+				// 	});
+				// }
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CLEAR_AND_REFILL_ALL_ARTIST_PLAYLISTS",
+						`Clearing and refilling all artist playlists failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CLEAR_AND_REFILL_ALL_ARTIST_PLAYLISTS",
+					`Successfully cleared and refilled all artist playlists for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Playlists have been successfully cleared and refilled"
+				});
 			}
 			}
-			logger.success("PLAYLIST_REMOVE", `Successfully removed private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.delete', {userId: userId, playlistId});
-			return cb({ status: 'success', message: 'Playlist successfully removed' });
-		});
+		);
 	})
 	})
-
 };
 };
-
-module.exports = lib;

+ 215 - 0
backend/logic/actions/punishments.js

@@ -0,0 +1,215 @@
+import async from "async";
+
+import { isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+const PunishmentsModule = moduleManager.modules.punishments;
+
+CacheModule.runJob("SUB", {
+	channel: "ip.ban",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.punishments",
+			args: ["event:admin.punishment.created", { data: { punishment: data.punishment } }]
+		});
+
+		WSModule.runJob("SOCKETS_FROM_IP", { ip: data.ip }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.disconnect(true);
+			});
+		});
+	}
+});
+
+export default {
+	/**
+	 * Gets all punishments
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	index: isAdminRequired(async function index(session, cb) {
+		const punishmentModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "punishment"
+			},
+			this
+		);
+		async.waterfall(
+			[
+				next => {
+					punishmentModel.find({}, next);
+				}
+			],
+			async (err, punishments) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PUNISHMENTS_INDEX", "Indexing punishments successful.");
+				return cb({ status: "success", data: { punishments } });
+			}
+		);
+	}),
+
+	/**
+	 * Gets all punishments for a user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the id of the user
+	 * @param {Function} cb - gets called with the result
+	 */
+	getPunishmentsForUser: isAdminRequired(async function getPunishmentsForUser(session, userId, cb) {
+		const punishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" }, this);
+
+		punishmentModel.find({ type: "banUserId", value: userId }, async (err, punishments) => {
+			if (err) {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+				this.log(
+					"ERROR",
+					"GET_PUNISHMENTS_FOR_USER",
+					`Getting punishments for user ${userId} failed. "${err}"`
+				);
+
+				return cb({ status: "error", message: err });
+			}
+
+			this.log("SUCCESS", "GET_PUNISHMENTS_FOR_USER", `Got punishments for user ${userId} successful.`);
+			return cb({ status: "success", data: { punishments } });
+		});
+	}),
+
+	/**
+	 * Returns a punishment by id
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} punishmentId - the punishment id
+	 * @param {Function} cb - gets called with the result
+	 */
+	findOne: isAdminRequired(async function findOne(session, punishmentId, cb) {
+		const punishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" }, this);
+
+		async.waterfall([next => punishmentModel.findOne({ _id: punishmentId }, next)], async (err, punishment) => {
+			if (err) {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log(
+					"ERROR",
+					"GET_PUNISHMENT_BY_ID",
+					`Getting punishment with id ${punishmentId} failed. "${err}"`
+				);
+				return cb({ status: "error", message: err });
+			}
+			this.log("SUCCESS", "GET_PUNISHMENT_BY_ID", `Got punishment with id ${punishmentId} successful.`);
+			return cb({ status: "success", data: { punishment } });
+		});
+	}),
+
+	/**
+	 * Bans an IP address
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} value - the ip address that is going to be banned
+	 * @param {string} reason - the reason for the ban
+	 * @param {string} expiresAt - the time the ban expires
+	 * @param {Function} cb - gets called with the result
+	 */
+	banIP: isAdminRequired(function banIP(session, value, reason, expiresAt, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!value) return next("You must provide an IP address to ban.");
+					if (!reason) return next("You must provide a reason for the ban.");
+					return next();
+				},
+
+				next => {
+					if (!expiresAt || typeof expiresAt !== "string") return next("Invalid expire date.");
+					const date = new Date();
+					switch (expiresAt) {
+						case "1h":
+							expiresAt = date.setHours(date.getHours() + 1);
+							break;
+						case "12h":
+							expiresAt = date.setHours(date.getHours() + 12);
+							break;
+						case "1d":
+							expiresAt = date.setDate(date.getDate() + 1);
+							break;
+						case "1w":
+							expiresAt = date.setDate(date.getDate() + 7);
+							break;
+						case "1m":
+							expiresAt = date.setMonth(date.getMonth() + 1);
+							break;
+						case "3m":
+							expiresAt = date.setMonth(date.getMonth() + 3);
+							break;
+						case "6m":
+							expiresAt = date.setMonth(date.getMonth() + 6);
+							break;
+						case "1y":
+							expiresAt = date.setFullYear(date.getFullYear() + 1);
+							break;
+						case "never":
+							expiresAt = new Date(3093527980800000);
+							break;
+						default:
+							return next("Invalid expire date.");
+					}
+
+					return next();
+				},
+
+				next => {
+					PunishmentsModule.runJob(
+						"ADD_PUNISHMENT",
+						{
+							type: "banUserIp",
+							value,
+							reason,
+							expiresAt,
+							punishedBy: session.userId
+						},
+						this
+					)
+						.then(punishment => {
+							next(null, punishment);
+						})
+						.catch(next);
+				}
+			],
+			async (err, punishment) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"BAN_IP",
+						`User ${session.userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`
+					);
+					cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"BAN_IP",
+					`User ${session.userId} has successfully banned IP address ${value} with the reason ${reason}.`
+				);
+				CacheModule.runJob("PUB", {
+					channel: "ip.ban",
+					value: { ip: value, punishment }
+				});
+				return cb({
+					status: "success",
+					message: "Successfully banned IP address."
+				});
+			}
+		);
+	})
+};

+ 0 - 201
backend/logic/actions/queueSongs.js

@@ -1,201 +0,0 @@
-'use strict';
-
-const db = require('../db');
-const utils = require('../utils');
-const logger = require('../logger');
-const notifications = require('../notifications');
-const cache = require('../cache');
-const async = require('async');
-const config = require('config');
-const request = require('request');
-const hooks = require('./hooks');
-
-cache.sub('queue.newSong', songId => {
-	db.models.queueSong.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.queue', 'event:admin.queueSong.added', song);
-	});
-});
-
-cache.sub('queue.removedSong', songId => {
-	utils.emitToRoom('admin.queue', 'event:admin.queueSong.removed', songId);
-});
-
-cache.sub('queue.update', songId => {
-	db.models.queueSong.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.queue', 'event:admin.queueSong.updated', song);
-	});
-});
-
-module.exports = {
-
-	/**
-	 * Gets all queuesongs
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	index: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.find({}, next);
-			}
-		], (err, songs) => {
-			if (err) {
-				logger.error("QUEUE_INDEX", `Indexing queuesongs failed. "${err.message}"`);
-				return cb({status: 'failure', message: 'Something went wrong.'});
-			} else {
-				module.exports.getSet(session, 1, result => {
-					logger.success("QUEUE_INDEX", `Indexing queuesongs successful.`);
-					return cb({
-						songs: result,
-						maxLength: songs.length
-					});
-				});
-			}
-		});
-	}),
-
-	getSet: hooks.adminRequired((session, set, cb) => {
-		db.models.queueSong.find({}).limit(50 * set).exec((err, songs) => {
-			if (err) throw err;
-			cb(songs.splice(Math.max(songs.length - 50, 0)));
-		});
-	}),
-
-	/**
-	 * Updates a queuesong
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the queuesong that gets updated
-	 * @param {Object} updatedSong - the object of the updated queueSong
-	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
-	 */
-	update: hooks.adminRequired((session, songId, updatedSong, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.findOne({ _id: songId }, next);
-			},
-
-			(song, next) => {
-				if(!song) return next('Song not found');
-				let updated = false;
-				let $set = {};
-				for (let prop in updatedSong) if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop]; updated = true;
-				if (!updated) return next('No properties changed');
-				db.models.queueSong.update({ _id: songId }, {$set}, next);
-			}
-		], (err) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${userId}. "${err.message}"`);
-				return cb({status: 'failure', message: error});
-			}
-			cache.pub('queue.update', songId);
-			logger.success("QUEUE_UPDATE", `User "${userId}" successfully update queuesong "${songId}".`);
-			return cb({status: 'success', message: 'Successfully updated song.'});
-		});
-	}),
-
-	/**
-	 * Removes a queuesong
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the queuesong that gets removed
-	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
-	 */
-	remove: hooks.adminRequired((session, songId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.remove({ _id: songId }, next);
-			}
-		], (err) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${userId}. "${err.message}"`);
-				return cb({status: 'failure', message: error});
-			}
-			cache.pub('queue.removedSong', songId);
-			logger.success("QUEUE_REMOVE", `User "${userId}" successfully removed queuesong "${songId}".`);
-			return cb({status: 'success', message: 'Successfully updated song.'});
-		});
-	}),
-
-	/**
-	 * Creates a queuesong
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the song that gets added
-	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
-	 */
-	add: hooks.loginRequired((session, songId, cb, userId) => {
-		let requestedAt = Date.now();
-
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.findOne({_id: songId}, next);
-			},
-
-			(song, next) => {
-				if (song) return next('This song is already in the queue.');
-				db.models.song.findOne({_id: songId}, next);
-			},
-
-			// Get YouTube data from id
-			(song, next) => {
-				if (song) return next('This song has already been added.');
-				//TODO Add err object as first param of callback
-				utils.getSongFromYouTube(songId, (song) => {
-					song.artists = [];
-					song.genres = [];
-					song.skipDuration = 0;
-					song.thumbnail = 'empty';
-					song.explicit = false;
-					song.requestedBy = userId;
-					song.requestedAt = requestedAt;
-					next(null, song);
-				});
-			},
-			(newSong, next) => {
-				//TODO Add err object as first param of callback
-				utils.getSongFromSpotify(newSong, (song) => {
-					next(null, song);
-				});
-			},
-			(newSong, next) => {
-				const song = new db.models.queueSong(newSong);
-				song.save(err => {
-					if (err) return next(err);
-					next(null, newSong);
-				});
-			},
-			(newSong, next) => {
-				db.models.user.findOne({ _id: userId }, (err, user) => {
-					if (err) next(err, newSong);
-					else {
-						user.statistics.songsRequested = user.statistics.songsRequested + 1;
-						user.save(err => {
-							if (err) return next(err, newSong);
-							else next(null, newSong);
-						});
-					}
-				});
-			}
-		], (err, newSong) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				return cb({status: 'failure', message: error});
-			}
-			cache.pub('queue.newSong', newSong._id);
-			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
-		});
-	})
-};

+ 463 - 143
backend/logic/actions/reports.js

@@ -1,185 +1,505 @@
-'use strict';
-
-const async = require('async');
-
-const db = require('../db');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
-const hooks = require('./hooks');
-const songs = require('../songs');
-const reportableIssues = [
-	{
-		name: 'Video',
-		reasons: [
-			'Doesn\'t exist',
-			'It\'s private',
-			'It\'s not available in my country'
-		]
-	},
-	{
-		name: 'Title',
-		reasons: [
-			'Incorrect',
-			'Inappropriate'
-		]
-	},
-	{
-		name: 'Duration',
-		reasons: [
-			'Skips too soon',
-			'Skips too late',
-			'Starts too soon',
-			'Skips too late'
-		]
-	},
-	{
-		name: 'Artists',
-		reasons: [
-			'Incorrect',
-			'Inappropriate'
-		]
-	},
-	{
-		name: 'Thumbnail',
-		reasons: [
-			'Incorrect',
-			'Inappropriate',
-			'Doesn\'t exist'
-		]
-	}
-];
+import async from "async";
+
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
 
 
-cache.sub('report.resolve', reportId => {
-	utils.emitToRoom('admin.reports', 'event:admin.report.resolved', reportId);
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const SongsModule = moduleManager.modules.songs;
+const CacheModule = moduleManager.modules.cache;
+const ActivitiesModule = moduleManager.modules.activities;
+
+CacheModule.runJob("SUB", {
+	channel: "report.issue.toggle",
+	cb: data =>
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`edit-song.${data.songId}`, `view-report.${data.reportId}`],
+			args: [
+				"event:admin.report.issue.toggled",
+				{ data: { issueId: data.issueId, reportId: data.reportId, resolved: data.resolved } }
+			]
+		})
 });
 });
 
 
-cache.sub('report.create', report => {
-	utils.emitToRoom('admin.reports', 'event:admin.report.created', report);
+CacheModule.runJob("SUB", {
+	channel: "report.resolve",
+	cb: ({ reportId, songId }) =>
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: ["admin.reports", `edit-song.${songId}`, `view-report.${reportId}`],
+			args: ["event:admin.report.resolved", { data: { reportId } }]
+		})
 });
 });
 
 
-module.exports = {
+CacheModule.runJob("SUB", {
+	channel: "report.create",
+	cb: report => {
+		console.log(report);
+
+		DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+			userModel
+				.findById(report.createdBy)
+				.select({ avatar: -1, name: -1, username: -1 })
+				.exec((err, { avatar, name, username }) => {
+					report.createdBy = {
+						avatar,
+						name,
+						username,
+						_id: report.createdBy
+					};
+
+					WSModule.runJob("EMIT_TO_ROOMS", {
+						rooms: ["admin.reports", `edit-song.${report.song._id}`],
+						args: ["event:admin.report.created", { data: { report } }]
+					});
+				});
+		});
+	}
+});
 
 
+export default {
 	/**
 	/**
-	 * Gets all reports
+	 * Gets all reports that haven't been yet resolved
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	index: hooks.adminRequired((session, cb) => {
+	index: isAdminRequired(async function index(session, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 
-		async.waterfall([
-			(next) => {
-				db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
-			}
-		], (err, reports) => {
-			if (err) {
-				logger.error("REPORTS_INDEX", `Indexing reports failed. "${err.message}"`);
-				return cb({ 'status': 'failure', 'message': 'Something went wrong'});
+		async.waterfall(
+			[
+				next => reportModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next),
+				(_reports, next) => {
+					const reports = [];
+
+					async.each(
+						_reports,
+						(report, cb) => {
+							console.log(typeof report.createdBy);
+
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
+
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
+				}
+			],
+			async (err, reports) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "REPORTS_INDEX", `Indexing reports failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "REPORTS_INDEX", "Indexing reports successful.");
+				return cb({ status: "success", data: { reports } });
 			}
 			}
-			logger.success("REPORTS_INDEX", "Indexing reports successful.");
-			cb({ status: 'success', data: reports });
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Resolves a report
+	 * Gets a specific report
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} reportId - the id of the report that is getting resolved
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} reportId - the id of the report to return
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	resolve: hooks.adminRequired((session, reportId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.report.findOne({ _id: reportId }).exec(next);
-			},
-
-			(report, next) => {
-				if (!report) return next('Report not found.');
-				report.resolved = true;
-				report.save(err => {
-					if (err) next(err.message);
-					else next();
-				});
+	findOne: isAdminRequired(async function findOne(session, reportId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => reportModel.findOne({ _id: reportId }).exec(next),
+				(report, next) =>
+					userModel
+						.findById(report.createdBy)
+						.select({ avatar: -1, name: -1, username: -1 })
+						.exec((err, user) => {
+							if (!user)
+								next(err, {
+									...report._doc,
+									createdBy: { _id: report.createdBy }
+								});
+							else
+								next(err, {
+									...report._doc,
+									createdBy: {
+										avatar: user.avatar,
+										name: user.name,
+										username: user.username,
+										_id: report.createdBy
+									}
+								});
+						})
+			],
+			async (err, report) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "REPORTS_FIND_ONE", `Finding report "${reportId}" successful.`);
+				return cb({ status: "success", data: { report } });
 			}
 			}
-		], (err) => {
-			if (err) {
-				logger.error("REPORTS_RESOLVE", `Resolving report "${reportId}" failed by user "${userId}". Mongo error. "${err.message}"`);
-				return cb({ 'status': 'failure', 'message': 'Something went wrong'});
-			} else {
-				cache.pub('report.resolve', reportId);
-				logger.success("REPORTS_RESOLVE", `User "${userId}" resolved report "${reportId}".`);
-				cb({ status: 'success', message: 'Successfully resolved Report' });
+		);
+	}),
+
+	/**
+	 * Gets all reports for a songId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the id of the song to index reports for
+	 * @param {Function} cb - gets called with the result
+	 */
+	getReportsForSong: isAdminRequired(async function getReportsForSong(session, songId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next =>
+					reportModel.find({ "song._id": songId, resolved: false }).sort({ createdAt: "desc" }).exec(next),
+
+				(_reports, next) => {
+					const reports = [];
+
+					async.each(
+						_reports,
+						(report, cb) => {
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
+
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
+				}
+			],
+			async (err, reports) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" successful.`);
+				return cb({ status: "success", data: { reports } });
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Creates a new report
+	 * Gets all a users reports for a specific songId
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} data - the object of the report data
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the id of the song
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	create: hooks.loginRequired((session, data, cb, userId) => {
-		async.waterfall([
+	myReportsForSong: isLoginRequired(async function myReportsForSong(session, songId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 
-			(next) => {
-				songs.getSong(data.songId, next);
-			},
+		async.waterfall(
+			[
+				next =>
+					reportModel
+						.find({ "song._id": songId, createdBy: session.userId, resolved: false })
+						.sort({ createdAt: "desc" })
+						.exec(next),
 
 
-			(song, next) => {
-				if (!song) return next('Song not found.');
+				(_reports, next) => {
+					const reports = [];
 
 
+					async.each(
+						_reports,
+						(report, cb) => {
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
 
 
-				for (let z = 0; z < data.issues.length; z++) {
-					if (reportableIssues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
-						for (let r = 0; r < reportableIssues.length; r++) {
-							if (reportableIssues[r].reasons.every(reason => data.issues[z].reasons.indexOf(reason) < -1)) {
-								return cb({ status: 'failure', message: 'Invalid data' });
-							}
-						}
-					} else return cb({ status: 'failure', message: 'Invalid data' });
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
+				}
+			],
+			async (err, reports) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MY_REPORTS_FOR_SONG",
+						`Indexing reports of user ${session.userId} for song "${songId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
 				}
 				}
 
 
-				next();
-			},
-			
-			(next) => {
-				let issues = [];
+				this.log(
+					"SUCCESS",
+					"MY_REPORTS_FOR_SONG",
+					`Indexing reports of user ${session.userId} for song "${songId}" successful.`
+				);
+
+				return cb({ status: "success", data: { reports } });
+			}
+		);
+	}),
+
+	/**
+	 * Resolves a report as a whole
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} reportId - the id of the report that is getting resolved
+	 * @param {Function} cb - gets called with the result
+	 */
+	resolve: isAdminRequired(async function resolve(session, reportId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					reportModel.findById(reportId).exec(next);
+				},
 
 
-				for (let r = 0; r < data.issues.length; r++) {
-					if (!data.issues[r].reasons.length <= 0) issues.push(data.issues[r]);
+				(report, next) => {
+					if (!report) return next("Report not found.");
+
+					report.resolved = true;
+
+					return report.save(err => {
+						if (err) return next(err.message);
+						return next(null, report.song._id);
+					});
+				}
+			],
+			async (err, songId) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REPORTS_RESOLVE",
+						`Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
 				}
 				}
 
 
-				data.issues = issues;
+				CacheModule.runJob("PUB", {
+					channel: "report.resolve",
+					value: { reportId, songId }
+				});
 
 
-				next();
-			},
+				this.log("SUCCESS", "REPORTS_RESOLVE", `User "${session.userId}" resolved report "${reportId}".`);
 
 
-			(next) => {
-				data.createdBy = userId;
-				data.createdAt = Date.now();
-				db.models.report.create(data, next);
+				return cb({
+					status: "success",
+					message: "Successfully resolved Report"
+				});
 			}
 			}
+		);
+	}),
+
+	/**
+	 * Resolves/Unresolves an issue within a report
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} reportId - the id of the report that is getting resolved
+	 * @param {string} issueId - the id of the issue within the report
+	 * @param {Function} cb - gets called with the result
+	 */
+	toggleIssue: isAdminRequired(async function toggleIssue(session, reportId, issueId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					reportModel.findById(reportId).exec(next);
+				},
 
 
-		], (err, report) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("REPORTS_CREATE", `Creating report for "${data.songId}" failed by user "${userId}". "${error}"`);
-				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
+				(report, next) => {
+					if (!report) return next("Report not found.");
+
+					const issue = report.issues.find(issue => issue._id.toString() === issueId);
+					issue.resolved = !issue.resolved;
+
+					return report.save(err => {
+						if (err) return next(err.message);
+						return next(null, issue.resolved, report.song._id);
+					});
+				}
+			],
+			async (err, resolved, songId) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REPORTS_TOGGLE_ISSUE",
+						`Resolving an issue within report "${reportId}" failed by user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "report.issue.toggle",
+					value: { reportId, issueId, songId, resolved }
+				});
+
+				this.log(
+					"SUCCESS",
+					"REPORTS_TOGGLE_ISSUE",
+					`User "${session.userId}" resolved an issue in report "${reportId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully resolved issue within report"
+				});
 			}
 			}
-			else {
-				cache.pub('report.create', report);
-				logger.success("REPORTS_CREATE", `User "${userId}" created report for "${data.songId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully created report' });
+		);
+	}),
+
+	/**
+	 * Creates a new report
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} report - the object of the report data
+	 * @param {string} report.youtubeId - the youtube id of the song that is being reported
+	 * @param {Array} report.issues - all issues reported (custom or defined)
+	 * @param {Function} cb - gets called with the result
+	 */
+	create: isLoginRequired(async function create(session, report, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		const { youtubeId } = report;
+
+		async.waterfall(
+			[
+				next => songModel.findOne({ youtubeId }).exec(next),
+
+				(song, next) => {
+					if (!song) return next("Song not found.");
+
+					return SongsModule.runJob("GET_SONG", { songId: song._id }, this)
+						.then(res => next(null, res.song))
+						.catch(next);
+				},
+
+				(song, next) => {
+					if (!song) return next("Song not found.");
+
+					delete report.youtubeId;
+					report.song = {
+						_id: song._id,
+						youtubeId: song.youtubeId
+					};
+
+					return next(null, { title: song.title, artists: song.artists, thumbnail: song.thumbnail });
+				},
+
+				(song, next) =>
+					reportModel.create(
+						{
+							createdBy: session.userId,
+							createdAt: Date.now(),
+							...report
+						},
+						(err, report) => next(err, report, song)
+					)
+			],
+			async (err, report, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REPORTS_CREATE",
+						`Creating report for "${report.song._id}" failed by user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__report",
+					payload: {
+						message: `Created a <reportId>${report._id}</reportId> for song <youtubeId>${song.title}</youtubeId>`,
+						youtubeId: report.song.youtubeId,
+						reportId: report._id,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				CacheModule.runJob("PUB", {
+					channel: "report.create",
+					value: report
+				});
+
+				this.log("SUCCESS", "REPORTS_CREATE", `User "${session.userId}" created report for "${youtubeId}".`);
+
+				return cb({
+					status: "success",
+					message: "Successfully created report"
+				});
 			}
 			}
-		});
+		);
 	})
 	})
-
-};
+};

+ 1316 - 181
backend/logic/actions/songs.js

@@ -1,228 +1,1363 @@
-'use strict';
-
-const db = require('../db');
-const io = require('../io');
-const songs = require('../songs');
-const cache = require('../cache');
-const utils = require('../utils');
-const hooks = require('./hooks');
-const queueSongs = require('./queueSongs');
-
-cache.sub('song.removed', songId => {
-	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
-});
+import async from "async";
 
 
-cache.sub('song.added', songId => {
-	db.models.song.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
-	});
-});
+import { isAdminRequired, isLoginRequired } from "./hooks";
 
 
-cache.sub('song.updated', songId => {
-	db.models.song.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
-	});
-});
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+const SongsModule = moduleManager.modules.songs;
+const ActivitiesModule = moduleManager.modules.activities;
+const YouTubeModule = moduleManager.modules.youtube;
+const PlaylistsModule = moduleManager.modules.playlists;
 
 
-cache.sub('song.like', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.like', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
-	utils.socketsFromUser(data.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:song.newRatings', {songId: data.songId, liked: true, disliked: false});
+CacheModule.runJob("SUB", {
+	channel: "song.updated",
+	cb: async data => {
+		const songModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "song"
 		});
 		});
-	});
-});
 
 
-cache.sub('song.dislike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.dislike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
-	utils.socketsFromUser(data.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: true});
+		songModel.findOne({ _id: data.songId }, (err, song) => {
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: [
+					"import-album",
+					"admin.songs",
+					"admin.unverifiedSongs",
+					"admin.hiddenSongs",
+					`edit-song.${data.songId}`
+				],
+				args: ["event:admin.song.updated", { data: { song, oldStatus: data.oldStatus } }]
+			});
 		});
 		});
-	});
+	}
 });
 });
 
 
-cache.sub('song.unlike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.unlike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
-	utils.socketsFromUser(data.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: false});
+CacheModule.runJob("SUB", {
+	channel: "song.like",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:song.liked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
 		});
 		});
-	});
-});
 
 
-cache.sub('song.undislike', (data) => {
-	utils.emitToRoom(`song.${data.songId}`, 'event:song.undislike', {songId: data.songId, likes: data.likes, dislikes: data.dislikes});
-	utils.socketsFromUser(data.userId, (sockets) => {
-		sockets.forEach((socket) => {
-			socket.emit('event:song.newRatings', {songId: data.songId, liked: false, disliked: false});
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:song.ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: true,
+						disliked: false
+					}
+				});
+			});
 		});
 		});
-	});
+	}
 });
 });
 
 
-module.exports = {
+CacheModule.runJob("SUB", {
+	channel: "song.dislike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:song.disliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
+		});
 
 
-	length: hooks.adminRequired((session, cb) => {
-		db.models.song.find({}, (err, songs) => {
-			if (err) console.error(err);
-			cb(songs.length);
-		})
-	}),
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:song.ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: true
+					}
+				});
+			});
+		});
+	}
+});
 
 
-	getSet: hooks.adminRequired((session, set, cb) => {
-		db.models.song.find({}).limit(15 * set).exec((err, songs) => {
-			if (err) console.error(err);
-			cb(songs.splice(Math.max(songs.length - 15, 0)));
+CacheModule.runJob("SUB", {
+	channel: "song.unlike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:song.unliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
 		});
 		});
-	}),
 
 
-	update: hooks.adminRequired((session, songId, song, cb) => {
-		db.models.song.update({ _id: songId }, song, { upsert: true }, err => {
-			if (err) console.error(err);
-			songs.updateSong(songId, (err, song) => {
-				if (err) console.error(err);
-				cache.pub('song.updated', song._id);
-				cb({ status: 'success', message: 'Song has been successfully updated', data: song });
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:song.ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: false
+					}
+				});
 			});
 			});
 		});
 		});
-	}),
+	}
+});
 
 
-	remove: hooks.adminRequired((session, songId, cb) => {
-		db.models.song.remove({ _id: songId }, (err) => {
-			if (err) return cb({status: 'failure', message: err.message});
-			cache.hdel('songs', songId, (err) => {
-				if (err) return cb({status: 'failure', message: err.message});
-				cache.pub('song.removed', songId);
-				cb({status: 'success', message: 'Successfully removed the song.'});
-			});
+CacheModule.runJob("SUB", {
+	channel: "song.undislike",
+	cb: data => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `song.${data.youtubeId}`,
+			args: [
+				"event:song.undisliked",
+				{
+					data: { youtubeId: data.youtubeId, likes: data.likes, dislikes: data.dislikes }
+				}
+			]
 		});
 		});
-	}),
 
 
-	add: hooks.adminRequired((session, song, cb, userId) => {
-		queueSongs.remove(session, song._id, () => {
-			const newSong = new db.models.song(song);
-			db.models.song.findOne({ _id: song._id }, (err, existingSong) => {
-				if (err) console.error(err);
-				newSong.acceptedBy = userId;
-				newSong.acceptedAt = Date.now();
-				if (!existingSong) newSong.save(err => {
-					if (err) {
-						console.error(err);
-						cb({ status: 'failure', message: 'Something went wrong while adding the song to the queue.' });
-					} else {
-						cache.pub('song.added', song._id);
-						cb({ status: 'success', message: 'Song has been moved from Queue' });
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:song.ratings.updated", {
+					data: {
+						youtubeId: data.youtubeId,
+						liked: false,
+						disliked: false
 					}
 					}
 				});
 				});
 			});
 			});
-			//TODO Check if video is in queue and Add the song to the appropriate stations
 		});
 		});
+	}
+});
+
+export default {
+	/**
+	 * Returns the length of the songs list
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param cb
+	 */
+	length: isAdminRequired(async function length(session, status, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.countDocuments({ status }, next);
+				}
+			],
+			async (err, count) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_LENGTH",
+						`Failed to get length from songs that have the status ${status}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"SONGS_LENGTH",
+					`Got length from songs that have the status ${status} successfully.`
+				);
+				return cb({ status: "success", message: "Successfully got length of songs.", data: { length: count } });
+			}
+		);
 	}),
 	}),
 
 
-	like: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
-			db.models.user.update({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
-				if (!err) {
-					db.models.user.count({"liked": songId}, (err, likes) => {
-						if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-						db.models.user.count({"disliked": songId}, (err, dislikes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-							db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-								songs.updateSong(songId, (err, song) => {});
-								cache.pub('song.like', JSON.stringify({ songId, userId: session.userId, likes: likes, dislikes: dislikes }));
-								return cb({ status: 'success', message: 'You have successfully liked this song.' });
-							});
+	/**
+	 * Gets a set of songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param set - the set number to return
+	 * @param cb
+	 */
+	getSet: isAdminRequired(async function getSet(session, set, status, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel
+						.find({ status })
+						.skip(15 * (set - 1))
+						.limit(15)
+						.exec(next);
+				}
+			],
+			async (err, songs) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_GET_SET",
+						`Failed to get set from songs that have the status ${status}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_GET_SET", `Got set from songs that have the status ${status} successfully.`);
+				return cb({ status: "success", message: "Successfully got set of songs.", data: { songs } });
+			}
+		);
+	}),
+
+	/**
+	 * Updates all songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param cb
+	 */
+	updateAll: isAdminRequired(async function length(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("UPDATE_ALL_SONGS", {}, this)
+						.then(() => {
+							next();
+						})
+						.catch(err => {
+							next(err);
 						});
 						});
-					});
-				} else return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-			});
-		});
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_UPDATE_ALL", `Failed to update all songs. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_UPDATE_ALL", `Updated all songs successfully.`);
+				return cb({ status: "success", message: "Successfully updated all songs." });
+			}
+		);
 	}),
 	}),
 
 
-	dislike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
-			db.models.user.update({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
-				if (!err) {
-					db.models.user.count({"liked": songId}, (err, likes) => {
-						if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-						db.models.user.count({"disliked": songId}, (err, dislikes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-							db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-								songs.updateSong(songId, (err, song) => {});
-								cache.pub('song.dislike', JSON.stringify({ songId, userId: session.userId, likes: likes, dislikes: dislikes }));
-								return cb({ status: 'success', message: 'You have successfully disliked this song.' });
-							});
+	/**
+	 * Gets a song from the Musare song id
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the song id
+	 * @param {Function} cb
+	 */
+	getSongFromSongId: isAdminRequired(function getSongFromSongId(session, songId, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("GET_SONG", { songId }, this)
+						.then(response => next(null, response.song))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_GET_SONG_FROM_MUSARE_ID", `Failed to get song ${songId}. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_GET_SONG_FROM_MUSARE_ID", `Got song ${songId} successfully.`);
+				return cb({ status: "success", data: { song } });
+			}
+		);
+	}),
+
+	/**
+	 * Updates a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the song id
+	 * @param {object} song - the updated song object
+	 * @param {Function} cb
+	 */
+	update: isAdminRequired(async function update(session, songId, song, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		let existingSong = null;
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ _id: songId }, next);
+				},
+
+				(_existingSong, next) => {
+					existingSong = _existingSong;
+					songModel.updateOne({ _id: songId }, song, { runValidators: true }, next);
+				},
+
+				(res, next) => {
+					SongsModule.runJob("UPDATE_SONG", { songId }, this)
+						.then(song => {
+							existingSong.genres
+								.concat(song.genres)
+								.filter((value, index, self) => self.indexOf(value) === index)
+								.forEach(genre => {
+									PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+										.then(() => {})
+										.catch(() => {});
+								});
+
+							existingSong.artists
+								.concat(song.artists)
+								.filter((value, index, self) => self.indexOf(value) === index)
+								.forEach(artist => {
+									PlaylistsModule.runJob("AUTOFILL_ARTIST_PLAYLIST", { artist })
+										.then(() => {})
+										.catch(() => {});
+								});
+
+							next(null, song);
+						})
+						.catch(next);
+				}
+			],
+			async (err, song) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "SONGS_UPDATE", `Successfully updated song "${songId}".`);
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully updated",
+					data: { song }
+				});
+			}
+		);
+	}),
+
+	// /**
+	//  * Removes a song
+	//  *
+	//  * @param session
+	//  * @param songId - the song id
+	//  * @param cb
+	//  */
+	// remove: isAdminRequired(async function remove(session, songId, cb) {
+	// 	const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	// 	let song = null;
+	// 	async.waterfall(
+	// 		[
+	// 			next => {
+	// 				songModel.findOne({ _id: songId }, next);
+	// 			},
+
+	// 			(_song, next) => {
+	// 				song = _song;
+	// 				songModel.deleteOne({ _id: songId }, next);
+	// 			},
+
+	// 			(res, next) => {
+	// 				CacheModule.runJob("HDEL", { table: "songs", key: songId }, this)
+	// 					.then(() => {
+	// 						next();
+	// 					})
+	// 					.catch(next)
+	// 					.finally(() => {
+	// 						song.genres.forEach(genre => {
+	// 							PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+	// 								.then(() => {})
+	// 								.catch(() => {});
+	// 						});
+	// 					});
+	// 			}
+	// 		],
+	// 		async err => {
+	// 			if (err) {
+	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+	// 				this.log("ERROR", "SONGS_REMOVE", `Failed to remove song "${songId}". "${err}"`);
+
+	// 				return cb({ status: "error", message: err });
+	// 			}
+
+	// 			this.log("SUCCESS", "SONGS_REMOVE", `Successfully removed song "${songId}".`);
+
+	// 			if (song.status === "verified") {
+	// 				CacheModule.runJob("PUB", {
+	// 					channel: "song.removedVerifiedSong",
+	// 					value: songId
+	// 				});
+	// 			}
+	// 			if (song.status === "unverified") {
+	// 				CacheModule.runJob("PUB", {
+	// 					channel: "song.removedUnverifiedSong",
+	// 					value: songId
+	// 				});
+	// 			}
+	// 			if (song.status === "hidden") {
+	// 				CacheModule.runJob("PUB", {
+	// 					channel: "song.removedHiddenSong",
+	// 					value: songId
+	// 				});
+	// 			}
+
+	// 			return cb({
+	// 				status: "success",
+	// 				message: "Song has been successfully removed"
+	// 			});
+	// 		}
+	// 	);
+	// }),
+
+	/**
+	 * Searches through official songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} page - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	searchOfficial: isLoginRequired(async function searchOfficial(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					SongsModule.runJob("SEARCH", {
+						query,
+						includeVerified: true,
+						trimmed: true,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
 						});
 						});
-					});
-				} else return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_SEARCH_OFFICIAL", `Searching songs failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_SEARCH_OFFICIAL", "Searching songs successful.");
+				return cb({ status: "success", data });
+			}
+		);
+	}),
+
+	/**
+	 * Requests a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} youtubeId - the youtube id of the song that gets requested
+	 * @param {string} returnSong - returns the simple song
+	 * @param {Function} cb - gets called with the result
+	 */
+	request: isLoginRequired(async function add(session, youtubeId, returnSong, cb) {
+		SongsModule.runJob("REQUEST_SONG", { youtubeId, userId: session.userId }, this)
+			.then(response => {
+				this.log(
+					"SUCCESS",
+					"SONGS_REQUEST",
+					`User "${session.userId}" successfully requested song "${youtubeId}".`
+				);
+				return cb({
+					status: "success",
+					message: "Successfully requested that song",
+					song: returnSong ? response.song : null
+				});
+			})
+			.catch(async _err => {
+				const err = await UtilsModule.runJob("GET_ERROR", { error: _err }, this);
+				this.log(
+					"ERROR",
+					"SONGS_REQUEST",
+					`Requesting song "${youtubeId}" failed for user ${session.userId}. "${err}"`
+				);
+				return cb({ status: "error", message: err, song: returnSong && _err.data ? _err.data.song : null });
 			});
 			});
-		});
 	}),
 	}),
 
 
-	undislike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (user.disliked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not disliked this song.' });
-			db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
-				if (!err) {
-					db.models.user.count({"liked": songId}, (err, likes) => {
-						if (err) return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
-						db.models.user.count({"disliked": songId}, (err, dislikes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
-							db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
-								songs.updateSong(songId, (err, song) => {});
-								cache.pub('song.undislike', JSON.stringify({ songId, userId: session.userId, likes: likes, dislikes: dislikes }));
-								return cb({ status: 'success', message: 'You have successfully undisliked this song.' });
-							});
-						});
-					});
-				} else return cb({ status: 'failure', message: 'Something went wrong while undisliking this song.' });
+	/**
+	 * Hides a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the song id of the song that gets hidden
+	 * @param {Function} cb - gets called with the result
+	 */
+	hide: isLoginRequired(async function add(session, songId, cb) {
+		SongsModule.runJob("HIDE_SONG", { songId }, this)
+			.then(() => {
+				this.log("SUCCESS", "SONGS_HIDE", `User "${session.userId}" successfully hid song "${songId}".`);
+				return cb({
+					status: "success",
+					message: "Successfully hid that song"
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "SONGS_HIDE", `Hiding song "${songId}" failed for user ${session.userId}. "${err}"`);
+				return cb({ status: "error", message: err });
 			});
 			});
-		});
 	}),
 	}),
 
 
-	unlike: hooks.loginRequired((session, songId, cb, userId) => {
-		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
-			db.models.user.update({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
-				if (!err) {
-					db.models.user.count({"liked": songId}, (err, likes) => {
-						if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-						db.models.user.count({"disliked": songId}, (err, dislikes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
-							db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-								songs.updateSong(songId, (err, song) => {});
-								cache.pub('song.unlike', JSON.stringify({ songId, userId: session.userId, likes: likes, dislikes: dislikes }));
-								return cb({ status: 'success', message: 'You have successfully unliked this song.' });
-							});
-						});
-					});
-				} else return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
+	/**
+	 * Unhides a song
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the song id of the song that gets hidden
+	 * @param {Function} cb - gets called with the result
+	 */
+	unhide: isLoginRequired(async function add(session, songId, cb) {
+		SongsModule.runJob("UNHIDE_SONG", { songId }, this)
+			.then(() => {
+				this.log("SUCCESS", "SONGS_UNHIDE", `User "${session.userId}" successfully unhid song "${songId}".`);
+				return cb({
+					status: "success",
+					message: "Successfully unhid that song"
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log(
+					"ERROR",
+					"SONGS_UNHIDE",
+					`Unhiding song "${songId}" failed for user ${session.userId}. "${err}"`
+				);
+				return cb({ status: "error", message: err });
 			});
 			});
-		});
 	}),
 	}),
 
 
-	getOwnSongRatings: hooks.loginRequired(function(session, songId, cb, userId) {
-		db.models.user.findOne({_id: userId}, (err, user) => {
-			if (!err && user) {
+	/**
+	 * Verifies a song
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 */
+	verify: isAdminRequired(async function add(session, songId, cb) {
+		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					SongModel.findOne({ _id: songId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("This song is not in the database.");
+					return next(null, song);
+				},
+
+				(song, next) => {
+					const oldStatus = song.status;
+
+					song.verifiedBy = session.userId;
+					song.verifiedAt = Date.now();
+					song.status = "verified";
+
+					song.save(err => next(err, song, oldStatus));
+				},
+
+				(song, oldStatus, next) => {
+					song.genres.forEach(genre => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					song.artists.forEach(artist => {
+						PlaylistsModule.runJob("AUTOFILL_ARTIST_PLAYLIST", { artist })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					SongsModule.runJob("UPDATE_SONG", { songId: song._id, oldStatus });
+					next(null, song);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_VERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "SONGS_VERIFY", `User "${session.userId}" successfully verified song "${songId}".`);
+
 				return cb({
 				return cb({
-					status: 'success',
-					songId: songId,
-					liked: (user.liked.indexOf(songId) !== -1),
-					disliked: (user.disliked.indexOf(songId) !== -1)
+					status: "success",
+					message: "Song has been verified successfully."
 				});
 				});
-			} else {
+			}
+		);
+		// TODO Check if video is in queue and Add the song to the appropriate stations
+	}),
+
+	/**
+	 * Un-verifies a song
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 */
+	unverify: isAdminRequired(async function add(session, songId, cb) {
+		const SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					SongModel.findOne({ _id: songId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("This song is not in the database.");
+					return next(null, song);
+				},
+
+				(song, next) => {
+					song.status = "unverified";
+					song.save(err => {
+						next(err, song);
+					});
+				},
+
+				(song, next) => {
+					song.genres.forEach(genre => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					song.artists.forEach(artist => {
+						PlaylistsModule.runJob("AUTOFILL_ARTIST_PLAYLIST", { artist })
+							.then(() => {})
+							.catch(() => {});
+					});
+
+					SongsModule.runJob("UPDATE_SONG", { songId, oldStatus: "verified" });
+
+					next(null);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_UNVERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"SONGS_UNVERIFY",
+					`User "${session.userId}" successfully unverified song "${songId}".`
+				);
+
 				return cb({
 				return cb({
-					status: 'failure',
-					message: 'You are not logged in.'
+					status: "success",
+					message: "Song has been unverified successfully."
 				});
 				});
 			}
 			}
-		});
-	})
+		);
+		// TODO Check if video is in queue and Add the song to the appropriate stations
+	}),
+
+	/**
+	 * Requests a set of songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} url - the url of the the YouTube playlist
+	 * @param {boolean} musicOnly - whether to only get music from the playlist
+	 * @param {Function} cb - gets called with the result
+	 */
+	requestSet: isLoginRequired(function requestSet(session, url, musicOnly, returnSongs, cb) {
+		async.waterfall(
+			[
+				next => {
+					YouTubeModule.runJob(
+						"GET_PLAYLIST",
+						{
+							url,
+							musicOnly
+						},
+						this
+					)
+						.then(res => {
+							next(null, res.songs);
+						})
+						.catch(next);
+				},
+				(youtubeIds, next) => {
+					let successful = 0;
+					let songs = {};
+					let failed = 0;
+					let alreadyInDatabase = 0;
+
+					if (youtubeIds.length === 0) next();
+
+					async.eachOfLimit(
+						youtubeIds,
+						1,
+						(youtubeId, index, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "songs",
+									action: "request",
+									args: [youtubeId, returnSongs]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "success") successful += 1;
+									else failed += 1;
+									if (res.message === "This song is already in the database.") alreadyInDatabase += 1;
+									if (res.song) songs[index] = res.song;
+									else songs[index] = null;
+								})
+								.catch(() => {
+									failed += 1;
+								})
+								.finally(() => {
+									next();
+								});
+						},
+						() => {
+							if (returnSongs)
+								songs = Object.keys(songs)
+									.sort()
+									.map(key => songs[key]);
 
 
-};
+							next(null, { successful, failed, alreadyInDatabase, songs });
+						}
+					);
+				}
+			],
+			async (err, response) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REQUEST_SET",
+						`Importing a YouTube playlist to be requested failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"REQUEST_SET",
+					`Successfully imported a YouTube playlist to be requested for user "${session.userId}".`
+				);
+				return cb({
+					status: "success",
+					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+					songs: returnSongs ? response.songs : null
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Likes a song
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	like: isLoginRequired(async function like(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => songModel.findOne({ youtubeId }, next),
+
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null, song);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, user.likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already liked this song.");
+								return next("Unable to add song to the 'Liked Songs' playlist.");
+							}
+
+							return next(null, song, user.dislikedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, dislikedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, { likes, dislikes }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_LIKE",
+						`User "${session.userId}" failed to like song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "song.like",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__like",
+					payload: {
+						message: `Liked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully liked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Dislikes a song
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	dislike: isLoginRequired(async function dislike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ youtubeId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null, song);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already disliked this song.");
+								return next("Unable to add song to the 'Disliked Songs' playlist.");
+							}
+
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, { likes, dislikes }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_DISLIKE",
+						`User "${session.userId}" failed to dislike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "song.dislike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__dislike",
+					payload: {
+						message: `Disliked song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully disliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Undislikes a song
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	undislike: isLoginRequired(async function undislike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ youtubeId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null, song);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, { likes, dislikes }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_UNDISLIKE",
+						`User "${session.userId}" failed to undislike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "song.undislike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__undislike",
+					payload: {
+						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+							", "
+						)}</youtubeId> from your Disliked Songs`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully undisliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Unlikes a song
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+	unlike: isLoginRequired(async function unlike(session, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ youtubeId }, next);
+				},
+
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null, song);
+				},
+
+				(song, next) => userModel.findOne({ _id: session.userId }, (err, user) => next(err, song, user)),
+
+				(song, user, next) => {
+					if (!user) return next("User does not exist.");
+
+					return this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, likedSongsPlaylist, next) => {
+					this.module
+						.runJob(
+							"RUN_ACTION2",
+							{
+								session,
+								namespace: "playlists",
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, likedSongsPlaylist]
+							},
+							this
+						)
+						.then(res => {
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
+						.then(ratings => next(null, song, ratings))
+						.catch(err => next(err));
+				}
+			],
+			async (err, song, { likes, dislikes }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_UNLIKE",
+						`User "${session.userId}" failed to unlike song ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+				CacheModule.runJob("PUB", {
+					channel: "song.unlike",
+					value: JSON.stringify({
+						youtubeId,
+						userId: session.userId,
+						likes,
+						dislikes
+					})
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "song__unlike",
+					payload: {
+						message: `Removed <youtubeId>${song.title} by ${song.artists.join(
+							", "
+						)}</youtubeId> from your Liked Songs`,
+						youtubeId,
+						thumbnail: song.thumbnail
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "You have successfully unliked this song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets song ratings
+	 *
+	 * @param session
+	 * @param songId - the Musare song id
+	 * @param cb
+	 */
+
+	getSongRatings: isLoginRequired(async function getSongRatings(session, songId, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("GET_SONG", { songId }, this)
+						.then(res => next(null, res.song))
+						.catch(next);
+				},
+
+				(song, next) => {
+					next(null, {
+						likes: song.likes,
+						dislikes: song.dislikes
+					});
+				}
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_GET_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${songId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						likes,
+						dislikes
+					}
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Gets user's own song ratings
+	 *
+	 * @param session
+	 * @param youtubeId - the youtube id
+	 * @param cb
+	 */
+
+	getOwnSongRatings: isLoginRequired(async function getOwnSongRatings(session, youtubeId, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		async.waterfall(
+			[
+				next => songModel.findOne({ youtubeId }, next),
+				(song, next) => {
+					if (!song) return next("No song found with that id.");
+					return next(null);
+				},
+
+				next =>
+					playlistModel.findOne(
+						{ createdBy: session.userId, displayName: "Liked Songs" },
+						(err, playlist) => {
+							if (err) return next(err);
+							if (!playlist) return next("'Liked Songs' playlist does not exist.");
+
+							let isLiked = false;
+
+							Object.values(playlist.songs).forEach(song => {
+								// song is found in 'liked songs' playlist
+								if (song.youtubeId === youtubeId) isLiked = true;
+							});
+
+							return next(null, isLiked);
+						}
+					),
+
+				(isLiked, next) =>
+					playlistModel.findOne(
+						{ createdBy: session.userId, displayName: "Disliked Songs" },
+						(err, playlist) => {
+							if (err) return next(err);
+							if (!playlist) return next("'Disliked Songs' playlist does not exist.");
+
+							const ratings = { isLiked, isDisliked: false };
+
+							Object.values(playlist.songs).forEach(song => {
+								// song is found in 'disliked songs' playlist
+								if (song.youtubeId === youtubeId) ratings.isDisliked = true;
+							});
+
+							return next(null, ratings);
+						}
+					)
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_GET_OWN_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${youtubeId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { isLiked, isDisliked } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						youtubeId,
+						liked: isLiked,
+						disliked: isDisliked
+					}
+				});
+			}
+		);
+	})
+};

+ 3871 - 461
backend/logic/actions/stations.js

@@ -1,558 +1,3968 @@
-'use strict';
-
-const async   = require('async'),
-	  request = require('request'),
-	  config  = require('config');
-
-const io = require('../io');
-const db = require('../db');
-const cache = require('../cache');
-const notifications = require('../notifications');
-const utils = require('../utils');
-const stations = require('../stations');
-const songs = require('../songs');
-const hooks = require('./hooks');
-
-cache.sub('station.updatePartyMode', data => {
-	utils.emitToRoom(`station.${data.stationId}`, "event:partyMode.updated", data.partyMode);
+import async from "async";
+import mongoose from "mongoose";
+
+import { isLoginRequired, isOwnerRequired, isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const SongsModule = moduleManager.modules.songs;
+const PlaylistsModule = moduleManager.modules.playlists;
+const CacheModule = moduleManager.modules.cache;
+const NotificationsModule = moduleManager.modules.notifications;
+const StationsModule = moduleManager.modules.stations;
+const ActivitiesModule = moduleManager.modules.activities;
+
+CacheModule.runJob("SUB", {
+	channel: "station.updateUsers",
+	cb: ({ stationId, usersPerStation }) => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.users.updated", { data: { users: usersPerStation } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.updateUserCount",
+	cb: ({ stationId, usersPerStationCount }) => {
+		const count = usersPerStationCount || 0;
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.userCount.updated", { data: { userCount: count } }]
+		});
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(async station => {
+			if (station.privacy === "public")
+				WSModule.runJob("EMIT_TO_ROOM", {
+					room: "home",
+					args: ["event:station.userCount.updated", { data: { stationId, userCount: count } }]
+				});
+			else {
+				const sockets = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", {
+					room: "home"
+				});
+
+				sockets.forEach(async socketId => {
+					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
+					if (!socket) return;
+					const { session } = socket;
+
+					if (session.sessionId) {
+						CacheModule.runJob("HGET", {
+							table: "sessions",
+							key: session.sessionId
+						}).then(session => {
+							if (session)
+								DBModule.runJob(
+									"GET_MODEL",
+									{
+										modelName: "user"
+									},
+									this
+								).then(userModel =>
+									userModel.findOne({ _id: session.userId }, (err, user) => {
+										if (user.role === "admin")
+											socket.dispatch("event:station.userCount.updated", {
+												data: { stationId, count }
+											});
+										else if (station.type === "community" && station.owner === session.userId)
+											socket.dispatch("event:station.userCount.updated", {
+												data: { stationId, count }
+											});
+									})
+								);
+						});
+					}
+				});
+			}
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.queueLockToggled",
+	cb: data => {
+		const { stationId, locked } = data;
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.queue.lock.toggled", { data: { locked } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `manage-station.${stationId}`,
+			args: ["event:station.queue.lock.toggled", { data: { stationId, locked } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.updatePartyMode",
+	cb: data => {
+		const { stationId, partyMode } = data;
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.partyMode.updated", { data: { partyMode } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.partyMode.updated", { data: { stationId, partyMode } }]
+			});
+
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket => {
+					socket.dispatch("event:station.partyMode.updated", { data: { stationId, partyMode } });
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.newPlayMode",
+	cb: data => {
+		const { stationId, playMode } = data;
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.playMode.updated", { data: { playMode } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `manage-station.${stationId}`,
+			args: ["event:station.playMode.updated", { data: { stationId, playMode } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.includedPlaylist",
+	cb: data => {
+		const { stationId, playlistId } = data;
+
+		PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }).then(playlist =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+				args: ["event:station.includedPlaylist", { data: { stationId, playlist } }]
+			})
+		);
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.excludedPlaylist",
+	cb: data => {
+		const { stationId, playlistId } = data;
+
+		PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }).then(playlist =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+				args: ["event:station.excludedPlaylist", { data: { stationId, playlist } }]
+			})
+		);
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.removedIncludedPlaylist",
+	cb: data => {
+		const { stationId, playlistId } = data;
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.removedIncludedPlaylist", { data: { stationId, playlistId } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.removedExcludedPlaylist",
+	cb: data => {
+		const { stationId, playlistId } = data;
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.removedExcludedPlaylist", { data: { stationId, playlistId } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.pause",
+	cb: stationId => {
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.pause", { data: { pausedAt: station.pausedAt } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.pause", { data: { stationId, pausedAt: station.pausedAt } }]
+			});
+
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket => {
+					socket.dispatch("event:station.pause", { data: { stationId } });
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.resume",
+	cb: stationId => {
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.resume", { data: { timePaused: station.timePaused } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.resume", { data: { stationId, timePaused: station.timePaused } }]
+			});
+
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			})
+				.then(response => {
+					const { socketsThatCan } = response;
+					socketsThatCan.forEach(socket => {
+						socket.dispatch("event:station.resume", { data: { stationId } });
+					});
+				})
+				.catch(console.log);
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.privacyUpdate",
+	cb: response => {
+		const { stationId, previousPrivacy } = response;
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			if (previousPrivacy !== station.privacy) {
+				if (station.privacy === "public") {
+					// Station became public
+
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "home",
+						args: ["event:station.created", { data: { station } }]
+					});
+				} else if (previousPrivacy === "public") {
+					// Station became hidden
+
+					StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+						room: `home`,
+						station
+					}).then(response => {
+						const { socketsThatCan, socketsThatCannot } = response;
+						socketsThatCan.forEach(socket => {
+							socket.dispatch("event:station.privacy.updated", {
+								data: { stationId, privacy: station.privacy }
+							});
+						});
+						socketsThatCannot.forEach(socket => {
+							socket.dispatch("event:station.deleted", { data: { stationId } });
+						});
+					});
+				} else {
+					// Station was hidden and is still hidden
+
+					StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+						room: `home`,
+						station
+					}).then(response => {
+						const { socketsThatCan } = response;
+						socketsThatCan.forEach(socket => {
+							socket.dispatch("event:station.privacy.updated", {
+								data: { stationId, privacy: station.privacy }
+							});
+						});
+					});
+				}
+			}
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.privacy.updated", { data: { privacy: station.privacy } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.privacy.updated", { data: { stationId, privacy: station.privacy } }]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.nameUpdate",
+	cb: res => {
+		const { stationId, name } = res;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket =>
+					socket.dispatch("event:station.name.updated", { data: { stationId, name } })
+				);
+			});
+		});
+
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.name.updated", { data: { stationId, name } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.displayNameUpdate",
+	cb: response => {
+		const { stationId, displayName } = response;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station =>
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket =>
+					socket.dispatch("event:station.displayName.updated", { data: { stationId, displayName } })
+				);
+			})
+		);
+
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.displayName.updated", { data: { stationId, displayName } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.descriptionUpdate",
+	cb: response => {
+		const { stationId, description } = response;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station =>
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(response => {
+				const { socketsThatCan } = response;
+				socketsThatCan.forEach(socket =>
+					socket.dispatch("event:station.description.updated", { data: { stationId, description } })
+				);
+			})
+		);
+
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
+			args: ["event:station.description.updated", { data: { stationId, description } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.themeUpdate",
+	cb: res => {
+		const { stationId } = res;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.theme.updated", { data: { theme: station.theme } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.theme.updated", { data: { stationId, theme: station.theme } }]
+			});
+
+			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
+				room: `home`,
+				station
+			}).then(res => {
+				const { socketsThatCan } = res;
+				socketsThatCan.forEach(socket => {
+					socket.dispatch("event:station.theme.updated", { data: { stationId, theme: station.theme } });
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.queueUpdate",
+	cb: stationId => {
+		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `station.${stationId}`,
+				args: ["event:station.queue.updated", { data: { queue: station.queue } }]
+			});
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: `manage-station.${stationId}`,
+				args: ["event:station.queue.updated", { data: { stationId, queue: station.queue } }]
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.repositionSongInQueue",
+	cb: res => {
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${res.stationId}`, `manage-station.${res.stationId}`],
+			args: ["event:station.queue.song.repositioned", { data: { song: res.song } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.voteSkipSong",
+	cb: stationId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.voteSkipSong"]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.remove",
+	cb: stationId => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.deleted"]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `home`,
+			args: ["event:station.deleted", { data: { stationId } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: "admin.stations",
+			args: ["event:admin.station.deleted", { data: { stationId } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "station.create",
+	cb: async stationId => {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+		StationsModule.runJob("INITIALIZE_STATION", { stationId }).then(async res => {
+			const { station } = res;
+			station.userCount = StationsModule.usersPerStationCount[stationId] || 0;
+
+			WSModule.runJob("EMIT_TO_ROOM", {
+				room: "admin.stations",
+				args: ["event:admin.station.created", { data: { station } }]
+			});
+
+			if (station.privacy === "public")
+				WSModule.runJob("EMIT_TO_ROOM", {
+					room: "home",
+					args: ["event:station.created", { data: { station } }]
+				});
+			else {
+				const sockets = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", {
+					room: "home"
+				});
+
+				sockets.forEach(async socketId => {
+					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
+					if (!socket) return;
+					const { session } = socket;
+
+					if (session.sessionId) {
+						CacheModule.runJob("HGET", {
+							table: "sessions",
+							key: session.sessionId
+						}).then(session => {
+							if (session) {
+								userModel.findOne({ _id: session.userId }, (err, user) => {
+									if (user.role === "admin")
+										socket.dispatch("event:station.created", { data: { station } });
+									else if (station.type === "community" && station.owner === session.userId)
+										socket.dispatch("event:station.created", { data: { station } });
+								});
+							}
+						});
+					}
+				});
+			}
+		});
+	}
 });
 });
 
 
-cache.sub('privatePlaylist.selected', data => {
-	utils.emitToRoom(`station.${data.stationId}`, "event:privatePlaylist.selected", data.playlistId);
-});
+export default {
+	/**
+	 * Get a list of all the stations
+	 *
+	 * @param {object} session - user session
+	 * @param {Function} cb - callback
+	 */
+	async index(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+		async.waterfall(
+			[
+				// get array of the ids of the user's favorite stations
+				next => {
+					if (session.userId)
+						return userModel.findById(session.userId).select({ favoriteStations: -1 }).exec(next);
+					return next(null, { favoriteStations: [] });
+				},
+
+				({ favoriteStations }, next) => {
+					CacheModule.runJob("HGETALL", { table: "stations" }, this).then(stations =>
+						next(null, stations, favoriteStations)
+					);
+				},
+
+				(stations, favorited, next) => {
+					const filteredStations = [];
+
+					async.eachLimit(
+						stations,
+						1,
+						(station, nextStation) => {
+							async.waterfall(
+								[
+									callback => {
+										// only relevant if user logged in
+										if (session.userId) {
+											if (favorited.indexOf(station._id) !== -1) station.isFavorited = true;
+											return callback();
+										}
+
+										return callback();
+									},
+
+									callback => {
+										StationsModule.runJob(
+											"CAN_USER_VIEW_STATION",
+											{
+												station,
+												userId: session.userId,
+												hideUnlisted: true
+											},
+											this
+										)
+											.then(exists => callback(null, exists))
+											.catch(callback);
+									}
+								],
+								(err, exists) => {
+									if (err) return this.log("ERROR", "STATIONS_INDEX", err);
+
+									station.userCount = StationsModule.usersPerStationCount[station._id] || 0;
+
+									if (exists) filteredStations.push(station);
+									return nextStation();
+								}
+							);
+						},
+						() => next(null, filteredStations, favorited)
+					);
+				}
+			],
+			async (err, stations, favorited) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_INDEX", `Indexing stations failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_INDEX", `Indexing stations successful.`, false);
+
+				return cb({ status: "success", data: { stations, favorited } });
+			}
+		);
+	},
+
+	/**
+	 * Obtains basic metadata of a station in order to format an activity
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getStationForActivity(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_GET_STATION_FOR_ACTIVITY",
+						`Failed to obtain metadata of station ${stationId} for activity formatting. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"STATIONS_GET_STATION_FOR_ACTIVITY",
+					`Obtained metadata of station ${stationId} for activity formatting successfully.`
+				);
+				return cb({
+					status: "success",
+					data: {
+						title: station.displayName,
+						thumbnail: station.currentSong ? station.currentSong.thumbnail : ""
+					}
+				});
+			}
+		);
+	},
+
+	/**
+	 * Verifies that a station exists from its name
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationName - the station name
+	 * @param {Function} cb - callback
+	 */
+	existsByName(session, stationName, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION_BY_NAME", { stationName }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next(null, false);
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(exists => next(null, exists))
+						.catch(next);
+				}
+			],
+			async (err, exists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATION_EXISTS_BY_NAME",
+						`Checking if station "${stationName}" exists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATION_EXISTS_BY_NAME",
+					`Station "${stationName}" exists successfully.` /* , false */
+				);
+
+				return cb({ status: "success", data: { exists } });
+			}
+		);
+	},
+
+	/**
+	 * Verifies that a station exists from its id
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	existsById(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next(null, false);
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(exists => next(null, exists))
+						.catch(next);
+				}
+			],
+			async (err, exists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATION_EXISTS_BY_ID",
+						`Checking if station "${stationId}" exists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATION_EXISTS_BY_ID",
+					`Station "${stationId}" exists successfully.` /* , false */
+				);
+
+				return cb({ status: "success", data: { exists } });
+			}
+		);
+	},
+
+	/**
+	 * Gets the official playlist for a station
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getPlaylist(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.type !== "official") return next("This is not an official station.");
+					return next();
+				},
+
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "officialPlaylists",
+							key: stationId
+						},
+						this
+					)
+						.then(playlist => {
+							next(null, playlist);
+						})
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist) return next("Playlist not found.");
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_GET_PLAYLIST",
+						`Getting playlist for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_GET_PLAYLIST",
+					`Got playlist for station "${stationId}" successfully.`,
+					false
+				);
+
+				return cb({ status: "success", data: { songs: playlist.songs } });
+			}
+		);
+	},
+
+	/**
+	 * Joins the station by its name
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationIdentifier - the station name or station id
+	 * @param {Function} cb - callback
+	 */
+	join(session, stationIdentifier, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION_BY_NAME", { stationName: stationIdentifier }, this)
+						.then(station => next(null, station))
+						.catch(() =>
+							// station identifier may be using stationid instead
+							StationsModule.runJob("GET_STATION", { stationId: stationIdentifier }, this)
+								.then(station => next(null, station))
+								.catch(next)
+						);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(canView => {
+							if (!canView) next("Not allowed to join station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					WSModule.runJob("SOCKET_JOIN_ROOM", {
+						socketId: session.socketId,
+						room: `station.${station._id}`
+					});
+
+					const data = {
+						_id: station._id,
+						type: station.type,
+						currentSong: station.currentSong,
+						startedAt: station.startedAt,
+						paused: station.paused,
+						timePaused: station.timePaused,
+						pausedAt: station.pausedAt,
+						description: station.description,
+						displayName: station.displayName,
+						name: station.name,
+						privacy: station.privacy,
+						locked: station.locked,
+						partyMode: station.partyMode,
+						playMode: station.playMode,
+						owner: station.owner,
+						includedPlaylists: station.includedPlaylists,
+						excludedPlaylists: station.excludedPlaylists,
+						theme: station.theme
+					};
+
+					StationsModule.userList[session.socketId] = station._id;
+
+					next(null, data);
+				},
+
+				(data, next) => {
+					data = JSON.parse(JSON.stringify(data));
+
+					data.userCount = StationsModule.usersPerStationCount[data._id] || 0;
+					data.users = StationsModule.usersPerStation[data._id] || [];
+
+					if (!data.currentSong || !data.currentSong.title) return next(null, data);
+
+					WSModule.runJob("SOCKET_JOIN_SONG_ROOM", {
+						socketId: session.socketId,
+						room: `song.${data.currentSong.youtubeId}`
+					});
+
+					data.currentSong.skipVotes = data.currentSong.skipVotes.length;
+
+					return SongsModule.runJob(
+						"GET_SONG_FROM_YOUTUBE_ID",
+						{ youtubeId: data.currentSong.youtubeId },
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							if (song) {
+								data.currentSong.likes = song.likes;
+								data.currentSong.dislikes = song.dislikes;
+							} else {
+								data.currentSong.likes = -1;
+								data.currentSong.dislikes = -1;
+							}
+						})
+						.catch(() => {
+							data.currentSong.likes = -1;
+							data.currentSong.dislikes = -1;
+						})
+						.finally(() => next(null, data));
+				},
+
+				(data, next) => {
+					// only relevant if user logged in
+					if (session.userId) {
+						return StationsModule.runJob(
+							"HAS_USER_FAVORITED_STATION",
+							{
+								userId: session.userId,
+								stationId: data._id
+							},
+							this
+						)
+							.then(isStationFavorited => {
+								data.isFavorited = isStationFavorited;
+								return next(null, data);
+							})
+							.catch(err => next(err));
+					}
+
+					return next(null, data);
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_JOIN", `Joining station "${stationIdentifier}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_JOIN", `Joined station "${data._id}" successfully.`);
+				return cb({ status: "success", data });
+			}
+		);
+	},
+
+	/**
+	 * Gets a station by id
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getStationById(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to get station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					const data = {
+						_id: station._id,
+						type: station.type,
+						description: station.description,
+						displayName: station.displayName,
+						name: station.name,
+						privacy: station.privacy,
+						locked: station.locked,
+						partyMode: station.partyMode,
+						playMode: station.playMode,
+						owner: station.owner,
+						theme: station.theme,
+						paused: station.paused,
+						currentSong: station.currentSong
+					};
+
+					next(null, data);
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_STATION_BY_ID", `Getting station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "GET_STATION_BY_ID", `Got station "${stationId}" successfully.`);
+				return cb({ status: "success", data: { station: data } });
+			}
+		);
+	},
+
+	getStationIncludedPlaylistsById(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to get station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					const playlists = [];
+
+					async.eachLimit(
+						station.includedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									playlists.push(playlist);
+									next();
+								})
+								.catch(() => {
+									playlists.push(null);
+									next();
+								});
+						},
+						err => {
+							next(err, playlists);
+						}
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_STATION_INCLUDED_PLAYLISTS_BY_ID",
+						`Getting station "${stationId}"'s included playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"GET_STATION_INCLUDED_PLAYLISTS_BY_ID",
+					`Got station "${stationId}"'s included playlists successfully.`
+				);
+				return cb({ status: "success", data: { playlists } });
+			}
+		);
+	},
+
+	getStationExcludedPlaylistsById(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to get station.");
+							else next(null, station);
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					const playlists = [];
+
+					async.eachLimit(
+						station.excludedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									playlists.push(playlist);
+									next();
+								})
+								.catch(() => {
+									playlists.push(null);
+									next();
+								});
+						},
+						err => {
+							next(err, playlists);
+						}
+					);
+				}
+			],
+			async (err, playlists) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_STATION_EXCLUDED_PLAYLISTS_BY_ID",
+						`Getting station "${stationId}"'s excluded playlists failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"GET_STATION_EXCLUDED_PLAYLISTS_BY_ID",
+					`Got station "${stationId}"'s excluded playlists successfully.`
+				);
+				return cb({ status: "success", data: { playlists } });
+			}
+		);
+	},
+
+	/**
+	 * Toggles if a station is locked
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	toggleLock: isOwnerRequired(async function toggleLock(session, stationId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					stationModel.updateOne({ _id: stationId }, { $set: { locked: !station.locked } }, next);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_LOCKED_STATUS",
+						`Toggling the queue lock for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_LOCKED_STATUS",
+					`Toggled the queue lock for station "${stationId}" successfully to "${station.locked}".`
+				);
+				CacheModule.runJob("PUB", {
+					channel: "station.queueLockToggled",
+					value: {
+						stationId,
+						locked: station.locked
+					}
+				});
+				return cb({ status: "success", data: { locked: station.locked } });
+			}
+		);
+	}),
+
+	/**
+	 * Votes to skip a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	voteSkip: isLoginRequired(async function voteSkip(session, stationId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		let skipVotes = 0;
+		let shouldSkip = false;
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					if (!station.currentSong) return next("There is currently no song to skip.");
+					if (station.currentSong.skipVotes.indexOf(session.userId) !== -1)
+						return next("You have already voted to skip this song.");
+					return next(null, station);
+				},
+
+				(station, next) => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $push: { "currentSong.skipVotes": session.userId } },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return next(null, station);
+				},
+
+				(station, next) => {
+					skipVotes = station.currentSong.skipVotes.length;
+					WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: `station.${stationId}` }, this)
+						.then(sockets => next(null, sockets))
+						.catch(next);
+				},
+
+				(sockets, next) => {
+					if (sockets.length <= skipVotes) {
+						shouldSkip = true;
+						return next();
+					}
+
+					const users = [];
+
+					return async.each(
+						sockets,
+						(socketId, next) => {
+							WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
+								.then(socket => {
+									if (socket && socket.session && socket.session.userId) {
+										if (!users.includes(socket.session.userId)) users.push(socket.session.userId);
+									}
+									return next();
+								})
+								.catch(next);
+						},
+						err => {
+							if (err) return next(err);
+
+							if (users.length <= skipVotes) shouldSkip = true;
+							return next();
+						}
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "STATIONS_VOTE_SKIP", `Vote skipping "${stationId}" successful.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.voteSkipSong",
+					value: stationId
+				});
+
+				if (shouldSkip) {
+					StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+				}
+
+				return cb({
+					status: "success",
+					message: "Successfully voted to skip the song."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Force skips a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	forceSkip: isOwnerRequired(function forceSkip(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return next();
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+				this.log("SUCCESS", "STATIONS_FORCE_SKIP", `Force skipped station "${stationId}" successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully skipped station."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Leaves the user's current station
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - id of station to leave
+	 * @param {Function} cb - callback
+	 */
+	leave(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return next();
+				}
+			],
+			async (err, userCount) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_LEAVE", `Left station "${stationId}" successfully.`);
+
+				WSModule.runJob("SOCKET_LEAVE_ROOM", { socketId: session.socketId, room: `station.${stationId}` });
+				WSModule.runJob("SOCKETS_LEAVE_SONG_ROOMS", { sockets: [session.socketId] });
+
+				delete StationsModule.userList[session.socketId];
+
+				return cb({
+					status: "success",
+					message: "Successfully left station.",
+					data: { userCount }
+				});
+			}
+		);
+	},
+
+	/**
+	 * Updates a station's name
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newName - the new station name
+	 * @param cb
+	 */
+	updateName: isOwnerRequired(async function updateName(session, stationId, newName, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { name: newName } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_NAME",
+						`Updating station "${stationId}" name to "${newName}" failed. "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_NAME",
+					`Updated station "${stationId}" name to "${newName}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.nameUpdate",
+					value: { stationId, name: newName }
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_name",
+					payload: {
+						message: `Changed name of station <stationId>${station.displayName}</stationId> to ${newName}`,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the name."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's display name
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newDisplayName - the new station display name
+	 * @param cb
+	 */
+	updateDisplayName: isOwnerRequired(async function updateDisplayName(session, stationId, newDisplayName, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { displayName: newDisplayName } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					playlistModel.updateOne(
+						{ _id: station.playlist },
+						{ $set: { displayName: `Station - ${station.displayName}` } },
+						err => {
+							next(err, station);
+						}
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_DISPLAY_NAME",
+						`Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_DISPLAY_NAME",
+					`Updated station "${stationId}" displayName to "${newDisplayName}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.displayNameUpdate",
+					value: { stationId, displayName: newDisplayName }
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_display_name",
+					payload: {
+						message: `Changed display name of station <stationId>${newDisplayName}</stationId>`,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the display name."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's description
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newDescription - the new station description
+	 * @param cb
+	 */
+	updateDescription: isOwnerRequired(async function updateDescription(session, stationId, newDescription, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { description: newDescription } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_DESCRIPTION",
+						`Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_DESCRIPTION",
+					`Updated station "${stationId}" description to "${newDescription}" successfully.`
+				);
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_description",
+					payload: {
+						message: `Changed description of station <stationId>${station.displayName}</stationId> to ${newDescription}`,
+						stationId
+					}
+				});
+
+				CacheModule.runJob("PUB", {
+					channel: "station.descriptionUpdate",
+					value: { stationId, description: newDescription }
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the description."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's privacy
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newPrivacy - the new station privacy
+	 * @param cb
+	 */
+	updatePrivacy: isOwnerRequired(async function updatePrivacy(session, stationId, newPrivacy, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		let previousPrivacy = null;
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.findOne({ _id: stationId }, next);
+				},
+
+				(station, next) => {
+					if (!station) next("No station found.");
+					else {
+						previousPrivacy = station.privacy;
+						next();
+					}
+				},
+
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { privacy: newPrivacy } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_PRIVACY",
+						`Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_PRIVACY",
+					`Updated station "${stationId}" privacy to "${newPrivacy}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.privacyUpdate",
+					value: { stationId, previousPrivacy }
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_privacy",
+					payload: {
+						message: `Changed privacy of station <stationId>${station.displayName}</stationId> to ${newPrivacy}`,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the privacy."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's genres
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newGenres - the new station genres
+	 * @param cb
+	 */
+	updateGenres: isOwnerRequired(async function updateGenres(session, stationId, newGenres, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					const playlists = [];
+					async.eachLimit(
+						newGenres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre, includeSongs: false }, this)
+								.then(response => {
+									playlists.push(response.playlist);
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found")
+										next(
+											`The genre playlist for "${genre}" was not found. Please ensure that this genre playlist exists.`
+										);
+									else next(err);
+								});
+						},
+						err => {
+							next(
+								err,
+								station,
+								playlists.map(playlist => playlist._id.toString())
+							);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromExcluded = playlists.filter(
+						playlistId => station.excludedPlaylists.indexOf(playlistId) !== -1
+					);
+					console.log(
+						`playlistsToRemoveFromExcluded: ${playlistsToRemoveFromExcluded.length}`,
+						playlistsToRemoveFromExcluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromIncluded = station.includedPlaylists.filter(
+						playlistId => playlists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToRemoveFromIncluded: ${playlistsToRemoveFromIncluded.length}`,
+						playlistsToRemoveFromIncluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToAddToIncluded = playlists.filter(
+						playlistId => station.includedPlaylists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToAddToIncluded: ${playlistsToAddToIncluded.length}`,
+						playlistsToAddToIncluded
+					);
+
+					async.eachLimit(
+						playlistsToAddToIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err);
+						}
+					);
+				},
+
+				next => {
+					PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err });
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_GENRES",
+						`Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_GENRES",
+					`Updated station "${stationId}" genres to "${newGenres}" successfully.`
+				);
+
+				if (newGenres.length > 0) {
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__edit_genres",
+						payload: {
+							message: `Updated genres of station <stationId>${station.displayName}</stationId> to 
+							${newGenres.join(", ")}`,
+							stationId
+						}
+					});
+				} else {
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__edit_genres",
+						payload: {
+							message: `Removed all genres of station <stationId>${station.displayName}</stationId>`,
+							stationId
+						}
+					});
+				}
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the genres."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's blacklisted genres
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newBlacklistedGenres - the new station blacklisted genres
+	 * @param cb
+	 */
+	updateBlacklistedGenres: isOwnerRequired(async function updateBlacklistedGenres(
+		session,
+		stationId,
+		newBlacklistedGenres,
+		cb
+	) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					const playlists = [];
+					async.eachLimit(
+						newBlacklistedGenres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre, includeSongs: false }, this)
+								.then(response => {
+									playlists.push(response.playlist);
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found")
+										next(
+											`The genre playlist for "${genre}" was not found. Please ensure that this genre playlist exists.`
+										);
+									else next(err);
+								});
+						},
+						err => {
+							next(
+								err,
+								station,
+								playlists.map(playlist => playlist._id.toString())
+							);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromIncluded = playlists.filter(
+						playlistId => station.includedPlaylists.indexOf(playlistId) !== -1
+					);
+					console.log(
+						`playlistsToRemoveFromIncluded: ${playlistsToRemoveFromIncluded.length}`,
+						playlistsToRemoveFromIncluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromIncluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToRemoveFromExcluded = station.excludedPlaylists.filter(
+						playlistId => playlists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToRemoveFromExcluded: ${playlistsToRemoveFromExcluded.length}`,
+						playlistsToRemoveFromExcluded
+					);
+
+					async.eachLimit(
+						playlistsToRemoveFromExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, station, playlists);
+						}
+					);
+				},
+
+				(station, playlists, next) => {
+					const playlistsToAddToExcluded = playlists.filter(
+						playlistId => station.excludedPlaylists.indexOf(playlistId) === -1
+					);
+					console.log(
+						`playlistsToAddToExcluded: ${playlistsToAddToExcluded.length}`,
+						playlistsToAddToExcluded
+					);
+
+					async.eachLimit(
+						playlistsToAddToExcluded,
+						1,
+						(playlistId, next) => {
+							StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+								.then(() => {
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err);
+						}
+					);
+				},
+
+				next => {
+					PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_BLACKLISTED_GENRES",
+						`Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_BLACKLISTED_GENRES",
+					`Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`
+				);
+
+				if (newBlacklistedGenres.length > 0) {
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__edit_blacklisted_genres",
+						payload: {
+							message: `Updated blacklisted genres of station <stationId>${
+								station.displayName
+							}</stationId> to ${newBlacklistedGenres.join(", ")}`,
+							stationId
+						}
+					});
+				} else {
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__edit_blacklisted_genres",
+						payload: {
+							message: `Removed all blacklisted genres of station <stationId>${station.displayName}</stationId>`,
+							stationId
+						}
+					});
+				}
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the blacklisted genres."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's party mode
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newPartyMode - the new station party mode
+	 * @param cb
+	 */
+	updatePartyMode: isOwnerRequired(async function updatePartyMode(session, stationId, newPartyMode, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.partyMode === newPartyMode)
+						return next(`The party mode was already ${newPartyMode ? "enabled." : "disabled."}`);
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { partyMode: newPartyMode, queue: [] } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "station.queueUpdate",
+						value: stationId
+					})
+						.then(() => {})
+						.catch(next);
+
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_PARTY_MODE",
+						`Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_PARTY_MODE",
+					`Updated station "${stationId}" party mode to "${newPartyMode}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.updatePartyMode",
+					value: {
+						stationId,
+						partyMode: newPartyMode
+					}
+				});
+
+				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the party mode."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's play mode
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newPlayMode - the new station play mode
+	 * @param cb
+	 */
+	updatePlayMode: isOwnerRequired(async function updatePartyMode(session, stationId, newPlayMode, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.newPlayMode === newPlayMode) return next(`The play mode was already ${newPlayMode}`);
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { playMode: newPlayMode, queue: [] } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "station.queueUpdate",
+						value: stationId
+					})
+						.then()
+						.catch();
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_PLAY_MODE",
+						`Updating station "${stationId}" play mode to "${newPlayMode}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_PLAY_MODE",
+					`Updated station "${stationId}" play mode to "${newPlayMode}" successfully.`
+				);
+				CacheModule.runJob("PUB", {
+					channel: "station.newPlayMode",
+					value: {
+						stationId,
+						playMode: newPlayMode
+					}
+				});
+				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+				return cb({
+					status: "success",
+					message: "Successfully updated the play mode."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a station's theme
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newTheme - the new station theme
+	 * @param cb
+	 */
+	updateTheme: isOwnerRequired(async function updateTheme(session, stationId, newTheme, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.theme === newTheme) return next("No change in theme.");
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { theme: newTheme } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_UPDATE_THEME",
+						`Updating station "${stationId}" theme to "${newTheme}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_UPDATE_THEME",
+					`Updated station "${stationId}" theme to "${newTheme}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.themeUpdate",
+					value: { stationId }
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__edit_theme",
+					payload: {
+						message: `Changed theme of station <stationId>${station.displayName}</stationId> to ${newTheme}`,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully updated the theme."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Pauses a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	pause: isOwnerRequired(async function pause(session, stationId, cb) {
+		const stationModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "station"
+			},
+			this
+		);
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.paused) return next("That station was already paused.");
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{ $set: { paused: true, pausedAt: Date.now() } },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "STATIONS_PAUSE", `Paused station "${stationId}" successfully.`);
+				CacheModule.runJob("PUB", {
+					channel: "station.pause",
+					value: stationId
+				});
+				NotificationsModule.runJob("UNSCHEDULE", {
+					name: `stations.nextSong?id=${stationId}`
+				});
+				return cb({
+					status: "success",
+					message: "Successfully paused."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Resumes a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	resume: isOwnerRequired(async function resume(session, stationId, cb) {
+		const stationModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "station"
+			},
+			this
+		);
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (!station.paused) return next("That station is not paused.");
+					station.timePaused += Date.now() - station.pausedAt;
+					return stationModel.updateOne(
+						{ _id: stationId },
+						{
+							$set: { paused: false },
+							$inc: { timePaused: Date.now() - station.pausedAt }
+						},
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "STATIONS_RESUME", `Resuming station "${stationId}" successfully.`);
+				CacheModule.runJob("PUB", {
+					channel: "station.resume",
+					value: stationId
+				});
+				return cb({
+					status: "success",
+					message: "Successfully resumed."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	remove: isOwnerRequired(async function remove(session, stationId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					stationModel.findById(stationId, (err, station) => {
+						if (err) return next(err);
+						return next(null, station);
+					});
+				},
+
+				(station, next) => {
+					stationModel.deleteOne({ _id: stationId }, err => next(err, station));
+				},
+
+				(station, next) => {
+					CacheModule.runJob("HDEL", { table: "stations", key: stationId }, this)
+						.then(() => next(null, station))
+						.catch(next);
+				},
+
+				// remove the playlist for the station
+				(station, next) => {
+					if (station.playlist)
+						PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: station.playlist })
+							.then(() => {})
+							.catch(next);
+					next(null, station);
+				},
+
+				// remove reference to the station id in any array of a user's favorite stations
+				(station, next) => {
+					userModel.updateMany(
+						{ favoriteStations: stationId },
+						{ $pull: { favoriteStations: stationId } },
+						err => next(err, station)
+					);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "STATIONS_REMOVE", `Removing station "${stationId}" successfully.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.remove",
+					value: stationId
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__remove",
+					payload: { message: `Removed a station named ${station.displayName}` }
+				});
+
+				ActivitiesModule.runJob("REMOVE_ACTIVITY_REFERENCES", { type: "stationId", stationId });
+
+				return cb({
+					status: "success",
+					message: "Successfully removed."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Create a station
+	 *
+	 * @param session
+	 * @param data - the station data
+	 * @param cb
+	 */
+	create: isLoginRequired(async function create(session, data, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		data.name = data.name.toLowerCase();
+
+		const blacklist = [
+			"country",
+			"edm",
+			"musare",
+			"hip-hop",
+			"rap",
+			"top-hits",
+			"todays-hits",
+			"old-school",
+			"christmas",
+			"about",
+			"support",
+			"staff",
+			"help",
+			"news",
+			"terms",
+			"privacy",
+			"profile",
+			"c",
+			"community",
+			"tos",
+			"login",
+			"register",
+			"p",
+			"official",
+			"o",
+			"trap",
+			"faq",
+			"team",
+			"donate",
+			"buy",
+			"shop",
+			"forums",
+			"explore",
+			"settings",
+			"admin",
+			"auth",
+			"reset_password",
+			"backend",
+			"api",
+			"songs",
+			"playlists",
+			"playlist"
+		];
+
+		async.waterfall(
+			[
+				next => {
+					if (!data) return next("Invalid data.");
+					return next();
+				},
+
+				next => {
+					stationModel.findOne(
+						{
+							$or: [{ name: data.name }, { displayName: new RegExp(`^${data.displayName}$`, "i") }]
+						},
+						next
+					);
+				},
+
+				// eslint-disable-next-line consistent-return
+				(station, next) => {
+					this.log(station);
+
+					if (station) return next("A station with that name or display name already exists.");
+					const { name, displayName, description, /* playlist, */ type, genres, blacklistedGenres } = data;
+					const stationId = mongoose.Types.ObjectId();
+
+					if (type === "official") {
+						return userModel.findOne({ _id: session.userId }, (err, user) => {
+							if (err) return next(err);
+							if (!user) return next("User not found.");
+							if (user.role !== "admin") return next("Admin required.");
+
+							return async.waterfall(
+								[
+									next => {
+										const playlists = [];
+										async.eachLimit(
+											genres,
+											1,
+											(genre, next) => {
+												PlaylistsModule.runJob(
+													"GET_GENRE_PLAYLIST",
+													{ genre, includeSongs: false },
+													this
+												)
+													.then(response => {
+														playlists.push(response.playlist);
+														next();
+													})
+													.catch(err => {
+														next(
+															`An error occurred when trying to get genre playlist for genre ${genre}. Error: ${err}.`
+														);
+													});
+											},
+											err => {
+												next(
+													err,
+													playlists.map(playlist => playlist._id.toString())
+												);
+											}
+										);
+									},
+
+									(genrePlaylistIds, next) => {
+										const playlists = [];
+										async.eachLimit(
+											blacklistedGenres,
+											1,
+											(genre, next) => {
+												PlaylistsModule.runJob(
+													"GET_GENRE_PLAYLIST",
+													{ genre, includeSongs: false },
+													this
+												)
+													.then(response => {
+														playlists.push(response.playlist);
+														next();
+													})
+													.catch(err => {
+														next(
+															`An error occurred when trying to get genre playlist for genre ${genre}. Error: ${err}.`
+														);
+													});
+											},
+											err => {
+												next(
+													err,
+													genrePlaylistIds,
+													playlists.map(playlist => playlist._id.toString())
+												);
+											}
+										);
+									},
+
+									(genrePlaylistIds, blacklistedGenrePlaylistIds, next) => {
+										const duplicateGenre =
+											genrePlaylistIds.length !== new Set(genrePlaylistIds).size;
+										const duplicateBlacklistedGenre =
+											genrePlaylistIds.length !== new Set(genrePlaylistIds).size;
+										const duplicateCross =
+											genrePlaylistIds.length + blacklistedGenrePlaylistIds.length !==
+											new Set([...genrePlaylistIds, ...blacklistedGenrePlaylistIds]).size;
+										if (duplicateGenre)
+											return next("You cannot have the same genre included twice.");
+										if (duplicateBlacklistedGenre)
+											return next("You cannot have the same blacklisted genre included twice.");
+										if (duplicateCross)
+											return next(
+												"You cannot have the same genre included and blacklisted at the same time."
+											);
+										return next(null, genrePlaylistIds, blacklistedGenrePlaylistIds);
+									}
+								],
+								(err, genrePlaylistIds, blacklistedGenrePlaylistIds) => {
+									if (err) return next(err);
+									return playlistModel.create(
+										{
+											isUserModifiable: false,
+											displayName: `Station - ${displayName}`,
+											songs: [],
+											createdBy: "Musare",
+											createdFor: `${stationId}`,
+											createdAt: Date.now(),
+											type: "station"
+										},
+
+										(err, playlist) => {
+											if (err) next(err);
+											else {
+												stationModel.create(
+													{
+														_id: stationId,
+														name,
+														displayName,
+														description,
+														type,
+														privacy: "private",
+														playlist: playlist._id,
+														currentSong: null,
+														partyMode: false,
+														playMode: "random"
+													},
+													(err, station) => {
+														next(
+															err,
+															station,
+															genrePlaylistIds,
+															blacklistedGenrePlaylistIds
+														);
+													}
+												);
+											}
+										}
+									);
+								}
+							);
+						});
+					}
+					if (type === "community") {
+						if (blacklist.indexOf(name) !== -1)
+							return next("That name is blacklisted. Please use a different name.");
+						return playlistModel.create(
+							{
+								isUserModifiable: false,
+								displayName: `Station - ${name}`,
+								songs: [],
+								createdBy: session.userId,
+								createdFor: `${stationId}`,
+								createdAt: Date.now(),
+								type: "station"
+							},
+
+							(err, playlist) => {
+								if (err) next(err);
+								else {
+									stationModel.create(
+										{
+											_id: stationId,
+											name,
+											displayName,
+											description,
+											playlist: playlist._id,
+											type,
+											privacy: "private",
+											owner: session.userId,
+											queue: [],
+											currentSong: null,
+											partyMode: true,
+											playMode: "random"
+										},
+										(err, station) => {
+											next(err, station, null, null);
+										}
+									);
+								}
+							}
+						);
+					}
+				},
+
+				(station, genrePlaylistIds, blacklistedGenrePlaylistIds, next) => {
+					if (station.type !== "official") return next(null, station);
+
+					const stationId = station._id;
+
+					return async.waterfall(
+						[
+							next => {
+								async.eachLimit(
+									genrePlaylistIds,
+									1,
+									(playlistId, next) => {
+										StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+											.then(() => next())
+											.catch(next);
+									},
+									next
+								);
+							},
+
+							next => {
+								async.eachLimit(
+									blacklistedGenrePlaylistIds,
+									1,
+									(playlistId, next) => {
+										StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+											.then(() => next())
+											.catch(next);
+									},
+									next
+								);
+							},
+
+							next => {
+								PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+								next();
+							}
+						],
+						async err => {
+							if (err) {
+								err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+								this.log(
+									"ERROR",
+									"STATIONS_CREATE",
+									`Created station ${stationId} successfully, but an error occurred during playing including/excluding. Error: ${err}`
+								);
+							}
+							next(null, station, err);
+						}
+					);
+				}
+			],
+			async (err, station, extraError) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_CREATE", `Creating station failed. "${err}"`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "STATIONS_CREATE", `Created station "${station._id}" successfully.`);
+
+					CacheModule.runJob("PUB", {
+						channel: "station.create",
+						value: station._id
+					});
+
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "station__create",
+						payload: {
+							message: `Created a station named <stationId>${station.displayName}</stationId>`,
+							stationId: station._id
+						}
+					});
+
+					if (!extraError) {
+						cb({
+							status: "success",
+							message: "Successfully created station."
+						});
+					} else {
+						cb({
+							status: "success",
+							message: `Successfully created station, but with one error at the end: ${extraError}`
+						});
+					}
+				}
+			}
+		);
+	}),
+
+	/**
+	 * Adds song to station queue
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param youtubeId - the song id
+	 * @param cb
+	 */
+	addToQueue: isLoginRequired(async function addToQueue(session, stationId, youtubeId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const stationModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "station"
+			},
+			this
+		);
+
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (!station.partyMode) return next("Station is not in party mode.");
+
+					if (station.locked) {
+						return userModel.findOne({ _id: session.userId }, (err, user) => {
+							if (user.role !== "admin" && station.owner !== session.userId)
+								return next("Only owners and admins can add songs to a locked queue.");
+							return next(null, station);
+						});
+					}
+
+					return next(null, station);
+				},
+
+				(station, next) => {
+					if (station.type !== "community") return next("That station is not a community station.");
+
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
+
+				(station, next) => {
+					if (station.currentSong && station.currentSong.youtubeId === youtubeId)
+						return next("That song is currently playing.");
+
+					return async.each(
+						station.queue,
+						(queueSong, next) => {
+							if (queueSong.youtubeId === youtubeId) return next("That song is already in the queue.");
+							return next();
+						},
+						err => next(err, station)
+					);
+				},
+
+				(station, next) => {
+					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+						.then(UserModel => {
+							UserModel.findOne(
+								{ _id: session.userId },
+								{ "preferences.anonymousSongRequests": 1 },
+								(err, user) => next(err, station, user)
+							);
+						})
+						.catch(next);
+				},
+
+				(station, user, next) => {
+					SongsModule.runJob(
+						"ENSURE_SONG_EXISTS_BY_YOUTUBE_ID",
+						{
+							youtubeId,
+							userId: user.preferences.anonymousSongRequests ? null : session.userId,
+							automaticallyRequested: true
+						},
+						this
+					)
+						.then(response => {
+							const { song } = response;
+							const { _id, title, skipDuration, artists, thumbnail, duration, status } = song;
+							next(
+								null,
+								{
+									_id,
+									youtubeId,
+									title,
+									skipDuration,
+									artists,
+									thumbnail,
+									duration,
+									status
+								},
+								station
+							);
+						})
+						.catch(next);
+				},
+
+				(song, station, next) => {
+					const excludedPlaylists = [];
+					async.eachLimit(
+						station.excludedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									excludedPlaylists.push(playlist);
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, song, station, excludedPlaylists);
+						}
+					);
+				},
+
+				(song, station, excludedPlaylists, next) => {
+					const excludedSongs = excludedPlaylists
+						.flatMap(excludedPlaylist => excludedPlaylist.songs)
+						.reduce(
+							(items, item) =>
+								items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+							[]
+						);
+
+					if (excludedSongs.find(excludedSong => excludedSong._id.toString() === song._id.toString()))
+						next("That song is in an excluded playlist and cannot be played.");
+					else next(null, song, station);
+				},
+
+				(song, station, next) => {
+					song.requestedBy = session.userId;
+					song.requestedAt = Date.now();
+					return next(null, song);
+				},
 
 
-cache.sub('station.pause', stationId => {
-	utils.emitToRoom(`station.${stationId}`, "event:stations.pause");
-});
+				// (song, station, next) => {
+				// 	song.requestedBy = session.userId;
+				// 	song.requestedAt = Date.now();
+				// 	let totalDuration = 0;
+				// 	station.queue.forEach(song => {
+				// 		totalDuration += song.duration;
+				// 	});
+				// 	if (totalDuration >= 3600 * 3) return next("The max length of the queue is 3 hours.");
+				// 	return next(null, song, station);
+				// },
 
 
-cache.sub('station.resume', stationId => {
-	stations.getStation(stationId, (err, station) => {
-		utils.emitToRoom(`station.${stationId}`, "event:stations.resume", { timePaused: station.timePaused });
-	});
-});
+				// (song, station, next) => {
+				// 	if (station.queue.length === 0) return next(null, song, station);
+				// 	let totalDuration = 0;
+				// 	const userId = station.queue[station.queue.length - 1].requestedBy;
+				// 	station.queue.forEach(song => {
+				// 		if (userId === song.requestedBy) {
+				// 			totalDuration += song.duration;
+				// 		}
+				// 	});
 
 
-cache.sub('station.queueUpdate', stationId => {
-	stations.getStation(stationId, (err, station) => {
-		if (!err) utils.emitToRoom(`station.${stationId}`, "event:queue.update", station.queue);
-	});
-});
+				// 	if (totalDuration >= 900) return next("The max length of songs per user is 15 minutes.");
+				// 	return next(null, song, station);
+				// },
 
 
-cache.sub('station.voteSkipSong', stationId => {
-	utils.emitToRoom(`station.${stationId}`, "event:song.voteSkipSong");
-});
+				// (song, station, next) => {
+				// 	if (station.queue.length === 0) return next(null, song);
+				// 	let totalSongs = 0;
+				// 	const userId = station.queue[station.queue.length - 1].requestedBy;
+				// 	station.queue.forEach(song => {
+				// 		if (userId === song.requestedBy) {
+				// 			totalSongs += 1;
+				// 		}
+				// 	});
 
 
-cache.sub('station.remove', stationId => {
-	utils.emitToRoom('admin.stations', 'event:admin.station.removed', stationId);
-});
+				// 	if (totalSongs <= 2) return next(null, song);
+				// 	if (totalSongs > 3)
+				// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
+				// 	if (
+				// 		station.queue[station.queue.length - 2].requestedBy !== userId ||
+				// 		station.queue[station.queue.length - 3] !== userId
+				// 	)
+				// 		return next("The max amount of songs per user is 3, and only 2 in a row is allowed.");
 
 
-cache.sub('station.create', stationId => {
-	stations.initializeStation(stationId, (err, station) => {
-		if (err) console.error(err);
-		utils.emitToRoom('admin.stations', 'event:admin.station.added', station);
-		// TODO If community, check if on whitelist
-		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
-		else {
-			let sockets = utils.getRoomSockets('home');
-			for (let socketId in sockets) {
-				let socket = sockets[socketId];
-				let session = sockets[socketId].session;
-				if (session.sessionId) {
-					cache.hget('sessions', session.sessionId, (err, session) => {
-						if (!err && session) {
-							db.models.user.findOne({_id: session.userId}, (err, user) => {
-								if (user.role === 'admin') socket.emit("event:stations.created", station);
-								else if (station.type === "community" && station.owner === session.userId) socket.emit("event:stations.created", station);
-							});
-						}
-					});
+				// 	return next(null, song);
+				// },
+
+				(song, next) => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $push: { queue: song } },
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
 				}
 				}
-			}
-		}
-	});
-});
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_ADD_SONG_TO_QUEUE",
+						`Adding song "${youtubeId}" to station "${stationId}" queue failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_ADD_SONG_TO_QUEUE",
+					`Added song "${youtubeId}" to station "${stationId}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.queueUpdate",
+					value: stationId
+				});
 
 
-module.exports = {
+				return cb({
+					status: "success",
+					message: "Successfully added song to queue."
+				});
+			}
+		);
+	}),
 
 
 	/**
 	/**
-	 * Get a list of all the stations
+	 * Removes song from station queue
 	 *
 	 *
 	 * @param session
 	 * @param session
+	 * @param stationId - the station id
+	 * @param youtubeId - the youtube id
 	 * @param cb
 	 * @param cb
-	 * @return {{ status: String, stations: Array }}
 	 */
 	 */
-	index: (session, cb) => {
-		cache.hgetall('stations', (err, stations) => {
+	removeFromQueue: isOwnerRequired(async function removeFromQueue(session, stationId, youtubeId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 
 
-			if (err && err !== true) {
-				return cb({
-					status: 'error',
-					message: 'An error occurred while obtaining the stations'
-				});
-			}
-
-			let arr = [];
-			let done = 0;
-			for (let prop in stations) {
-				// TODO If community, check if on whitelist
-				let station = stations[prop];
-				if (station.privacy === 'public') add(true, station);
-				else if (!session.sessionId) add(false);
-				else {
-					cache.hget('sessions', session.sessionId, (err, session) => {
-						if (err || !session) {
-							add(false);
-						} else {
-							db.models.user.findOne({_id: session.userId}, (err, user) => {
-								if (err || !user) add(false);
-								else if (user.role === 'admin') add(true, station);
-								else if (station.type === 'official') add(false);
-								else if (station.owner === session.userId) add(true, station);
-								else add(false);
-							});
+		async.waterfall(
+			[
+				next => {
+					if (!youtubeId) return next("Invalid youtube id.");
+					return StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+
+					return async.each(
+						station.queue,
+						(queueSong, next) => {
+							if (queueSong.youtubeId === youtubeId) return next(true);
+							return next();
+						},
+						err => {
+							if (err === true) return next();
+							return next("Song is not currently in the queue.");
 						}
 						}
-					});
+					);
+				},
+
+				next => {
+					stationModel.updateOne({ _id: stationId }, { $pull: { queue: { youtubeId } } }, next);
+				},
+
+				(res, next) => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
 				}
 				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REMOVE_SONG_TO_QUEUE",
+						`Removing song "${youtubeId}" from station "${stationId}" queue failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REMOVE_SONG_TO_QUEUE",
+					`Removed song "${youtubeId}" from station "${stationId}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.queueUpdate",
+					value: stationId
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully removed song from queue."
+				});
 			}
 			}
+		);
+	}),
+
+	/**
+	 * Gets the queue from a station
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getQueue(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return next(null, station);
+				},
+
+				(station, next) => {
+					StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
 
 
-			function add(add, station) {
-				if (add) arr.push(station);
-				done++;
-				if (done === Object.keys(stations).length) {
-					cb({ status: 'success', stations: arr });
+				(station, next) => next(null, station.queue)
+			],
+			async (err, queue) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_GET_QUEUE",
+						`Getting queue for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
 				}
 				}
+
+				this.log("SUCCESS", "STATIONS_GET_QUEUE", `Got queue for station "${stationId}" successfully.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully got queue.",
+					data: { queue }
+				});
 			}
 			}
-		});
+		);
 	},
 	},
 
 
-	find: (session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) cb({ status: 'error', message: err });
-			else if (station) cb({ status: 'success', data: station });
-		});
-	},
+	/**
+	 * Reposition a song in station queue
+	 *
+	 * @param {object} session - user session
+	 * @param {object} song - contains details about the song that is to be repositioned
+	 * @param {string} song.youtubeId - the youtube id of the song
+	 * @param {number} song.newIndex - the new position for the song in the queue
+	 * @param {number} song.oldIndex - the old position of the song in the queue
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	repositionSongInQueue: isOwnerRequired(async function repositionQueue(session, stationId, song, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
 
 
-	getPlaylist: (session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when getting the station.' });
-			if (!station) return cb({ status: 'failure', message: 'Station not found..' });
-			if (station.type === 'official') {
-				cache.hget("officialPlaylists", stationId, (err, playlist) => {
-					if (err) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist.' });
-					if (!playlist) return cb({ status: 'failure', message: 'Playlist not found.' });
-					cb({ status: 'success', data: playlist.songs })
-				})
-			} else cb({ status: 'failure', message: 'This is not an official station.' })
-		});
-	},
+		async.waterfall(
+			[
+				next => {
+					if (!song || !song.youtubeId) return next("You must provide a song to reposition.");
+					return next();
+				},
+
+				// remove song from queue
+				next => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $pull: { queue: { youtubeId: song.youtubeId } } },
+						next
+					);
+				},
+
+				// add song back to queue (in new position)
+				(res, next) => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $push: { queue: { $each: [song], $position: song.newIndex } } },
+						err => next(err)
+					);
+				},
+
+				// update the cache representation of the station
+				next => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REPOSITION_SONG_IN_QUEUE",
+						`Repositioning song ${song.youtubeId} in queue of station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REPOSITION_SONG_IN_QUEUE",
+					`Repositioned song ${song.youtubeId} in queue of station "${stationId}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.repositionSongInQueue",
+					value: {
+						song: {
+							youtubeId: song.youtubeId,
+							oldIndex: song.oldIndex,
+							newIndex: song.newIndex
+						},
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully repositioned song in queue."
+				});
+			}
+		);
+	}),
 
 
 	/**
 	/**
-	 * Joins the station by its id
+	 * Includes a playlist in a station
 	 *
 	 *
 	 * @param session
 	 * @param session
 	 * @param stationId - the station id
 	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
 	 * @param cb
 	 * @param cb
-	 * @return {{ status: String, userCount: Integer }}
 	 */
 	 */
-	join: (session, stationId, cb) => {
+	includePlaylist: isOwnerRequired(async function includePlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
 
 
-		stations.getStation(stationId, (err, station) => {
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.includedPlaylists.indexOf(playlistId) !== -1)
+						return next("That playlist is already included.");
+					if (station.playMode === "sequential" && station.includedPlaylists.length > 0)
+						return next("Error: Only 1 playlist can be included in sequential play mode.");
+					return next();
+				},
 
 
-			if (err && err !== true) return cb({ status: 'error', message: 'An error occurred while joining the station' });
+				next => {
+					StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_INCLUDE_PLAYLIST",
+						`Including playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
 
 
-			if (station) {
+				this.log(
+					"SUCCESS",
+					"STATIONS_INCLUDE_PLAYLIST",
+					`Including playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
 
 
-				if (station.privacy !== 'private') joinStation();
-				else {
-					// TODO If community, check if on whitelist
-					if (!session.userId) return cb({ status: 'error', message: 'An error occurred while joining the station1' });
-					db.models.user.findOne({_id: session.userId}, (err, user) => {
-						if (err || !user) return cb({ status: 'error', message: 'An error occurred while joining the station2' });
-						if (user.role === 'admin') return joinStation();
-						if (station.type === 'official') return cb({ status: 'error', message: 'An error occurred while joining the station3' });
-						if (station.owner === session.userId) return joinStation();
-						return cb({ status: 'error', message: 'An error occurred while joining the station4' });
-					});
-				}
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
 
 
-				function joinStation() {
-					utils.socketJoinRoom(session.socketId, `station.${stationId}`);
-					if (station.currentSong) {
-						utils.socketJoinSongRoom(session.socketId, `song.${station.currentSong._id}`);
-						//TODO Emit to cache, listen on cache
-						songs.getSong(station.currentSong._id, (err, song) => {
-							if (!err && song) {
-								station.currentSong.likes = song.likes;
-								station.currentSong.dislikes = song.dislikes;
-							} else {
-								station.currentSong.likes = -1;
-								station.currentSong.dislikes = -1;
-							}
-							station.currentSong.skipVotes = station.currentSong.skipVotes.length;
-							cb({
-								status: 'success',
-								data: {
-									type: station.type,
-									currentSong: station.currentSong,
-									startedAt: station.startedAt,
-									paused: station.paused,
-									timePaused: station.timePaused,
-									description: station.description,
-									displayName: station.displayName,
-									privacy: station.privacy,
-									partyMode: station.partyMode,
-									owner: station.owner,
-									privatePlaylist: station.privatePlaylist
-								}
-							});
-						});
-					} else {
-						cb({
-							status: 'success',
-							data: {
-								type: station.type,
-								currentSong: null,
-								startedAt: station.startedAt,
-								paused: station.paused,
-								timePaused: station.timePaused,
-								description: station.description,
-								displayName: station.displayName,
-								privacy: station.privacy,
-								partyMode: station.partyMode,
-								owner: station.owner,
-								privatePlaylist: station.privatePlaylist
-							}
-						});
+				CacheModule.runJob("PUB", {
+					channel: "station.includedPlaylist",
+					value: {
+						playlistId,
+						stationId
 					}
 					}
-				}
-			} else {
-				cb({ status: 'failure', message: `That station doesn't exist` });
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully included playlist."
+				});
 			}
 			}
-		});
-	},
+		);
+	}),
 
 
 	/**
 	/**
-	 * Skips the users current station
+	 * Remove included a playlist from a station
 	 *
 	 *
 	 * @param session
 	 * @param session
 	 * @param stationId - the station id
 	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
 	 * @param cb
 	 * @param cb
 	 */
 	 */
-	voteSkip: hooks.loginRequired((session, stationId, cb, userId) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-			if (!station.currentSong) return cb({ status: 'failure', message: 'There is currently no song to skip.' });
-			if (station.currentSong.skipVotes.indexOf(userId) !== -1) return cb({ status: 'failure', message: 'You have already voted to skip this song.' });
-			db.models.station.update({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, (err) => {
-				if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-				stations.updateStation(stationId, (err, station) => {
-					cache.pub('station.voteSkipSong', stationId);
-					if (station.currentSong && station.currentSong.skipVotes.length >= 3) stations.skipStation(stationId)();
-					cb({ status: 'success', message: 'Successfully voted to skip the song.' });
-				})
-			});
-		});
-	}),
+	removeIncludedPlaylist: isOwnerRequired(async function removeIncludedPlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
 
 
-	forceSkip: hooks.ownerRequired((session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.includedPlaylists.indexOf(playlistId) === -1)
+						return next("That playlist is not included.");
+					return next();
+				},
 
 
-			if (err && err !== true) {
-				return cb({ status: 'error', message: 'An error occurred while skipping the station' });
-			}
+				next => {
+					StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REMOVE_INCLUDED_PLAYLIST",
+						`Removing included playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
 
 
-			if (station) {
-				notifications.unschedule(`stations.nextSong?id=${stationId}`);
-				//notifications.schedule(`stations.nextSong?id=${stationId}`, 100);
-				stations.skipStation(stationId)();
-			}
-			else {
-				cb({ status: 'failure', message: `That station doesn't exist` });
+				this.log(
+					"SUCCESS",
+					"STATIONS_REMOVE_INCLUDED_PLAYLIST",
+					`Removing included playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				CacheModule.runJob("PUB", {
+					channel: "station.removedIncludedPlaylist",
+					value: {
+						playlistId,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully removed included playlist."
+				});
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Leaves the users current station
+	 * Excludes a playlist in a station
 	 *
 	 *
 	 * @param session
 	 * @param session
-	 * @param stationId
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
 	 * @param cb
 	 * @param cb
-	 * @return {{ status: String, userCount: Integer }}
 	 */
 	 */
-	leave: (session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
+	excludePlaylist: isOwnerRequired(async function excludePlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
 
 
-			if (err && err !== true) {
-				return cb({ status: 'error', message: 'An error occurred while leaving the station' });
-			}
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.excludedPlaylists.indexOf(playlistId) !== -1)
+						return next("That playlist is already excluded.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_EXCLUDE_PLAYLIST",
+						`Excluding playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_EXCLUDE_PLAYLIST",
+					`Excluding playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
 
 
-			if (session) session.stationId = null;
-			else if (station) {
-				cache.client.hincrby('station.userCounts', stationId, -1, (err, userCount) => {
-					if (err) return cb({ status: 'error', message: 'An error occurred while leaving the station' });
-					utils.socketLeaveRooms(session);
-					cb({ status: 'success', userCount });
+				CacheModule.runJob("PUB", {
+					channel: "station.excludedPlaylist",
+					value: {
+						playlistId,
+						stationId
+					}
 				});
 				});
-			} else {
-				cb({ status: 'failure', message: `That station doesn't exist, it may have been deleted` });
-			}
-		});
-	},
 
 
-	updateDisplayName: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
-		db.models.station.update({_id: stationId}, {$set: {displayName: newDisplayName}}, (err) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-			stations.updateStation(stationId, () => {
-				//TODO Pub/sub for displayName change
-				cb({ status: 'success', message: 'Successfully updated the display name.' });
-			})
-		});
+				return cb({
+					status: "success",
+					message: "Successfully excluded playlist."
+				});
+			}
+		);
 	}),
 	}),
 
 
-	updateDescription: hooks.ownerRequired((session, stationId, newDescription, cb) => {
-		db.models.station.update({_id: stationId}, {$set: {description: newDescription}}, (err) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-			stations.updateStation(stationId, () => {
-				//TODO Pub/sub for description change
-				cb({ status: 'success', message: 'Successfully updated the description.' });
-			})
-		});
-	}),
+	/**
+	 * Remove excluded a playlist from a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	removeExcludedPlaylist: isOwnerRequired(async function removeExcludedPlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
 
 
-	updatePrivacy: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
-		db.models.station.update({_id: stationId}, {$set: {privacy: newPrivacy}}, (err) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-			stations.updateStation(stationId, () => {
-				//TODO Pub/sub for privacy change
-				cb({ status: 'success', message: 'Successfully updated the privacy.' });
-			})
-		});
-	}),
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.excludedPlaylists.indexOf(playlistId) === -1)
+						return next("That playlist is not excluded.");
+					return next();
+				},
 
 
-	updatePartyMode: hooks.ownerRequired((session, stationId, newPartyMode, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb({ status: 'failure', message: err });
-			if (station.partyMode === newPartyMode) return cb({ status: 'failure', message: 'The party mode was already ' + ((newPartyMode) ? 'enabled.' : 'disabled.') });
-			db.models.station.update({_id: stationId}, {$set: {partyMode: newPartyMode}}, (err) => {
-				if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the station.' });
-				stations.updateStation(stationId, () => {
-					//TODO Pub/sub for privacy change
-					cache.pub('station.updatePartyMode', {stationId: stationId, partyMode: newPartyMode});
-					stations.skipStation(stationId)();
-					cb({ status: 'success', message: 'Successfully updated the party mode.' });
-				})
-			});
-		});
+				next => {
+					StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REMOVE_EXCLUDED_PLAYLIST",
+						`Removing excluded playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REMOVE_EXCLUDED_PLAYLIST",
+					`Removing excluded playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				CacheModule.runJob("PUB", {
+					channel: "station.removedExcludedPlaylist",
+					value: {
+						playlistId,
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully removed excluded playlist."
+				});
+			}
+		);
 	}),
 	}),
 
 
-	pause: hooks.ownerRequired((session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err && err !== true) {
-				return cb({ status: 'error', message: 'An error occurred while pausing the station' });
-			} else if (station) {
-				if (!station.paused) {
-					station.paused = true;
-					station.pausedAt = Date.now();
-					db.models.station.update({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, () => {
-						if (err) return cb({ status: 'failure', message: 'An error occurred while pausing the station.' });
-						stations.updateStation(stationId, () => {
-							cache.pub('station.pause', stationId);
-							notifications.unschedule(`stations.nextSong?id=${stationId}`);
-							cb({ status: 'success' });
-						});
-					});
-				} else {
-					cb({ status: 'failure', message: 'That station was already paused.' });
+	/**
+	 * Selects a private playlist for a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the private playlist id
+	 * @param cb
+	 */
+	selectPrivatePlaylist: isOwnerRequired(async function selectPrivatePlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.type !== "community") return next("Station is not a community station.");
+					if (station.includedPlaylists.indexOf(playlistId) !== -1)
+						return next("That playlist is already included.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
 				}
 				}
-				cb({ status: 'success' });
-			} else {
-				cb({ status: 'failure', message: `That station doesn't exist, it may have been deleted` });
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_SELECT_PRIVATE_PLAYLIST",
+						`Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_SELECT_PRIVATE_PLAYLIST",
+					`Selected private playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				return cb({
+					status: "success",
+					message: "Successfully selected playlist."
+				});
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
-	resume: hooks.ownerRequired((session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err && err !== true) return cb({ status: 'error', message: 'An error occurred while resuming the station' });
-			else if (station) {
-				if (station.paused) {
-					station.paused = false;
-					station.timePaused += (Date.now() - station.pausedAt);
-					db.models.station.update({ _id: stationId }, { $set: { paused: false }, $inc: { timePaused: Date.now() - station.pausedAt } }, () => {
-						stations.updateStation(stationId, (err, station) => {
-							cache.pub('station.resume', stationId);
-							cb({ status: 'success' });
-						});
-					});
-				} else cb({ status: 'failure', message: 'That station is not paused.' });
-			} else cb({ status: 'failure', message: `That station doesn't exist, it may have been deleted` });
-		});
-	}),
+	/**
+	 * Deselects the private playlist selected in a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param cb
+	 */
+	deselectPrivatePlaylist: isOwnerRequired(async function deselectPrivatePlaylist(
+		session,
+		stationId,
+		playlistId,
+		cb
+	) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
 
 
-	remove: hooks.ownerRequired((session, stationId, cb) => {
-		db.models.station.remove({ _id: stationId }, (err) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when deleting that station' });
-			cache.hdel('stations', stationId, () => {
-				cache.pub('station.remove', stationId);
-				return cb({ status: 'success', message: 'Station successfully removed' });
-			});
-		});
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.type !== "community") return next("Station is not a community station.");
+					if (station.includedPlaylists.indexOf(playlistId) === -1)
+						return next("That playlist is not included.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_DESELECT_PRIVATE_PLAYLIST",
+						`Deselecting private playlist for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_DESELECT_PRIVATE_PLAYLIST",
+					`Deselected private playlist for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				return cb({
+					status: "success",
+					message: "Successfully deselected playlist."
+				});
+			}
+		);
 	}),
 	}),
 
 
-	create: hooks.loginRequired((session, data, cb) => {
-		data._id = data._id.toLowerCase();
-		let blacklist = ["country", "edm", "musare", "hip-hop", "rap", "top-hits", "todays-hits", "old-school", "christmas", "about", "support", "staff", "help", "news", "terms", "privacy", "profile", "c", "community", "tos", "login", "register", "p", "official", "o", "trap", "faq", "team", "donate", "buy", "shop", "forums", "explore", "settings", "admin", "auth", "reset_password"];
-		async.waterfall([
+	favoriteStation: isLoginRequired(async function favoriteStation(session, stationId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
 
 
-			(next) => {
-				return (data) ? next() : cb({ 'status': 'failure', 'message': 'Invalid data' });
-			},
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob("CAN_USER_VIEW_STATION", { station, userId: session.userId }, this)
+						.then(canView => {
+							if (canView) return next(null, station);
+							return next("Insufficient permissions.");
+						})
+						.catch(err => next(err));
+				},
 
 
-			(next) => {
-				db.models.station.findOne({ $or: [{_id: data._id}, {displayName: new RegExp(`^${data.displayName}$`, 'i')}] }, next);
-			},
+				(station, next) => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $addToSet: { favoriteStations: stationId } },
+						(err, res) => next(err, station, res)
+					);
+				},
 
 
-			(station, next) => {
-				if (station) return next({ 'status': 'failure', 'message': 'A station with that name or display name already exists' });
-				const { _id, displayName, description, genres, playlist, type, blacklistedGenres } = data;
-				cache.hget('sessions', session.sessionId, (err, session) => {
-					if (type === 'official') {
-						db.models.user.findOne({_id: session.userId}, (err, user) => {
-							if (err) return next({ 'status': 'failure', 'message': 'Something went wrong when getting your user info.' });
-							if (!user) return next({ 'status': 'failure', 'message': 'User not found.' });
-							if (user.role !== 'admin') return next({ 'status': 'failure', 'message': 'Admin required.' });
-							db.models.station.create({
-								_id,
-								displayName,
-								description,
-								type,
-								privacy: 'private',
-								playlist,
-								genres,
-								blacklistedGenres,
-								currentSong: stations.defaultSong
-							}, next);
-						});
-					} else if (type === 'community') {
-						if (blacklist.indexOf(_id) !== -1) return next({ 'status': 'failure', 'message': 'That id is blacklisted. Please use a different id.' });
-						db.models.station.create({
-							_id,
-							displayName,
-							description,
-							type,
-							privacy: 'private',
-							owner: session.userId,
-							queue: [],
-							currentSong: null
-						}, next);
+				(station, res, next) => {
+					if (res.nModified === 0) return next("The station was already favorited.");
+					return next(null, station);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "FAVORITE_STATION", `Favoriting station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "FAVORITE_STATION", `Favorited station "${stationId}" successfully.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.favoritedStation",
+					value: {
+						userId: session.userId,
+						stationId
+					}
+				});
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__favorite",
+					payload: {
+						message: `Favorited station <stationId>${station.displayName}</stationId>`,
+						stationId
 					}
 					}
 				});
 				});
-			}
 
 
-		], (err, station) => {
-			if (err) {
-				console.error(err);
-				return cb({ 'status': 'failure', 'message': err.message});
-			} else {
-				cache.pub('station.create', data._id);
-				cb({ 'status': 'success', 'message': 'Successfully created station' });
+				return cb({
+					status: "success",
+					message: "Succesfully favorited station."
+				});
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
-	addToQueue: hooks.loginRequired((session, stationId, songId, cb, userId) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb(err);
-			if (station.type === 'community') {
-				let has = false;
-				station.queue.forEach(queueSong => {
-					if (queueSong._id === songId) has = true;
-				});
-				if (has) return cb({'status': 'failure', 'message': 'That song has already been added to the queue'});
-				if (station.currentSong && station.currentSong._id === songId) return cb({'status': 'failure', 'message': 'That song is currently playing'});
-
-				songs.getSong(songId, (err, song) => {
-					if (err) {
-						utils.getSongFromYouTube(songId, (song) => {
-							song.artists = [];
-							song.skipDuration = 0;
-							song.likes = -1;
-							song.dislikes = -1;
-							song.thumbnail = "empty";
-							song.explicit = false;
-							cont(song);
-						});
-					} else cont(song);
-					function cont(song) {
-						song.requestedBy = userId;
-						db.models.station.update({ _id: stationId }, { $push: { queue: song } }, (err) => {
-							if (err) return cb({'status': 'failure', 'message': 'Something went wrong'});
-							stations.updateStation(stationId, (err, station) => {
-								if (err) return cb(err);
-								cache.pub('station.queueUpdate', stationId);
-								cb({ 'status': 'success', 'message': 'Added that song to the queue' });
-							});
-						});
+	unfavoriteStation: isLoginRequired(async function unfavoriteStation(session, stationId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.updateOne({ _id: session.userId }, { $pull: { favoriteStations: stationId } }, next);
+				},
+
+				(res, next) => {
+					if (res.nModified === 0) return next("The station wasn't favorited.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async (err, station) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "UNFAVORITE_STATION", `Unfavoriting station "${stationId}" failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "UNFAVORITE_STATION", `Unfavorited station "${stationId}" successfully.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.unfavoritedStation",
+					value: {
+						userId: session.userId,
+						stationId
 					}
 					}
 				});
 				});
-			} else cb({'status': 'failure', 'message': 'That station is not a community station'});
-		});
-	}),
 
 
-	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb, userId) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb(err);
-			if (station.type === 'community') {
-				let has = false;
-				station.queue.forEach((queueSong) => {
-					if (queueSong._id === songId) {
-						has = true;
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: session.userId,
+					type: "station__unfavorite",
+					payload: {
+						message: `Unfavorited station <stationId>${station.displayName}</stationId>`,
+						stationId
 					}
 					}
 				});
 				});
-				if (!has) return cb({'status': 'failure', 'message': 'That song is not in the queue.'});
-				db.models.update({_id: stationId}, {$pull: {queue: {songId: songId}}}, (err) => {
-					if (err) return cb({'status': 'failure', 'message': 'Something went wrong.'});
-					stations.updateStation(stationId, (err, station) => {
-						if (err) return cb(err);
-						cache.pub('station.queueUpdate', stationId);
-					});
+
+				return cb({
+					status: "success",
+					message: "Succesfully unfavorited station."
 				});
 				});
-			} else cb({'status': 'failure', 'message': 'That station is not a community station.'});
-		});
+			}
+		);
 	}),
 	}),
 
 
-	getQueue: hooks.adminRequired((session, stationId, cb) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb(err);
-			if (!station) return cb({'status': 'failure', 'message': 'Station not found.'});
-			if (station.type === 'community') {
-				cb({'status': 'success', queue: station.queue});
-			} else cb({'status': 'failure', 'message': 'That station is not a community station.'});
-		});
+	/**
+	 * Clears every station queue
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearEveryStationQueue: isAdminRequired(async function clearEveryStationQueue(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("CLEAR_EVERY_STATION_QUEUE", {}, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "CLEAR_EVERY_STATION_QUEUE", `Clearing every station queue failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "CLEAR_EVERY_STATION_QUEUE", "Clearing every station queue was successful.");
+				return cb({ status: "success", message: "Successfully cleared every station queue." });
+			}
+		);
 	}),
 	}),
 
 
-	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb, userId) => {
-		stations.getStation(stationId, (err, station) => {
-			if (err) return cb(err);
-			if (station.type === 'community') {
-				if (station.privatePlaylist === playlistId) return cb({'status': 'failure', 'message': 'That playlist is already selected.'});
-				db.models.playlist.findOne({ _id: playlistId }, (err, playlist) => {
-					if (err) return cb(err);
-					if (playlist) {
-						let currentSongIndex = (playlist.songs.length > 0) ? playlist.songs.length - 1 : 0;
-						db.models.station.update({_id: stationId}, { $set: { privatePlaylist: playlistId, currentSongIndex: currentSongIndex } }, (err) => {
-							if (err) return cb(err);
-							stations.updateStation(stationId, (err, station) => {
-								if (err) return cb(err);
-								if (!station.partyMode) stations.skipStation(stationId)();
-								cache.pub('privatePlaylist.selected', {playlistId, stationId});
-								cb({'status': 'success', 'message': 'Playlist selected.'});
-							});
-						});
-					} else cb({'status': 'failure', 'message': 'Playlist not found.'});
-				});
-			} else cb({'status': 'failure', 'message': 'That station is not a community station.'});
-		});
+	/**
+	 * Clears and refills a station queue
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - gets called with the result
+	 */
+	clearAndRefillStationQueue: isAdminRequired(async function clearAndRefillStationQueue(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("CLEAR_AND_REFILL_STATION_QUEUE", { stationId }, this)
+						.then(() => next())
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"CLEAR_AND_REFILL_STATION_QUEUE",
+						`Clearing and refilling station queue failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"CLEAR_AND_REFILL_STATION_QUEUE",
+					"Clearing and refilling station queue was successful."
+				);
+				return cb({ status: "success", message: "Successfully cleared and refilled station queue." });
+			}
+		);
 	}),
 	}),
 
 
+	/**
+	 * Gets skip votes for a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param stationId - the song id to get skipvotes for
+	 * @param cb
+	 */
+
+	getSkipVotes: isLoginRequired(async function getSkipVotes(session, stationId, songId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(res => next(null, res.currentSong))
+						.catch(console.log);
+				},
+
+				(currentSong, next) => {
+					if (currentSong && currentSong._id === songId)
+						next(null, {
+							skipVotes: currentSong.skipVotes.length,
+							skipVotesCurrent: true
+						});
+					else
+						next(null, {
+							skipVotes: 0,
+							skipVotesCurrent: false
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_GET_SKIP_VOTES",
+						`User "${session.userId}" failed to get skip votes for ${stationId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { skipVotes, skipVotesCurrent } = data;
+
+				return cb({
+					status: "success",
+					data: {
+						skipVotes,
+						skipVotesCurrent
+					}
+				});
+			}
+		);
+	})
 };
 };

+ 2569 - 489
backend/logic/actions/users.js

@@ -1,49 +1,200 @@
-'use strict';
-
-const async = require('async');
-const config = require('config');
-const request = require('request');
-const bcrypt = require('bcrypt');
-
-const db = require('../db');
-const mail = require('../mail');
-const cache = require('../cache');
-const utils = require('../utils');
-const hooks = require('./hooks');
-const sha256 = require('sha256');
-const logger = require('../logger');
-
-cache.sub('user.updateUsername', user => {
-	utils.socketsFromUser(user._id, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.username.changed', user.username);
+import config from "config";
+
+import async from "async";
+
+import axios from "axios";
+import bcrypt from "bcrypt";
+import sha256 from "sha256";
+import { isAdminRequired, isLoginRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const DBModule = moduleManager.modules.db;
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+const CacheModule = moduleManager.modules.cache;
+const MailModule = moduleManager.modules.mail;
+const PunishmentsModule = moduleManager.modules.punishments;
+const SongsModule = moduleManager.modules.songs;
+const ActivitiesModule = moduleManager.modules.activities;
+const PlaylistsModule = moduleManager.modules.playlists;
+
+CacheModule.runJob("SUB", {
+	channel: "user.updatePreferences",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("keep.event:user.preferences.updated", { data: { preferences: res.preferences } });
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.updateOrderOfFavoriteStations",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.orderOfFavoriteStations.updated", {
+					data: { order: res.favoriteStations }
+				});
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.updateOrderOfPlaylists",
+	cb: res => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.orderOfPlaylists.updated", { data: { order: res.orderOfPlaylists } });
+			});
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `profile.${res.userId}.playlists`,
+			args: ["event:user.orderOfPlaylists.updated", { data: { order: res.orderOfPlaylists } }]
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.updateUsername",
+	cb: user => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: user._id }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.username.updated", { data: { username: user.username } });
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.removeSessions",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets =>
+			sockets.forEach(socket => socket.dispatch("keep.event:user.session.deleted"))
+		);
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.linkPassword",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.password.linked");
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.unlinkPassword",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.password.unlinked");
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.linkGithub",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.github.linked");
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.unlinkGithub",
+	cb: userId => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.github.unlinked");
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.ban",
+	cb: data => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("keep.event:user.banned", { data: { ban: data.punishment } });
+				socket.disconnect(true);
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.favoritedStation",
+	cb: data => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.station.favorited", { data: { stationId: data.stationId } });
+			});
+		});
+	}
+});
+
+CacheModule.runJob("SUB", {
+	channel: "user.unfavoritedStation",
+	cb: data => {
+		WSModule.runJob("SOCKETS_FROM_USER", { userId: data.userId }).then(sockets => {
+			sockets.forEach(socket => {
+				socket.dispatch("event:user.station.unfavorited", { data: { stationId: data.stationId } });
+			});
 		});
 		});
-	});
+	}
 });
 });
 
 
-module.exports = {
+CacheModule.runJob("SUB", {
+	channel: "user.removeAccount",
+	cb: userId => {
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: ["admin.users", `edit-user.${userId}`],
+			args: ["event:user.removed", { data: { userId } }]
+		});
+	}
+});
 
 
+export default {
 	/**
 	/**
 	 * Lists all Users
 	 * Lists all Users
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	index: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.find({}).exec(next);
-			}
-		], (err, users) => {
-			if (err) {
-				logger.error("USER_INDEX", `Indexing users failed. "${err.message}"`);
-				return cb({status: 'failure', message: 'Something went wrong.'});
-			} else {
-				logger.success("USER_INDEX", `Indexing users successful.`);
-				let filteredUsers = [];
+	index: isAdminRequired(async function index(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.find({}).exec(next);
+				}
+			],
+			async (err, users) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "USER_INDEX", `Indexing users failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "USER_INDEX", `Indexing users successful.`);
+				const filteredUsers = [];
 				users.forEach(user => {
 				users.forEach(user => {
 					filteredUsers.push({
 					filteredUsers.push({
 						_id: user._id,
 						_id: user._id,
+						name: user.name,
 						username: user.username,
 						username: user.username,
 						role: user.role,
 						role: user.role,
 						liked: user.liked,
 						liked: user.liked,
@@ -53,633 +204,2562 @@ module.exports = {
 							address: user.email.address,
 							address: user.email.address,
 							verified: user.email.verified
 							verified: user.email.verified
 						},
 						},
+						avatar: {
+							type: user.avatar.type,
+							url: user.avatar.url,
+							color: user.avatar.color
+						},
 						hasPassword: !!user.services.password,
 						hasPassword: !!user.services.password,
 						services: { github: user.services.github }
 						services: { github: user.services.github }
 					});
 					});
 				});
 				});
-				return cb({ status: 'success', data: filteredUsers });
+				return cb({ status: "success", data: { users: filteredUsers } });
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Logs user in
+	 * Removes all data held on a user, including their ability to login
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} identifier - the email of the user
-	 * @param {String} password - the plaintext of the user
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	login: (session, identifier, password, cb) => {
+	remove: isLoginRequired(async function remove(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		const dataRequestEmail = await MailModule.runJob("GET_SCHEMA", { schemaName: "dataRequest" }, this);
+
+		const songsToAdjustRatings = [];
+
+		async.waterfall(
+			[
+				// activities related to the user
+				next => {
+					activityModel.deleteMany({ userId: session.userId }, next);
+				},
+
+				// user's stations
+				(res, next) => {
+					stationModel.find({ owner: session.userId }, (err, stations) => {
+						if (err) return next(err);
+
+						return async.each(
+							stations,
+							(station, callback) => {
+								// delete the station
+								stationModel.deleteOne({ _id: station._id }, err => {
+									if (err) return callback(err);
+
+									CacheModule.runJob("HDEL", { table: "stations", key: station._id });
+
+									// if applicable, delete the corresponding playlist for the station
+									if (station.playlist)
+										return PlaylistsModule.runJob("DELETE_PLAYLIST", {
+											playlistId: station.playlist
+										})
+											.then(() => callback())
+											.catch(callback);
+
+									return callback();
+								});
+							},
+							err => next(err)
+						);
+					});
+				},
 
 
-		identifier = identifier.toLowerCase();
+				next => {
+					playlistModel.findOne({ createdBy: session.userId, displayName: "Liked Songs" }, next);
+				},
 
 
-		async.waterfall([
+				// get all liked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
 
 
-			// check if a user with the requested identifier exists
-			(next) => {
-				db.models.user.findOne({
-					$or: [{ 'email.address': identifier }]
-				}, next)
-			},
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: session.userId, displayName: "Disliked Songs" }, next);
+				},
 
 
-			// if the user doesn't exist, respond with a failure
-			// otherwise compare the requested password and the actual users password
-			(user, next) => {
-				if (!user) return next('User not found');
-				if (!user.services.password || !user.services.password.password) return next('The account you are trying to access uses GitHub to log in.');
-				bcrypt.compare(sha256(password), user.services.password.password, (err, match) => {
-					if (err) return next(err);
-					if (!match) return next('Incorrect password');
-					next(null, user);
+				// get all disliked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				// user's playlists
+				next => {
+					playlistModel.deleteMany({ createdBy: session.userId }, next);
+				},
+
+				(res, next) => {
+					async.each(
+						songsToAdjustRatings,
+						(song, next) => {
+							const { songId, youtubeId } = song;
+
+							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+								.then(() => next())
+								.catch(next);
+						},
+						err => next(err)
+					);
+				},
+
+				// user object
+				next => {
+					userModel.deleteMany({ _id: session.userId }, next);
+				},
+
+				// request data removal for user
+				(res, next) => {
+					dataRequestModel.create({ userId: session.userId, type: "remove" }, next);
+				},
+
+				(request, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.users",
+						args: ["event:admin.dataRequests.created", { data: { request } }]
+					});
+
+					return next();
+				},
+
+				next => userModel.find({ role: "admin" }, next),
+
+				// send email to all admins of a data removal request
+				(users, next) => {
+					if (!config.get("sendDataRequestEmails")) return next();
+					if (users.length === 0) return next();
+
+					const to = [];
+					users.forEach(user => to.push(user.email.address));
+
+					return dataRequestEmail(to, session.userId, "remove", err => next(err));
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_REMOVE",
+						`Removing data and account for user "${session.userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"USER_REMOVE",
+					`Successfully removed data and account for user "${session.userId}"`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.removeAccount",
+					value: session.userId
 				});
 				});
-			},
 
 
-			(user, next) => {
-				let sessionId = utils.guid();
-				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
-					if (err) return next(err);
-					next(null, sessionId);
+				return cb({
+					status: "success",
+					message: "Successfully removed data and account."
 				});
 				});
 			}
 			}
+		);
+	}),
+
+	/**
+	 * Removes all data held on a user, including their ability to login, by userId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the user id that is going to be banned
+	 * @param {Function} cb - gets called with the result
+	 */
+	adminRemove: isAdminRequired(async function adminRemove(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		const dataRequestEmail = await MailModule.runJob("GET_SCHEMA", { schemaName: "dataRequest" }, this);
+
+		const songsToAdjustRatings = [];
+
+		async.waterfall(
+			[
+				next => {
+					if (!userId) return next("You must provide a userId to remove.");
+					return next();
+				},
+				// activities related to the user
+				next => {
+					activityModel.deleteMany({ userId }, next);
+				},
+
+				// user's stations
+				(res, next) => {
+					stationModel.find({ owner: userId }, (err, stations) => {
+						if (err) return next(err);
+
+						return async.each(
+							stations,
+							(station, callback) => {
+								// delete the station
+								stationModel.deleteOne({ _id: station._id }, err => {
+									if (err) return callback(err);
+
+									// if applicable, delete the corresponding playlist for the station
+									if (station.playlist)
+										return PlaylistsModule.runJob("DELETE_PLAYLIST", {
+											playlistId: station.playlist
+										})
+											.then(() => callback())
+											.catch(callback);
+
+									return callback();
+								});
+							},
+							err => next(err)
+						);
+					});
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: userId, displayName: "Liked Songs" }, next);
+				},
+
+				// get all liked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: userId, displayName: "Disliked Songs" }, next);
+				},
+
+				// get all disliked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				// user's playlists
+				next => {
+					playlistModel.deleteMany({ createdBy: userId }, next);
+				},
+
+				(res, next) => {
+					async.each(
+						songsToAdjustRatings,
+						(song, next) => {
+							const { songId, youtubeId } = song;
+
+							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+								.then(() => next())
+								.catch(next);
+						},
+						err => next(err)
+					);
+				},
+
+				// user object
+				next => {
+					userModel.deleteMany({ _id: userId }, next);
+				},
+
+				// request data removal for user
+				(res, next) => {
+					dataRequestModel.create({ userId, type: "remove" }, next);
+				},
+
+				(request, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.users",
+						args: ["event:admin.dataRequests.created", { data: { request } }]
+					});
+
+					return next();
+				},
+
+				next => userModel.find({ role: "admin" }, next),
+
+				// send email to all admins of a data removal request
+				(users, next) => {
+					if (!config.get("sendDataRequestEmails")) return next();
+					if (users.length === 0) return next();
+
+					const to = [];
+					users.forEach(user => to.push(user.email.address));
+
+					return dataRequestEmail(to, userId, "remove", err => next(err));
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_ADMIN_REMOVE",
+						`Removing data and account for user "${userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "USER_ADMIN_REMOVE", `Successfully removed data and account for user "${userId}"`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.removeAccount",
+					value: userId
+				});
 
 
-		], (err, sessionId) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("USER_PASSWORD_LOGIN", "Login failed with password for user " + identifier + '. "' + error + '"');
-				return cb({ status: 'failure', message: error });
+				return cb({
+					status: "success",
+					message: "Successfully removed data and account."
+				});
 			}
 			}
-			logger.success("USER_PASSWORD_LOGIN", "Login successful with password for user " + identifier);
-			cb({ status: 'success', message: 'Login successful', user: {}, SID: sessionId });
-		});
+		);
+	}),
+
+	/**
+	 * Logs user in
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} identifier - the email of the user
+	 * @param {string} password - the plaintext of the user
+	 * @param {Function} cb - gets called with the result
+	 */
+	async login(session, identifier, password, cb) {
+		identifier = identifier.toLowerCase();
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const sessionSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "session" }, this);
+
+		async.waterfall(
+			[
+				// check if a user with the requested identifier exists
+				next => {
+					userModel.findOne(
+						{
+							$or: [{ "email.address": identifier }]
+						},
+						next
+					);
+				},
+
+				// if the user doesn't exist, respond with a failure
+				// otherwise compare the requested password and the actual users password
+				(user, next) => {
+					if (!user) return next("User not found");
+					if (!user.services.password || !user.services.password.password)
+						return next("The account you are trying to access uses GitHub to log in.");
+
+					return bcrypt.compare(sha256(password), user.services.password.password, (err, match) => {
+						if (err) return next(err);
+						if (!match) return next("Incorrect password");
+						return next(null, user);
+					});
+				},
 
 
+				(user, next) => {
+					UtilsModule.runJob("GUID", {}, this).then(sessionId => {
+						next(null, user, sessionId);
+					});
+				},
+
+				(user, sessionId, next) => {
+					CacheModule.runJob(
+						"HSET",
+						{
+							table: "sessions",
+							key: sessionId,
+							value: sessionSchema(sessionId, user._id)
+						},
+						this
+					)
+						.then(() => next(null, sessionId))
+						.catch(next);
+				}
+			],
+			async (err, sessionId) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_PASSWORD_LOGIN",
+						`Login failed with password for user "${identifier}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "USER_PASSWORD_LOGIN", `Login successful with password for user "${identifier}"`);
+
+				return cb({
+					status: "success",
+					message: "Login successful",
+					data: { SID: sessionId }
+				});
+			}
+		);
 	},
 	},
 
 
 	/**
 	/**
 	 * Registers a new user
 	 * Registers a new user
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} username - the username for the new user
-	 * @param {String} email - the email for the new user
-	 * @param {String} password - the plaintext password for the new user
-	 * @param {Object} recaptcha - the recaptcha data
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} username - the username for the new user
+	 * @param {string} email - the email for the new user
+	 * @param {string} password - the plaintext password for the new user
+	 * @param {object} recaptcha - the recaptcha data
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	register: function(session, username, email, password, recaptcha, cb) {
+	async register(session, username, email, password, recaptcha, cb) {
 		email = email.toLowerCase();
 		email = email.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
-		async.waterfall([
-
-			// verify the request with google recaptcha
-			(next) => {
-				request({
-					url: 'https://www.google.com/recaptcha/api/siteverify',
-					method: 'POST',
-					form: {
-						'secret': config.get("apis").recaptcha.secret,
-						'response': recaptcha
-					}
-				}, next);
-			},
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (config.get("registrationDisabled") === true)
+						return next("Registration is not allowed at this time.");
+					return next();
+				},
+
+				next => {
+					if (!DBModule.passwordValid(password))
+						return next("Invalid password. Check if it meets all the requirements.");
+					return next();
+				},
+
+				// verify the request with google recaptcha
+				next => {
+					if (config.get("apis.recaptcha.enabled") === true)
+						axios
+							.post("https://www.google.com/recaptcha/api/siteverify", {
+								data: {
+									secret: config.get("apis").recaptcha.secret,
+									response: recaptcha
+								}
+							})
+							.then(res => next(null, res.data))
+							.catch(err => next(err));
+					else next(null, null);
+				},
+
+				// check if the response from Google recaptcha is successful
+				// if it is, we check if a user with the requested username already exists
+				(body, next) => {
+					if (config.get("apis.recaptcha.enabled") === true)
+						if (body.success !== true) return next("Response from recaptcha was not successful.");
+
+					return userModel.findOne({ username: new RegExp(`^${username}$`, "i") }, next);
+				},
+
+				// if the user already exists, respond with that
+				// otherwise check if a user with the requested email already exists
+				(user, next) => {
+					if (user) return next("A user with that username already exists.");
+					return userModel.findOne({ "email.address": email }, next);
+				},
+
+				// if the user already exists, respond with that
+				// otherwise, generate a salt to use with hashing the new users password
+				(user, next) => {
+					if (user) return next("A user with that email already exists.");
+					return bcrypt.genSalt(10, next);
+				},
+
+				// hash the password
+				(salt, next) => {
+					bcrypt.hash(sha256(password), salt, next);
+				},
+
+				(hash, next) => {
+					UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 12 }, this).then(_id => {
+						next(null, hash, _id);
+					});
+				},
+
+				// create the user object
+				(hash, _id, next) => {
+					next(null, {
+						_id,
+						name: username,
+						username,
+						email: {
+							address: email,
+							verificationToken
+						},
+						services: {
+							password: {
+								password: hash
+							}
+						}
+					});
+				},
+
+				// generate the url for gravatar avatar
+				(user, next) => {
+					UtilsModule.runJob("CREATE_GRAVATAR", { email: user.email.address }, this).then(url => {
+						const avatarColors = ["blue", "orange", "green", "purple", "teal"];
+						user.avatar = {
+							type: "initials",
+							color: avatarColors[Math.floor(Math.random() * avatarColors.length)],
+							url
+						};
+						next(null, user);
+					});
+				},
 
 
-			// check if the response from Google recaptcha is successful
-			// if it is, we check if a user with the requested username already exists
-			(response, body, next) => {
-				let json = JSON.parse(body);
-				if (json.success !== true) return next('Response from recaptcha was not successful.');
-				db.models.user.findOne({ username: new RegExp(`^${username}$`, 'i') }, next);
-			},
+				// save the new user to the database
+				(user, next) => {
+					userModel.create(user, next);
+				},
 
 
-			// if the user already exists, respond with that
-			// otherwise check if a user with the requested email already exists
-			(user, next) => {
-				if (user) return next('A user with that username already exists.');
-				db.models.user.findOne({ 'email.address': email }, next);
-			},
+				// respond with the new user
+				(user, next) => {
+					verifyEmailSchema(email, username, verificationToken, err => {
+						next(err, user._id);
+					});
+				},
+
+				// create a liked songs playlist for the new user
+				(userId, next) => {
+					PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+						userId,
+						displayName: "Liked Songs",
+						type: "user"
+					})
+						.then(likedSongsPlaylist => {
+							next(null, likedSongsPlaylist, userId);
+						})
+						.catch(err => next(err));
+				},
+
+				// create a disliked songs playlist for the new user
+				(likedSongsPlaylist, userId, next) => {
+					PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+						userId,
+						displayName: "Disliked Songs",
+						type: "user"
+					})
+						.then(dislikedSongsPlaylist => {
+							next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);
+						})
+						.catch(err => next(err));
+				},
+
+				// associate liked + disliked songs playlist to the user object
+				({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
+					userModel.updateOne(
+						{ _id: userId },
+						{ $set: { likedSongsPlaylist, dislikedSongsPlaylist } },
+						{ runValidators: true },
+						err => {
+							if (err) return next(err);
+							return next(null, userId);
+						}
+					);
+				}
+			],
+			async (err, userId) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_PASSWORD_REGISTER",
+						`Register failed with password for user "${username}"."${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId,
+					type: "user__joined",
+					payload: { message: "Welcome to Musare!" }
+				});
 
 
-			// if the user already exists, respond with that
-			// otherwise, generate a salt to use with hashing the new users password
-			(user, next) => {
-				if (user) return next('A user with that email already exists.');
-				bcrypt.genSalt(10, next);
-			},
+				this.log(
+					"SUCCESS",
+					"USER_PASSWORD_REGISTER",
+					`Register successful with password for user "${username}".`
+				);
+
+				const res = await this.module.runJob(
+					"RUN_ACTION2",
+					{
+						session,
+						namespace: "users",
+						action: "login",
+						args: [email, password]
+					},
+					this
+				);
 
 
-			// hash the password
-			(salt, next) => {
-				bcrypt.hash(sha256(password), salt, next)
-			},
+				const obj = {
+					status: "success",
+					message: "Successfully registered."
+				};
 
 
-			// save the new user to the database
-			(hash, next) => {
-				db.models.user.create({
-					_id: utils.generateRandomString(12),//TODO Check if exists
-					username,
-					email: {
-						address: email,
-						verificationToken
-					},
-					services: {
-						password: {
-							password: hash
-						}
-					}
-				}, next);
-			},
+				if (res.status === "success") {
+					obj.SID = res.data.SID;
+				}
 
 
-			// respond with the new user
-			(newUser, next) => {
-				//TODO Send verification email
-				mail.schemas.verifyEmail(email, username, verificationToken, () => {
-					next();
+				return cb(obj);
+			}
+		);
+	},
+
+	/**
+	 * Logs out a user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	logout(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob("HGET", { table: "sessions", key: session.sessionId }, this)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+
+				(session, next) => {
+					if (!session) return next("Session not found");
+					return next(null, session);
+				},
+
+				(session, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "user.removeSessions",
+						value: session.userId
+					});
+
+					// temp fix, need to wait properly for the SUB/PUB refactor (on wekan)
+					setTimeout(() => {
+						CacheModule.runJob("HDEL", { table: "sessions", key: session.sessionId }, this)
+							.then(() => next())
+							.catch(next);
+					}, 50);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "USER_LOGOUT", `Logout failed. "${err}" `);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "USER_LOGOUT", `Logout successful.`);
+
+				return cb({
+					status: "success",
+					message: "Successfully logged out."
 				});
 				});
 			}
 			}
+		);
+	},
 
 
-		], (err) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("USER_PASSWORD_REGISTER", "Register failed with password for user. " + '"' + error + '"');
-				cb({status: 'failure', message: error});
-			} else {
-				module.exports.login(session, email, password, (result) => {
-					let obj = {status: 'success', message: 'Successfully registered.'};
-					if (result.status === 'success') {
-						obj.SID = result.SID;
-					}
-					logger.success("USER_PASSWORD_REGISTER", "Register successful with password for user '" + username + "'.");
-					cb(obj);
+	/**
+	 * Checks if user's password is correct (e.g. before a sensitive action)
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} password - the password the user entered that we need to validate
+	 * @param {Function} cb - gets called with the result
+	 */
+	confirmPasswordMatch: isLoginRequired(async function confirmPasswordMatch(session, password, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		return async.waterfall(
+			[
+				next => {
+					if (!password || password === "") return next("Please provide a valid password.");
+					return next();
+				},
+
+				next => {
+					userModel.findOne({ _id: session.userId }, (err, user) =>
+						next(err, user.services.password.password)
+					);
+				},
+
+				(passwordHash, next) => {
+					if (!passwordHash) return next("Your account doesn't have a password linked.");
+
+					return bcrypt.compare(sha256(password), passwordHash, (err, match) => {
+						if (err) return next(err);
+						if (!match) return next(null, false);
+						return next(null, true);
+					});
+				}
+			],
+			async (err, match) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_CONFIRM_PASSWORD",
+						`Couldn't confirm password for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				if (match) {
+					this.log(
+						"SUCCESS",
+						"USER_CONFIRM_PASSWORD",
+						`Successfully checked for password match (it matched) for user "${session.userId}".`
+					);
+
+					return cb({
+						status: "success",
+						message: "Your password matches."
+					});
+				}
+
+				this.log(
+					"SUCCESS",
+					"USER_CONFIRM_PASSWORD",
+					`Successfully checked for password match (it didn't match) for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "error",
+					message: "Unfortunately your password doesn't match."
 				});
 				});
 			}
 			}
-		});
+		);
+	}),
 
 
-	},
+	/**
+	 * Checks if user's github access token has expired or not (ie. if their github account is still linked)
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	confirmGithubLink: isLoginRequired(async function confirmGithubLink(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		return async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, (err, user) => next(err, user));
+				},
+
+				(user, next) => {
+					if (!user.services.github) return next("You don't have GitHub linked to your account.");
+
+					return axios
+						.get(`https://api.github.com/user/emails`, {
+							headers: {
+								"User-Agent": "request",
+								Authorization: `token ${user.services.github.access_token}`
+							}
+						})
+						.then(res => next(null, res))
+						.catch(err => next(err));
+				},
+
+				(res, next) => next(null, res.status === 200)
+			],
+			async (err, linked) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_CONFIRM_GITHUB_LINK",
+						`Couldn't confirm github link for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"USER_CONFIRM_GITHUB_LINK",
+					`GitHub is ${linked ? "linked" : "not linked"} for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					data: { linked },
+					message: "Successfully checked if GitHub accounty was linked."
+				});
+			}
+		);
+	}),
 
 
 	/**
 	/**
-	 * Logs out a user
+	 * Removes all sessions for a user
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the id of the user we are trying to delete the sessions of
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	logout: (session, cb) => {
+	removeSessions: isLoginRequired(async function removeSessions(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, (err, user) => {
+						if (err) return next(err);
+						if (user.role !== "admin" && session.userId !== userId)
+							return next("Only admins and the owner of the account can remove their sessions.");
+						return next();
+					});
+				},
 
 
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
+				next => {
+					CacheModule.runJob("HGETALL", { table: "sessions" }, this)
+						.then(sessions => {
+							next(null, sessions);
+						})
+						.catch(next);
+				},
 
 
-			(session, next) => {
-				if (!session) return next('Session not found');
-				next(null, session);
-			},
+				(sessions, next) => {
+					if (!sessions) return next("There are no sessions for this user to remove.");
+
+					const keys = Object.keys(sessions);
+
+					return next(null, keys, sessions);
+				},
+
+				(keys, sessions, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "user.removeSessions",
+						value: userId
+					});
+
+					// temp fix, need to wait properly for the SUB/PUB refactor (on wekan)
+					setTimeout(
+						() =>
+							async.each(
+								keys,
+								(sessionId, callback) => {
+									const session = sessions[sessionId];
+
+									if (session.userId === userId) {
+										// TODO Also maybe add this to this runJob
+										CacheModule.runJob("HDEL", {
+											table: "sessions",
+											key: sessionId
+										})
+											.then(() => callback(null))
+											.catch(callback);
+									}
+								},
+								err => {
+									next(err);
+								}
+							),
+						50
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REMOVE_SESSIONS_FOR_USER",
+						`Couldn't remove all sessions for user "${userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "REMOVE_SESSIONS_FOR_USER", `Removed all sessions for user "${userId}".`);
+
+				return cb({
+					status: "success",
+					message: "Successfully removed all sessions."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates the order of a user's favorite stations
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} favoriteStations - array of station ids (with a specific order)
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateOrderOfFavoriteStations: isLoginRequired(async function updateOrderOfFavoriteStations(
+		session,
+		favoriteStations,
+		cb
+	) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $set: { favoriteStations } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
+						`Couldn't update order of favorite stations for user "${session.userId}" to "${favoriteStations}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updateOrderOfFavoriteStations",
+					value: {
+						favoriteStations,
+						userId: session.userId
+					}
+				});
 
 
-			(session, next) => {
-				cache.hdel('sessions', session.sessionId, next);
+				this.log(
+					"SUCCESS",
+					"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
+					`Updated order of favorite stations for user "${session.userId}" to "${favoriteStations}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Order of favorite stations successfully updated"
+				});
 			}
 			}
-		], (err) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("USER_LOGOUT", `Logout failed. ${error}`);
-				cb({status: 'failure', message: error});
-			} else {
-				logger.success("USER_LOGOUT", `Logout successful.`);
-				cb({status: 'success', message: 'Successfully logged out.'});
+		);
+	}),
+
+	/**
+	 * Updates the order of a user's playlists
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} orderOfPlaylists - array of playlist ids (with a specific order)
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateOrderOfPlaylists: isLoginRequired(async function updateOrderOfPlaylists(session, orderOfPlaylists, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{ $set: { "preferences.orderOfPlaylists": orderOfPlaylists } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_ORDER_OF_USER_PLAYLISTS",
+						`Couldn't update order of playlists for user "${session.userId}" to "${orderOfPlaylists}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updateOrderOfPlaylists",
+					value: {
+						orderOfPlaylists,
+						userId: session.userId
+					}
+				});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_ORDER_OF_USER_PLAYLISTS",
+					`Updated order of playlists for user "${session.userId}" to "${orderOfPlaylists}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Order of playlists successfully updated"
+				});
 			}
 			}
-		});
+		);
+	}),
 
 
-	},
+	/**
+	 * Updates a user's preferences
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} preferences - object containing preferences
+	 * @param {boolean} preferences.nightmode - whether or not the user is using the night mode theme
+	 * @param {boolean} preferences.autoSkipDisliked - whether to automatically skip disliked songs
+	 * @param {boolean} preferences.activityLogPublic - whether or not a user's activity log can be publicly viewed
+	 * @param {boolean} preferences.anonymousSongRequests - whether or not a user's requested songs will be anonymous
+	 * @param {boolean} preferences.activityWatch - whether or not a user is using the ActivityWatch integration
+	 * @param {Function} cb - gets called with the result
+	 */
+	updatePreferences: isLoginRequired(async function updatePreferences(session, preferences, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					const $set = {};
+
+					Object.keys(preferences).forEach(preference => {
+						$set[`preferences.${preference}`] = preferences[preference];
+					});
+
+					return next(null, $set);
+				},
+
+				($set, next) => {
+					userModel.findByIdAndUpdate(session.userId, { $set }, { new: false, upsert: true }, next);
+				}
+			],
+			async (err, user) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_USER_PREFERENCES",
+						`Couldn't update preferences for user "${session.userId}" to "${JSON.stringify(
+							preferences
+						)}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updatePreferences",
+					value: {
+						preferences,
+						userId: session.userId
+					}
+				});
+
+				// if (preferences.nightmode !== undefined && preferences.nightmode !== user.preferences.nightmode)
+				// 	ActivitiesModule.runJob("ADD_ACTIVITY", {
+				// 		userId: session.userId,
+				// 		type: "user__toggle_nightmode",
+				// 		payload: { message: preferences.nightmode ? "Enabled nightmode" : "Disabled nightmode" }
+				// 	});
+
+				if (
+					preferences.autoSkipDisliked !== undefined &&
+					preferences.autoSkipDisliked !== user.preferences.autoSkipDisliked
+				)
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "user__toggle_autoskip_disliked_songs",
+						payload: {
+							message: preferences.autoSkipDisliked
+								? "Enabled the autoskipping of disliked songs"
+								: "Disabled the autoskipping of disliked songs"
+						}
+					});
+
+				if (
+					preferences.activityWatch !== undefined &&
+					preferences.activityWatch !== user.preferences.activityWatch
+				)
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "user__toggle_activity_watch",
+						payload: {
+							message: preferences.activityWatch
+								? "Enabled ActivityWatch integration"
+								: "Disabled ActivityWatch integration"
+						}
+					});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_USER_PREFERENCES",
+					`Updated preferences for user "${session.userId}" to "${JSON.stringify(preferences)}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Preferences successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Retrieves a user's preferences
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	getPreferences: isLoginRequired(async function updatePreferences(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findById(session.userId).select({ preferences: -1 }).exec(next);
+				}
+			],
+			async (err, { preferences }) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"GET_USER_PREFERENCES",
+						`Couldn't retrieve preferences for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"GET_USER_PREFERENCES",
+					`Successfully obtained preferences for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Preferences successfully retrieved",
+					data: { preferences }
+				});
+			}
+		);
+	}),
 
 
 	/**
 	/**
 	 * Gets user object from username (only a few properties)
 	 * Gets user object from username (only a few properties)
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} username - the username of the user we are trying to find
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} username - the username of the user we are trying to find
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	findByUsername: (session, username, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ username: new RegExp(`^${username}$`, 'i') }, next);
-			},
+	findByUsername: async function findByUsername(session, username, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ username: new RegExp(`^${username}$`, "i") }, next);
+				},
+
+				(account, next) => {
+					if (!account) return next("User not found.");
+					return next(null, account);
+				}
+			],
+			async (err, account) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "FIND_BY_USERNAME", `User found for username "${username}".`);
 
 
-			(account, next) => {
-				if (!account) return next('User not found.');
-				next(null, account);
-			}
-		], (err, account) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("FIND_BY_USERNAME", `User not found for username '${username}'. ${error}`);
-				cb({status: 'failure', message: error});
-			} else {
-				logger.success("FIND_BY_USERNAME", `User found for username '${username}'.`);
 				return cb({
 				return cb({
-					status: 'success',
+					status: "success",
 					data: {
 					data: {
 						_id: account._id,
 						_id: account._id,
+						name: account.name,
 						username: account.username,
 						username: account.username,
+						location: account.location,
+						bio: account.bio,
 						role: account.role,
 						role: account.role,
-						email: account.email.address,
-						createdAt: account.createdAt,
-						statistics: account.statistics,
-						liked: account.liked,
-						disliked: account.disliked
+						avatar: account.avatar,
+						createdAt: account.createdAt
 					}
 					}
 				});
 				});
 			}
 			}
-		});
+		);
 	},
 	},
 
 
-	//TODO Fix security issues
 	/**
 	/**
-	 * Gets user info from session
+	 * Gets a username from an userId
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the userId of the person we are trying to get the username from
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	findBySession: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
+	async getUsernameFromId(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		userModel
+			.findById(userId)
+			.then(user => {
+				if (user) {
+					this.log("SUCCESS", "GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
+
+					return cb({
+						status: "success",
+						data: { username: user.username }
+					});
+				}
 
 
-			(session, next) => {
-				if (!session) return next('Session not found.');
-				next(null, session);
-			},
+				this.log(
+					"ERROR",
+					"GET_USERNAME_FROM_ID",
+					`Getting the username from userId "${userId}" failed. User not found.`
+				);
 
 
-			(session, next) => {
-				db.models.user.findOne({ _id: session.userId }, next);
-			},
+				return cb({
+					status: "error",
+					message: "Couldn't find the user."
+				});
+			})
+			.catch(async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_USERNAME_FROM_ID",
+						`Getting the username from userId "${userId}" failed. "${err}"`
+					);
+					cb({ status: "error", message: err });
+				}
+			});
+	},
 
 
-			(user, next) => {
-				if (!user) return next('User not found.');
-				next(null, user);
-			}
-		], (err, user) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("FIND_BY_SESSION", `User not found. ${error}`);
-				cb({status: 'failure', message: error});
-			} else {
-				let data = {
+	/**
+	 * Gets a user from a userId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the userId of the person we are trying to get the username from
+	 * @param {Function} cb - gets called with the result
+	 */
+	getUserFromId: isAdminRequired(async function getUserFromId(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		userModel
+			.findById(userId)
+			.then(user => {
+				if (user) {
+					this.log("SUCCESS", "GET_USER_FROM_ID", `Found user for userId "${userId}".`);
+
+					return cb({
+						status: "success",
+						data: {
+							_id: user._id,
+							username: user.username,
+							role: user.role,
+							liked: user.liked,
+							disliked: user.disliked,
+							songsRequested: user.statistics.songsRequested,
+							email: {
+								address: user.email.address,
+								verified: user.email.verified
+							},
+							hasPassword: !!user.services.password,
+							services: { github: user.services.github }
+						}
+					});
+				}
+
+				this.log(
+					"ERROR",
+					"GET_USER_FROM_ID",
+					`Getting the user from userId "${userId}" failed. User not found.`
+				);
+
+				return cb({
+					status: "error",
+					message: "Couldn't find the user."
+				});
+			})
+			.catch(async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_USER_FROM_ID", `Getting the user from userId "${userId}" failed. "${err}"`);
+					cb({ status: "error", message: err });
+				}
+			});
+	}),
+
+	/**
+	 * Gets user info from session
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	findBySession: isLoginRequired(async function findBySession(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					CacheModule.runJob(
+						"HGET",
+						{
+							table: "sessions",
+							key: session.sessionId
+						},
+						this
+					)
+						.then(session => next(null, session))
+						.catch(next);
+				},
+
+				(session, next) => {
+					if (!session) return next("Session not found.");
+					return next(null, session);
+				},
+
+				(session, next) => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return next(null, user);
+				}
+			],
+			async (err, user) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "FIND_BY_SESSION", `User not found. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+
+				const sanitisedUser = {
 					email: {
 					email: {
 						address: user.email.address
 						address: user.email.address
 					},
 					},
-					username: user.username
+					avatar: user.avatar,
+					username: user.username,
+					name: user.name,
+					location: user.location,
+					bio: user.bio
 				};
 				};
-				if (user.services.password && user.services.password.password) data.password = true;
-				logger.success("FIND_BY_SESSION", `User found. '${user.username}'.`);
+
+				if (user.services.password && user.services.password.password) sanitisedUser.password = true;
+				if (user.services.github && user.services.github.id) sanitisedUser.github = true;
+
+				this.log("SUCCESS", "FIND_BY_SESSION", `User found. "${user.username}".`);
 				return cb({
 				return cb({
-					status: 'success',
-					data
+					status: "success",
+					data: { user: sanitisedUser }
 				});
 				});
 			}
 			}
-		});
-	},
+		);
+	}),
 
 
 	/**
 	/**
 	 * Updates a user's username
 	 * Updates a user's username
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newUsername - the new username
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newUsername - the new username
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	updateUsername: hooks.loginRequired((session, updatingUserId, newUsername, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (user.username === newUsername) return next('New username can\'t be the same as the old username.');
-				next(null);
-			},
-
-			(next) => {
-				db.models.user.findOne({ username: new RegExp(`^${newUsername}$`, 'i') }, next);
-			},
+	updateUsername: isLoginRequired(async function updateUsername(session, updatingUserId, newUsername, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.username === newUsername)
+						return next("New username can't be the same as the old username.");
+					return next(null);
+				},
+
+				next => {
+					userModel.findOne({ username: new RegExp(`^${newUsername}$`, "i") }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next();
+					if (user._id === updatingUserId) return next();
+					return next("That username is already in use.");
+				},
+
+				next => {
+					userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { username: newUsername } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_USERNAME",
+						`Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "user.updateUsername",
+					value: {
+						username: newUsername,
+						_id: updatingUserId
+					}
+				});
 
 
-			(user, next) => {
-				if (!user) return next();
-				if (user._id === updatingUserId) return next();
-				next('That username is already in use.');
-			},
+				this.log(
+					"SUCCESS",
+					"UPDATE_USERNAME",
+					`Updated username for user "${updatingUserId}" to username "${newUsername}".`
+				);
 
 
-			(next) => {
-				db.models.user.update({ _id: updatingUserId }, {$set: {username: newUsername}}, next);
-			}
-		], (err) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("UPDATE_USERNAME", `Couldn't update username for user '${updatingUserId}' to username '${newUsername}'. '${error}'`);
-				cb({status: 'failure', message: error});
-			} else {
-				cache.pub('user.updateUsername', {
-					username: newUsername,
-					_id: updatingUserId
+				return cb({
+					status: "success",
+					message: "Username updated successfully"
 				});
 				});
-				logger.success("UPDATE_USERNAME", `Updated username for user '${updatingUserId}' to username '${newUsername}'.`);
-				cb({ status: 'success', message: 'Username updated successfully' });
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Updates a user's email
 	 * Updates a user's email
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newEmail - the new email
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newEmail - the new email
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	updateEmail: hooks.loginRequired((session, updatingUserId, newEmail, cb, userId) => {
+	updateEmail: isLoginRequired(async function updateEmail(session, updatingUserId, newEmail, cb) {
 		newEmail = newEmail.toLowerCase();
 		newEmail = newEmail.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
+		const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
+
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.email.address === newEmail)
+						return next("New email can't be the same as your the old email.");
+					return next();
+				},
+
+				next => {
+					userModel.findOne({ "email.address": newEmail }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next();
+					if (user._id === updatingUserId) return next();
+					return next("That email is already in use.");
+				},
+
+				// regenerate the url for gravatar avatar
+				next => {
+					UtilsModule.runJob("CREATE_GRAVATAR", { email: newEmail }, this).then(url => {
+						next(null, url);
+					});
+				},
+
+				(newAvatarUrl, next) => {
+					userModel.updateOne(
+						{ _id: updatingUserId },
+						{
+							$set: {
+								"avatar.url": newAvatarUrl,
+								"email.address": newEmail,
+								"email.verified": false,
+								"email.verificationToken": verificationToken
+							}
+						},
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(res, next) => {
+					userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					verifyEmailSchema(newEmail, user.username, verificationToken, err => {
+						next(err);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_EMAIL",
+						`Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_EMAIL",
+					`Updated email for user "${updatingUserId}" to email "${newEmail}".`
+				);
 
 
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (user.email.address === newEmail) return next('New email can\'t be the same as your the old email.');
-				next();
-			},
+				return cb({
+					status: "success",
+					message: "Email updated successfully."
+				});
+			}
+		);
+	}),
 
 
-			(next) => {
-				db.models.user.findOne({"email.address": newEmail}, next);
+	/**
+	 * Updates a user's name
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newBio - the new name
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateName: isLoginRequired(async function updateName(session, updatingUserId, newName, cb) {
+		const userModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "user"
 			},
 			},
+			this
+		);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { name: newName } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UPDATE_NAME",
+						`Couldn't update name for user "${updatingUserId}" to name "${newName}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: updatingUserId,
+					type: "user__edit_name",
+					payload: { message: `Changed name to ${newName}` }
+				});
 
 
-			(user, next) => {
-				if (!user) return next();
-				if (user._id === updatingUserId) return next();
-				next('That email is already in use.');
-			},
+				this.log("SUCCESS", "UPDATE_NAME", `Updated name for user "${updatingUserId}" to name "${newName}".`);
 
 
-			(next) => {
-				db.models.user.update({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, next);
-			},
+				return cb({
+					status: "success",
+					message: "Name updated successfully"
+				});
+			}
+		);
+	}),
 
 
-			(res, next) => {
-				db.models.user.findOne({ _id: updatingUserId }, next);
+	/**
+	 * Updates a user's location
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newLocation - the new location
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateLocation: isLoginRequired(async function updateLocation(session, updatingUserId, newLocation, cb) {
+		const userModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "user"
 			},
 			},
+			this
+		);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { location: newLocation } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_LOCATION",
+						`Couldn't update location for user "${updatingUserId}" to location "${newLocation}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: updatingUserId,
+					type: "user__edit_location",
+					payload: { message: `Changed location to ${newLocation}` }
+				});
 
 
-			(user, next) => {
-				mail.schemas.verifyEmail(newEmail, user.username, verificationToken, () => {
-					next();
+				this.log(
+					"SUCCESS",
+					"UPDATE_LOCATION",
+					`Updated location for user "${updatingUserId}" to location "${newLocation}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Location updated successfully"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Updates a user's bio
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newBio - the new bio
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateBio: isLoginRequired(async function updateBio(session, updatingUserId, newBio, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { bio: newBio } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UPDATE_BIO",
+						`Couldn't update bio for user "${updatingUserId}" to bio "${newBio}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: updatingUserId,
+					type: "user__edit_bio",
+					payload: { message: `Changed bio to ${newBio}` }
+				});
+
+				this.log("SUCCESS", "UPDATE_BIO", `Updated bio for user "${updatingUserId}" to bio "${newBio}".`);
+
+				return cb({
+					status: "success",
+					message: "Bio updated successfully"
 				});
 				});
 			}
 			}
-		], (err) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("UPDATE_EMAIL", `Couldn't update email for user '${updatingUserId}' to email '${newEmail}'. '${error}'`);
-				cb({status: 'failure', message: error});
-			} else {
-				logger.success("UPDATE_EMAIL", `Updated email for user '${updatingUserId}' to email '${newEmail}'.`);
-				cb({ status: 'success', message: 'Email updated successfully.' });
+		);
+	}),
+
+	/**
+	 * Updates a user's avatar
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newAvatar - the new avatar object
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateAvatar: isLoginRequired(async function updateAvatarType(session, updatingUserId, newAvatar, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (updatingUserId === session.userId) return next(null, true);
+					return userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (user !== true && (!user || user.role !== "admin")) return next("Invalid permissions.");
+					return userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					return userModel.findOneAndUpdate(
+						{ _id: updatingUserId },
+						{ $set: { "avatar.type": newAvatar.type, "avatar.color": newAvatar.color } },
+						{ new: true, runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UPDATE_AVATAR",
+						`Couldn't update avatar for user "${updatingUserId}" to type "${newAvatar.type}" and color "${newAvatar.color}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				ActivitiesModule.runJob("ADD_ACTIVITY", {
+					userId: updatingUserId,
+					type: "user__edit_avatar",
+					payload: { message: `Changed avatar to use ${newAvatar.type} and ${newAvatar.color}` }
+				});
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_AVATAR",
+					`Updated avatar for user "${updatingUserId}" to type "${newAvatar.type} and color ${newAvatar.color}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Avatar updated successfully"
+				});
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
 	 * Updates a user's role
 	 * Updates a user's role
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} updatingUserId - the updating user's id
-	 * @param {String} newRole - the new role
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} updatingUserId - the updating user's id
+	 * @param {string} newRole - the new role
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	updateRole: hooks.adminRequired((session, updatingUserId, newRole, cb, userId) => {
+	updateRole: isAdminRequired(async function updateRole(session, updatingUserId, newRole, cb) {
 		newRole = newRole.toLowerCase();
 		newRole = newRole.toLowerCase();
-		async.waterfall([
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: updatingUserId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.role === newRole) return next("New role can't be the same as the old role.");
+					return next();
+				},
+				next => {
+					userModel.updateOne(
+						{ _id: updatingUserId },
+						{ $set: { role: newRole } },
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"UPDATE_ROLE",
+						`User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"UPDATE_ROLE",
+					`User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
+				);
 
 
-			(next) => {
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				else if (user.role === newRole) return next('New role can\'t be the same as the old role.');
-				else return next();
-			},
-			(next) => {
-				db.models.user.update({_id: updatingUserId}, {$set: {role: newRole}}, next);
+				return cb({
+					status: "success",
+					message: "Role successfully updated."
+				});
 			}
 			}
+		);
+	}),
+
+	/**
+	 * Updates a user's password
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} previousPassword - the previous password
+	 * @param {string} newPassword - the new password
+	 * @param {Function} cb - gets called with the result
+	 */
+	updatePassword: isLoginRequired(async function updatePassword(session, previousPassword, newPassword, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user.services.password) return next("This account does not have a password set.");
+					return next(null, user.services.password.password);
+				},
+
+				(storedPassword, next) => {
+					bcrypt.compare(sha256(previousPassword), storedPassword).then(res => {
+						if (res) return next();
+						return next("Please enter the correct previous password.");
+					});
+				},
+
+				next => {
+					if (!DBModule.passwordValid(newPassword))
+						return next("Invalid new password. Check if it meets all the requirements.");
+					return next();
+				},
+
+				next => {
+					bcrypt.genSalt(10, next);
+				},
+
+				// hash the password
+				(salt, next) => {
+					bcrypt.hash(sha256(newPassword), salt, next);
+				},
+
+				(hashedPassword, next) => {
+					userModel.updateOne(
+						{ _id: session.userId },
+						{
+							$set: {
+								"services.password.password": hashedPassword
+							}
+						},
+						next
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UPDATE_PASSWORD",
+						`Failed updating user password of user '${session.userId}'. '${err}'.`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "UPDATE_PASSWORD", `User '${session.userId}' updated their password.`);
 
 
-		], (err) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("UPDATE_ROLE", `User '${userId}' couldn't update role for user '${updatingUserId}' to role '${newRole}'. '${error}'`);
-				cb({status: 'failure', message: error});
-			} else {
-				logger.success("UPDATE_ROLE", `User '${userId}' updated the role of user '${updatingUserId}' to role '${newRole}'.`);
-				cb({
-					status: 'success',
-					message: 'Role successfully updated.'
+				return cb({
+					status: "success",
+					message: "Password successfully updated."
 				});
 				});
 			}
 			}
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Updates a user's password
+	 * Requests a password for a session
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} newPassword - the new password
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} email - the email of the user that requests a password reset
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	 */
-	updatePassword: hooks.loginRequired((session, newPassword, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+	requestPassword: isLoginRequired(async function requestPassword(session, cb) {
+		const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
+		const passwordRequestSchema = await MailModule.runJob(
+			"GET_SCHEMA",
+			{
+				schemaName: "passwordRequest"
 			},
 			},
+			this
+		);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.services.password && user.services.password.password)
+						return next("You already have a password set.");
+					return next(null, user);
+				},
+
+				(user, next) => {
+					const expires = new Date();
+					expires.setDate(expires.getDate() + 1);
+					userModel.findOneAndUpdate(
+						{ "email.address": user.email.address },
+						{
+							$set: {
+								"services.password": {
+									set: { code, expires }
+								}
+							}
+						},
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(user, next) => {
+					passwordRequestSchema(user.email.address, user.username, code, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"REQUEST_PASSWORD",
+						`UserId '${session.userId}' failed to request password. '${err}'`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"REQUEST_PASSWORD",
+					`UserId '${session.userId}' successfully requested a password.`
+				);
 
 
-			(user, next) => {
-				if (!user.services.password) return next('This account does not have a password set.');
-				next();
-			},
+				return cb({
+					status: "success",
+					message: "Successfully requested password."
+				});
+			}
+		);
+	}),
 
 
-			(next) => {
-				bcrypt.genSalt(10, next);
-			},
+	/**
+	 * Verifies a password code
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} code - the password code
+	 * @param {Function} cb - gets called with the result
+	 */
+	verifyPasswordCode: isLoginRequired(async function verifyPasswordCode(session, code, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					if (!code || typeof code !== "string") return next("Invalid code.");
+					return userModel.findOne(
+						{
+							"services.password.set.code": code,
+							_id: session.userId
+						},
+						next
+					);
+				},
+
+				(user, next) => {
+					if (!user) return next("Invalid code.");
+					if (user.services.password.set.expires < new Date()) return next("That code has expired.");
+					return next(null);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "VERIFY_PASSWORD_CODE", `Code '${code}' successfully verified.`);
+					cb({
+						status: "success",
+						message: "Successfully verified password code."
+					});
+				}
+			}
+		);
+	}),
 
 
-			// hash the password
-			(salt, next) => {
-				bcrypt.hash(sha256(newPassword), salt, next);
+	/**
+	 * Adds a password to a user with a code
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} code - the password code
+	 * @param {string} newPassword - the new password code
+	 * @param {Function} cb - gets called with the result
+	 */
+	changePasswordWithCode: isLoginRequired(async function changePasswordWithCode(session, code, newPassword, cb) {
+		const userModel = await DBModule.runJob(
+			"GET_MODEL",
+			{
+				modelName: "user"
 			},
 			},
+			this
+		);
+		async.waterfall(
+			[
+				next => {
+					if (!code || typeof code !== "string") return next("Invalid code.");
+					return userModel.findOne({ "services.password.set.code": code }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Invalid code.");
+					if (!user.services.password.set.expires > new Date()) return next("That code has expired.");
+					return next();
+				},
+
+				next => {
+					if (!DBModule.passwordValid(newPassword))
+						return next("Invalid password. Check if it meets all the requirements.");
+					return next();
+				},
+
+				next => {
+					bcrypt.genSalt(10, next);
+				},
+
+				// hash the password
+				(salt, next) => {
+					bcrypt.hash(sha256(newPassword), salt, next);
+				},
+
+				(hashedPassword, next) => {
+					userModel.updateOne(
+						{ "services.password.set.code": code },
+						{
+							$set: {
+								"services.password.password": hashedPassword
+							},
+							$unset: { "services.password.set": "" }
+						},
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.linkPassword",
+					value: session.userId
+				});
 
 
-			(hashedPassword, next) => {
-				db.models.user.update({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
-			}
-		], (err) => {
-			if (err) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${userId}'. '${error}'.`);
-				return cb({ status: 'failure', message: error });
+				return cb({
+					status: "success",
+					message: "Successfully added password."
+				});
 			}
 			}
-
-			logger.error("UPDATE_PASSWORD", `User '${userId}' updated their password.`);
-			cb({
-				status: 'success',
-				message: 'Password successfully updated.'
-			});
-		});
+		);
 	}),
 	}),
 
 
 	/**
 	/**
-	 * Requests a password reset for an email
+	 * Unlinks password from user
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} email - the email of the user that requests a password reset
+	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	requestPasswordReset: (session, email, cb) => {
-		let code = utils.generateRandomString(8);
-		async.waterfall([
-			(next) => {
-				if (!email || typeof email !== 'string') return next('Invalid code.');
-				email = email.toLowerCase();
-				db.models.user.findOne({"email.address": email}, next);
-			},
+	unlinkPassword: isLoginRequired(async function unlinkPassword(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Not logged in.");
+					if (!user.services.github || !user.services.github.id)
+						return next("You can't remove password login without having GitHub login.");
+					return userModel.updateOne({ _id: session.userId }, { $unset: { "services.password": "" } }, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UNLINK_PASSWORD",
+						`Unlinking password failed for userId '${session.userId}'. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "UNLINK_PASSWORD", `Unlinking password successful for userId '${session.userId}'.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.unlinkPassword",
+					value: session.userId
+				});
 
 
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (!user.services.password || !user.services.password.password) return next('User does not have a password set, and probably uses GitHub to log in.');
-				next(null, user);
-			},
+				return cb({
+					status: "success",
+					message: "Successfully unlinked password."
+				});
+			}
+		);
+	}),
 
 
-			(user, next) => {
-				let expires = new Date();
-				expires.setDate(expires.getDate() + 1);
-				db.models.user.findOneAndUpdate({"email.address": email}, {$set: {"services.password.reset": {code: code, expires}}}, next);
-			},
+	/**
+	 * Unlinks GitHub from user
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Function} cb - gets called with the result
+	 */
+	unlinkGitHub: isLoginRequired(async function unlinkGitHub(session, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					userModel.findOne({ _id: session.userId }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Not logged in.");
+					if (!user.services.password || !user.services.password.password)
+						return next("You can't remove GitHub login without having password login.");
+					return userModel.updateOne({ _id: session.userId }, { $unset: { "services.github": "" } }, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"UNLINK_GITHUB",
+						`Unlinking GitHub failed for userId '${session.userId}'. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "UNLINK_GITHUB", `Unlinking GitHub successful for userId '${session.userId}'.`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.unlinkGithub",
+					value: session.userId
+				});
 
 
-			(user, next) => {
-				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
+				return cb({
+					status: "success",
+					message: "Successfully unlinked GitHub."
+				});
 			}
 			}
-		], (err) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${error}'`);
-				cb({status: 'failure', message: error});
-			} else {
-				logger.success("REQUEST_PASSWORD_RESET", `Email '${email}' successfully requested a password reset.`);
-				cb({
-					status: 'success',
-					message: 'Successfully requested password reset.'
+		);
+	}),
+
+	/**
+	 * Requests a password reset for an email
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} email - the email of the user that requests a password reset
+	 * @param {Function} cb - gets called with the result
+	 */
+	async requestPasswordReset(session, email, cb) {
+		const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const resetPasswordRequestSchema = await MailModule.runJob(
+			"GET_SCHEMA",
+			{ schemaName: "resetPasswordRequest" },
+			this
+		);
+
+		async.waterfall(
+			[
+				next => {
+					if (!email || typeof email !== "string") return next("Invalid email.");
+					email = email.toLowerCase();
+					return userModel.findOne({ "email.address": email }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (!user.services.password || !user.services.password.password)
+						return next("User does not have a password set, and probably uses GitHub to log in.");
+					return next(null, user);
+				},
+
+				(user, next) => {
+					const expires = new Date();
+					expires.setDate(expires.getDate() + 1);
+					userModel.findOneAndUpdate(
+						{ "email.address": email },
+						{
+							$set: {
+								"services.password.reset": {
+									code,
+									expires
+								}
+							}
+						},
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(user, next) => {
+					resetPasswordRequestSchema(user.email.address, user.username, code, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REQUEST_PASSWORD_RESET",
+						`Email '${email}' failed to request password reset. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"REQUEST_PASSWORD_RESET",
+					`Email '${email}' successfully requested a password reset.`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully requested password reset."
 				});
 				});
 			}
 			}
-		});
+		);
 	},
 	},
 
 
 	/**
 	/**
 	 * Verifies a reset code
 	 * Verifies a reset code
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password reset code
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} code - the password reset code
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	verifyPasswordResetCode: (session, code, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!code || typeof code !== 'string') return next('Invalid code.');
-				db.models.user.findOne({"services.password.reset.code": code}, next);
-			},
+	async verifyPasswordResetCode(session, code, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					if (!code || typeof code !== "string") return next("Invalid code.");
+					return userModel.findOne({ "services.password.reset.code": code }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Invalid code.");
+					if (!user.services.password.reset.expires > new Date()) return next("That code has expired.");
+					return next(null);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "VERIFY_PASSWORD_RESET_CODE", `Code '${code}' successfully verified.`);
 
 
-			(user, next) => {
-				if (!user) return next('Invalid code.');
-				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
-				next(null);
-			}
-		], (err) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${error}'`);
-				cb({status: 'failure', message: error});
-			} else {
-				logger.success("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' successfully verified.`);
-				cb({
-					status: 'success',
-					message: 'Successfully verified password reset code.'
+				return cb({
+					status: "success",
+					message: "Successfully verified password reset code."
 				});
 				});
 			}
 			}
-		});
+		);
 	},
 	},
 
 
 	/**
 	/**
 	 * Changes a user's password with a reset code
 	 * Changes a user's password with a reset code
 	 *
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password reset code
-	 * @param {String} newPassword - the new password reset code
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} code - the password reset code
+	 * @param {string} newPassword - the new password reset code
 	 * @param {Function} cb - gets called with the result
 	 * @param {Function} cb - gets called with the result
 	 */
 	 */
-	changePasswordWithResetCode: (session, code, newPassword, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!code || typeof code !== 'string') return next('Invalid code.');
-				db.models.user.findOne({"services.password.reset.code": code}, next);
-			},
+	async changePasswordWithResetCode(session, code, newPassword, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		async.waterfall(
+			[
+				next => {
+					if (!code || typeof code !== "string") return next("Invalid code.");
+					return userModel.findOne({ "services.password.reset.code": code }, next);
+				},
+
+				(user, next) => {
+					if (!user) return next("Invalid code.");
+					if (!user.services.password.reset.expires > new Date()) return next("That code has expired.");
+					return next();
+				},
+
+				next => {
+					if (!DBModule.passwordValid(newPassword))
+						return next("Invalid password. Check if it meets all the requirements.");
+					return next();
+				},
+
+				next => {
+					bcrypt.genSalt(10, next);
+				},
+
+				// hash the password
+				(salt, next) => {
+					bcrypt.hash(sha256(newPassword), salt, next);
+				},
+
+				(hashedPassword, next) => {
+					userModel.updateOne(
+						{ "services.password.reset.code": code },
+						{
+							$set: {
+								"services.password.password": hashedPassword
+							},
+							$unset: { "services.password.reset": "" }
+						},
+						{ runValidators: true },
+						next
+					);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"CHANGE_PASSWORD_WITH_RESET_CODE",
+						`Code '${code}' failed to change password. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' successfully changed password.`);
 
 
-			(user, next) => {
-				if (!user) return next('Invalid code.');
-				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
-				next();
-			},
+				return cb({
+					status: "success",
+					message: "Successfully changed password."
+				});
+			}
+		);
+	},
 
 
-			(next) => {
-				bcrypt.genSalt(10, next);
-			},
+	/**
+	 * Bans a user by userId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} value - the user id that is going to be banned
+	 * @param {string} reason - the reason for the ban
+	 * @param {string} expiresAt - the time the ban expires
+	 * @param {Function} cb - gets called with the result
+	 */
+	banUserById: isAdminRequired(function banUserById(session, userId, reason, expiresAt, cb) {
+		async.waterfall(
+			[
+				next => {
+					if (!userId) return next("You must provide a userId to ban.");
+					if (!reason) return next("You must provide a reason for the ban.");
+					return next();
+				},
+
+				next => {
+					if (!expiresAt || typeof expiresAt !== "string") return next("Invalid expire date.");
+					const date = new Date();
+					switch (expiresAt) {
+						case "1h":
+							expiresAt = date.setHours(date.getHours() + 1);
+							break;
+						case "12h":
+							expiresAt = date.setHours(date.getHours() + 12);
+							break;
+						case "1d":
+							expiresAt = date.setDate(date.getDate() + 1);
+							break;
+						case "1w":
+							expiresAt = date.setDate(date.getDate() + 7);
+							break;
+						case "1m":
+							expiresAt = date.setMonth(date.getMonth() + 1);
+							break;
+						case "3m":
+							expiresAt = date.setMonth(date.getMonth() + 3);
+							break;
+						case "6m":
+							expiresAt = date.setMonth(date.getMonth() + 6);
+							break;
+						case "1y":
+							expiresAt = date.setFullYear(date.getFullYear() + 1);
+							break;
+						case "never":
+							expiresAt = new Date(3093527980800000);
+							break;
+						default:
+							return next("Invalid expire date.");
+					}
 
 
-			// hash the password
-			(salt, next) => {
-				bcrypt.hash(sha256(newPassword), salt, next);
-			},
+					return next();
+				},
+
+				next => {
+					PunishmentsModule.runJob(
+						"ADD_PUNISHMENT",
+						{
+							type: "banUserId",
+							value: userId,
+							reason,
+							expiresAt,
+							punishedBy: session.userId
+						},
+						this
+					)
+						.then(punishment => next(null, punishment))
+						.catch(next);
+				},
+
+				(punishment, next) => {
+					CacheModule.runJob("PUB", {
+						channel: "user.ban",
+						value: { userId, punishment }
+					});
+					next();
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"BAN_USER_BY_ID",
+						`User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"BAN_USER_BY_ID",
+					`User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`
+				);
 
 
-			(hashedPassword, next) => {
-				db.models.user.update({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, next);
-			}
-		], (err) => {
-			if (err && err !== true) {
-				let error = 'An error occurred.';
-				if (typeof err === "string") error = err;
-				else if (err.message) error = err.message;
-				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${error}'`);
-				cb({status: 'failure', message: error});
-			} else {
-				logger.success("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' successfully changed password.`);
-				cb({
-					status: 'success',
-					message: 'Successfully changed password.'
+				return cb({
+					status: "success",
+					message: "Successfully banned user."
 				});
 				});
 			}
 			}
-		});
-	}
+		);
+	})
 };
 };

+ 97 - 0
backend/logic/actions/utils.js

@@ -0,0 +1,97 @@
+import async from "async";
+
+import { isAdminRequired } from "./hooks";
+
+import moduleManager from "../../index";
+
+const UtilsModule = moduleManager.modules.utils;
+const WSModule = moduleManager.modules.ws;
+
+export default {
+	getModules: isAdminRequired(function getModules(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					next(null, UtilsModule.moduleManager.modules);
+				},
+
+				(modules, next) => {
+					next(
+						null,
+						Object.keys(modules).map(moduleName => {
+							const module = modules[moduleName];
+							return {
+								name: module.name,
+								status: module.status,
+								stage: module.stage,
+								jobsInQueue: module.jobQueue.lengthQueue(),
+								jobsInProgress: module.jobQueue.lengthRunning(),
+								jobsPaused: module.jobQueue.lengthPaused(),
+								concurrency: module.jobQueue.concurrency
+							};
+						})
+					);
+				}
+			],
+			async (err, modules) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_MODULES", `User ${session.userId} failed to get modules. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "GET_MODULES", `User ${session.userId} has successfully got the modules info.`);
+					cb({
+						status: "success",
+						message: "Successfully got modules.",
+						data: { modules }
+					});
+				}
+			}
+		);
+	}),
+
+	getModule: isAdminRequired(function getModule(session, moduleName, cb) {
+		async.waterfall(
+			[
+				next => {
+					next(null, UtilsModule.moduleManager.modules[moduleName]);
+				}
+			],
+			async (err, module) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_MODULE", `User ${session.userId} failed to get module. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "GET_MODULE", `User ${session.userId} has successfully got the module info.`);
+					cb({
+						status: "success",
+						message: "Successfully got module info.",
+						data: {
+							jobStatistics: module.jobStatistics
+						}
+					});
+				}
+			}
+		);
+	}),
+
+	getRooms(session, cb) {
+		WSModule.runJob("GET_ROOMS_FOR_SOCKET", { socketId: session.socketId })
+			.then(response => {
+				this.log("SUCCESS", "GET_ROOMS", `User ${session.userId} has successfully got the module info.`);
+				cb({
+					status: "success",
+					message: "Successfully got rooms.",
+					data: {
+						rooms: response
+					}
+				});
+			})
+			.catch(async err => {
+				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+				this.log("ERROR", "GET_ROOMS", `Failed to get rooms. '${err}'`);
+				cb({ status: "error", message: err });
+			});
+	}
+};

+ 468 - 0
backend/logic/activities.js

@@ -0,0 +1,468 @@
+import async from "async";
+
+import CoreClass from "../core";
+
+let ActivitiesModule;
+let DBModule;
+let CacheModule;
+let UtilsModule;
+let WSModule;
+let PlaylistsModule;
+
+class _ActivitiesModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("activities");
+
+		ActivitiesModule = this;
+	}
+
+	/**
+	 * Initialises the activities module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => {
+			DBModule = this.moduleManager.modules.db;
+			CacheModule = this.moduleManager.modules.cache;
+			UtilsModule = this.moduleManager.modules.utils;
+			WSModule = this.moduleManager.modules.ws;
+			PlaylistsModule = this.moduleManager.modules.playlists;
+
+			resolve();
+		});
+	}
+
+	/**
+	 * Adds a new activity to the database
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user who's activity is to be added
+	 * @param {string} payload.type - the type of activity (enum specified in schema)
+	 * @param {object} payload.payload - the details of the activity e.g. an array of songs that were added
+	 * @param {string} payload.payload.message - the main message describing the activity e.g. 50 songs added to playlist 'playlist name'
+	 * @param {string} payload.payload.thumbnail - url to a thumbnail e.g. song album art to be used when display an activity
+	 * @param {string} payload.payload.youtubeId - (optional) if relevant, the youtube id of the song related to the activity
+	 * @param {string} payload.payload.reportId - (optional) if relevant, the id of the report related to the activity
+	 * @param {string} payload.payload.playlistId - (optional) if relevant, the id of the playlist related to the activity
+	 * @param {string} payload.payload.stationId - (optional) if relevant, the id of the station related to the activity
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	ADD_ACTIVITY(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "activity" }, this)
+							.then(res => next(null, res))
+							.catch(next);
+					},
+
+					(ActivityModel, next) => {
+						const { userId, type } = payload;
+
+						const activity = new ActivityModel({
+							userId,
+							type,
+							payload: payload.payload
+						});
+
+						activity.save(next);
+					},
+
+					(activity, next) => {
+						WSModule.runJob("SOCKETS_FROM_USER", { userId: activity.userId }, this)
+							.then(sockets => {
+								sockets.forEach(socket =>
+									socket.dispatch("event:activity.created", { data: { activity } })
+								);
+								next(null, activity);
+							})
+							.catch(next);
+					},
+
+					(activity, next) => {
+						const mergeableActivities = ["playlist__remove_song", "playlist__add_song"];
+
+						const spammableActivities = [
+							"user__toggle_nightmode",
+							"user__toggle_autoskip_disliked_songs",
+							"user__toggle_activity_watch",
+							"song__like",
+							"song__unlike",
+							"song__dislike",
+							"song__undislike"
+						];
+
+						CacheModule.runJob("HGET", { table: "recentActivities", key: activity.userId })
+							.then(recentActivity => {
+								if (recentActivity) {
+									const timeDifference = mins =>
+										new Date() - new Date(recentActivity.createdAt) < mins * 60 * 1000;
+
+									// if both activities have the same type, if within last 15 mins and if activity is within the spammableActivities array
+									if (
+										recentActivity.type === activity.type &&
+										!!timeDifference(15) &&
+										spammableActivities.includes(activity.type)
+									)
+										return ActivitiesModule.runJob(
+											"CHECK_FOR_ACTIVITY_SPAM_TO_HIDE",
+											{ userId: activity.userId, type: activity.type },
+											this
+										)
+											.then(() => next(null, activity))
+											.catch(next);
+
+									// if activity is within the mergeableActivities array, if both activities are about removing/adding and if within last 5 mins
+									if (
+										mergeableActivities.includes(activity.type) &&
+										recentActivity.type === activity.type &&
+										!!timeDifference(5)
+									) {
+										return PlaylistsModule.runJob("GET_PLAYLIST", {
+											playlistId: activity.payload.playlistId
+										})
+											.then(playlist =>
+												ActivitiesModule.runJob(
+													"CHECK_FOR_ACTIVITY_SPAM_TO_MERGE",
+													{
+														userId: activity.userId,
+														type: activity.type,
+														playlist: {
+															playlistId: playlist._id,
+															displayName: playlist.displayName
+														}
+													},
+													this
+												)
+													.then(() => next(null, activity))
+													.catch(next)
+											)
+											.catch(next);
+									}
+									return next(null, activity);
+								}
+
+								return next(null, activity);
+							})
+							.catch(next);
+					},
+
+					// store most recent activity in cache to be quickly accessible
+					(activity, next) =>
+						CacheModule.runJob(
+							"HSET",
+							{
+								table: "recentActivities",
+								key: activity.userId,
+								value: { createdAt: activity.createdAt, type: activity.type }
+							},
+							this
+						)
+							.then(() => next(null))
+							.catch(next)
+				],
+				async (err, activity) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve(activity);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Merges activities about adding/removing songs from a playlist within a 5-minute period to prevent spam
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user to check for duplicates
+	 * @param {object} payload.playlist - object that contains info about the relevant playlist
+	 * @param {string} payload.playlist.playlistId - the id of the playlist
+	 * @param {string} payload.playlist.displayName - the display name of the playlist
+	 * @param {string} payload.type - the type of activity to check for duplicates
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async CHECK_FOR_ACTIVITY_SPAM_TO_MERGE(payload) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					// find all activities of this type from the last 5 minutes
+					next => {
+						activityModel
+							.find(
+								{
+									userId: payload.userId,
+									type: { $in: [payload.type, `${payload.type}s`] },
+									hidden: false,
+									createdAt: {
+										$gte: new Date(new Date() - 5 * 60 * 1000)
+									},
+									"payload.playlistId": payload.playlist.playlistId
+								},
+								["_id", "type", "payload.message"]
+							)
+							.sort({ createdAt: -1 })
+							.exec(next);
+					},
+
+					// hide these activities, emit to socket listeners and count number of songs in each
+					(activities, next) => {
+						let howManySongs = 0; // how many songs added/removed
+
+						activities.forEach(activity => {
+							activityModel.updateOne({ _id: activity._id }, { $set: { hidden: true } }).catch(next);
+
+							WSModule.runJob("SOCKETS_FROM_USER", { userId: payload.userId }, this)
+								.then(sockets =>
+									sockets.forEach(socket =>
+										socket.dispatch("event:activity.hidden", { data: { activityId: activity._id } })
+									)
+								)
+								.catch(next);
+
+							if (activity.type === payload.type) howManySongs += 1;
+							else if (activity.type === `${payload.type}s`)
+								howManySongs += parseInt(
+									activity.payload.message.replace(
+										/(?:Removed|Added)\s(?<songs>\d+)\ssongs.+/g,
+										"$<songs>"
+									)
+								);
+						});
+
+						return next(null, howManySongs);
+					},
+
+					// // delete in cache the most recent activity to avoid issues when adding a new activity
+					(howManySongs, next) => {
+						CacheModule.runJob("HDEL", { table: "recentActivities", key: payload.userId }, this)
+							.then(() => next(null, howManySongs))
+							.catch(next);
+					},
+
+					// add a new activity that merges the activities together
+					(howManySongs, next) => {
+						const activity = {
+							userId: payload.userId,
+							type: "",
+							payload: {
+								message: "",
+								playlistId: payload.playlist.playlistId
+							}
+						};
+
+						if (payload.type === "playlist__remove_song" || payload.type === "playlist__remove_songs") {
+							activity.payload.message = `Removed ${howManySongs} songs from playlist <playlistId>${payload.playlist.displayName}</playlistId>`;
+							activity.type = "playlist__remove_songs";
+						} else if (payload.type === "playlist__add_song" || payload.type === "playlist__add_songs") {
+							activity.payload.message = `Added ${howManySongs} songs to playlist <playlistId>${payload.playlist.displayName}</playlistId>`;
+							activity.type = "playlist__add_songs";
+						}
+
+						ActivitiesModule.runJob("ADD_ACTIVITY", activity, this)
+							.then(() => next())
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes any references to a station, playlist or song in activities
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.type - type of reference. enum: ["youtubeId", "stationId", "playlistId", "playlistId"]
+	 * @param {string} payload.stationId - (optional) the id of a station
+	 * @param {string} payload.reportId - (optional) the id of a report
+	 * @param {string} payload.playlistId - (optional) the id of a playlist
+	 * @param {string} payload.youtubeId - (optional) the id of a song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async REMOVE_ACTIVITY_REFERENCES(payload) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (
+							(payload.type !== "youtubeId" &&
+								payload.type !== "stationId" &&
+								payload.type !== "reportId" &&
+								payload.type !== "playlistId") ||
+							!payload.type
+						)
+							return next("Please use a valid reference type.");
+
+						if (!payload[payload.type]) return next(`Please provide a ${payload.type} in the job payload.`);
+
+						return next();
+					},
+
+					// find all activities that include the reference
+					next => {
+						const query = {};
+						query[`payload.${payload.type}`] = payload[payload.type];
+
+						activityModel
+							.find(query, ["_id", "userId", "payload.message"])
+							.sort({ createdAt: -1 })
+							.exec(next);
+					},
+
+					(activities, next) => {
+						async.eachLimit(
+							activities,
+							1,
+							(activity, next) => {
+								// remove the reference tags
+
+								if (payload.youtubeId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<youtubeId>(.*)<\/youtubeId>/g,
+										"$1"
+									);
+								}
+
+								if (payload.reportId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<reportId>(.*)<\/reportId>/g,
+										"$1"
+									);
+								}
+
+								if (payload.playlistId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<playlistId>(.*)<\/playlistId>/g,
+										`$1`
+									);
+								}
+
+								if (payload.stationId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<stationId>(.*)<\/stationId>/g,
+										`$1`
+									);
+								}
+
+								activityModel
+									.updateOne(
+										{ _id: activity._id },
+										{ $set: { "payload.message": activity.payload.message } }
+									)
+									.then(() => {
+										WSModule.runJob("SOCKETS_FROM_USER", { userId: activity.userId })
+											.then(sockets =>
+												sockets.forEach(socket =>
+													socket.dispatch("event:activity.updated", {
+														data: {
+															activityId: activity._id,
+															message: activity.payload.message
+														}
+													})
+												)
+											)
+											.catch(next);
+
+										return next();
+									})
+									.catch(next);
+							},
+							err => next(err)
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Hides any activities of the same type within a 15-minute period to prevent spam
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user to check for duplicates
+	 * @param {string} payload.type - the type of activity to check for duplicates
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async CHECK_FOR_ACTIVITY_SPAM_TO_HIDE(payload) {
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					// find all activities of this type from the last 15 minutes
+					next => {
+						activityModel
+							.find(
+								{
+									userId: payload.userId,
+									type: payload.type,
+									hidden: false,
+									createdAt: {
+										$gte: new Date(new Date() - 15 * 60 * 1000)
+									}
+								},
+								"_id"
+							)
+							.sort({ createdAt: -1 })
+							.skip(1)
+							.exec(next);
+					},
+
+					// hide these activities and emit to socket listeners
+					(activities, next) => {
+						activities.forEach(activity => {
+							activityModel.updateOne({ _id: activity._id }, { $set: { hidden: true } }).catch(next);
+
+							WSModule.runJob("SOCKETS_FROM_USER", { userId: payload.userId })
+								.then(sockets => {
+									sockets.forEach(socket =>
+										socket.dispatch("event:activity.hidden", {
+											data: { activityId: activity._id }
+										})
+									);
+								})
+								.catch(next);
+						});
+
+						return next();
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+}
+
+export default new _ActivitiesModule();

+ 279 - 21
backend/logic/api.js

@@ -1,27 +1,285 @@
-module.exports = {
-	init: (cb) => {
-		const { app } = require('./app.js');
-		const actions = require('./actions');
-
-		app.get('/', (req, res) => {
-			res.json({
-				status: 'success',
-				message: 'Coming Soon'
-			});
-		});
+import config from "config";
+
+import async from "async";
+import crypto from "crypto";
+
+import CoreClass from "../core";
+
+let AppModule;
+let DBModule;
+let PlaylistsModule;
+let UtilsModule;
+let PunishmentsModule;
+let CacheModule;
+let NotificationsModule;
+
+class _APIModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("api");
+	}
+
+	/**
+	 * Initialises the api module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise((resolve, reject) => {
+			AppModule = this.moduleManager.modules.app;
+			DBModule = this.moduleManager.modules.db;
+			PlaylistsModule = this.moduleManager.modules.playlists;
+			UtilsModule = this.moduleManager.modules.utils;
+			PunishmentsModule = this.moduleManager.modules.punishments;
+			CacheModule = this.moduleManager.modules.cache;
+			NotificationsModule = this.moduleManager.modules.notifications;
+
+			const SIDname = config.get("cookie.SIDname");
+
+			const isLoggedIn = (req, res, next) => {
+				let SID;
+				async.waterfall(
+					[
+						next => {
+							UtilsModule.runJob("PARSE_COOKIES", {
+								cookieString: req.headers.cookie
+							})
+								.then(res => {
+									SID = res[SIDname];
+									next(null);
+								})
+								.catch(next);
+						},
+
+						next => {
+							if (!SID) return next("No SID.");
+							return next();
+						},
 
 
-		Object.keys(actions).forEach((namespace) => {
-			Object.keys(actions[namespace]).forEach((action) => {
-				let name = `/${namespace}/${action}`;
+						next => {
+							CacheModule.runJob("HGET", { table: "sessions", key: SID }).then(session =>
+								next(null, session)
+							);
+						},
 
 
-				app.get(name, (req, res) => {
-					actions[namespace][action](null, (result) => {
-						if (typeof cb === 'function') return res.json(result);
+						(session, next) => {
+							if (!session) return next("No session found.");
+
+							session.refreshDate = Date.now();
+
+							req.session = session;
+
+							return CacheModule.runJob("HSET", {
+								table: "sessions",
+								key: SID,
+								value: session
+							}).then(session => {
+								next(null, session);
+							});
+						},
+
+						(res, next) => {
+							// check if a session's user / IP is banned
+							PunishmentsModule.runJob("GET_PUNISHMENTS", {})
+								.then(punishments => {
+									const isLoggedIn = !!(req.session && req.session.refreshDate);
+									const userId = isLoggedIn ? req.session.userId : null;
+
+									const banishment = { banned: false, ban: 0 };
+
+									punishments.forEach(punishment => {
+										if (punishment.expiresAt > banishment.ban) banishment.ban = punishment;
+										if (
+											punishment.type === "banUserId" &&
+											isLoggedIn &&
+											punishment.value === userId
+										)
+											banishment.banned = true;
+										if (punishment.type === "banUserIp" && punishment.value === req.ip)
+											banishment.banned = true;
+									});
+
+									req.banishment = banishment;
+
+									next();
+								})
+								.catch(() => {
+									next();
+								});
+						}
+					],
+					err => {
+						if (err) return res.json({ status: "error", message: "You are not logged in" });
+						return next();
+					}
+				);
+			};
+
+			AppModule.runJob("GET_APP", {})
+				.then(response => {
+					response.app.get("/", (req, res) => {
+						res.json({
+							status: "success",
+							message: "Coming Soon"
+						});
 					});
 					});
+
+					response.app.get("/export/playlist/:playlistId", async (req, res) => {
+						const { playlistId } = req.params;
+
+						const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId })
+							.then(playlist => {
+								if (playlist.privacy === "public") res.json({ status: "success", playlist });
+								else {
+									isLoggedIn(req, res, () => {
+										if (playlist.createdBy === req.session.userId)
+											res.json({ status: "success", playlist });
+										else {
+											userModel.findOne({ _id: req.session.userId }, (err, user) => {
+												if (err) res.json({ status: "error", message: err.message });
+												else if (user.role === "admin")
+													res.json({ status: "success", playlist });
+												else
+													res.json({
+														status: "error",
+														message: "You're not allowed to download this playlist."
+													});
+											});
+										}
+									});
+								}
+							})
+							.catch(err => {
+								res.json({ status: "error", message: err.message });
+							});
+					});
+
+					if (config.get("debug.stationIssue")) {
+						response.app.get("/debug_station", async (req, res) => {
+							const responseObject = {};
+
+							const stationModel = await DBModule.runJob("GET_MODEL", {
+								modelName: "station"
+							});
+
+							async.waterfall(
+								[
+									next => {
+										stationModel.find({}, next);
+									},
+
+									(stations, next) => {
+										responseObject.mongo = {
+											stations
+										};
+										next();
+									},
+
+									next => {
+										CacheModule.runJob("HGETALL", { table: "stations" })
+											.then(stations => {
+												next(null, stations);
+											})
+											.catch(err => {
+												console.log(err);
+												next(err);
+											});
+									},
+
+									(stations, next) => {
+										responseObject.redis = {
+											stations
+										};
+										next();
+									},
+
+									next => {
+										responseObject.cryptoExamples = {};
+										responseObject.mongo.stations.forEach(station => {
+											const payloadName = `stations.nextSong?id=${station._id}`;
+											responseObject.cryptoExamples[station._id] = crypto
+												.createHash("md5")
+												.update(`_notification:${payloadName}_`)
+												.digest("hex");
+										});
+										next();
+									},
+
+									next => {
+										NotificationsModule.pub.keys("*", next);
+									},
+
+									(redisKeys, next) => {
+										responseObject.redis = {
+											...redisKeys,
+											ttl: {}
+										};
+										async.eachLimit(
+											redisKeys,
+											1,
+											(redisKey, next) => {
+												NotificationsModule.pub.ttl(redisKey, (err, ttl) => {
+													responseObject.redis.ttl[redisKey] = ttl;
+													next(err);
+												});
+											},
+											next
+										);
+									},
+
+									next => {
+										responseObject.debugLogs = this.moduleManager.debugLogs.stationIssue;
+										next();
+									},
+
+									next => {
+										responseObject.debugJobs = this.moduleManager.debugJobs;
+										next();
+									}
+								],
+								err => {
+									if (err) {
+										console.log(err);
+										return res.json({
+											error: err,
+											objectSoFar: responseObject
+										});
+									}
+
+									const responseJson = JSON.stringify(responseObject, (key, value) => {
+										if (
+											key === "module" ||
+											key === "task" ||
+											key === "onFinish" ||
+											key === "server" ||
+											key === "nsp" ||
+											key === "socket" ||
+											key === "res" ||
+											key === "client" ||
+											key === "_idleNext" ||
+											key === "_idlePrev"
+										) {
+											return undefined;
+										}
+										if (key === "parentJob" && value) return value.toString();
+										return value;
+									});
+
+									return res.end(responseJson);
+								}
+							);
+						});
+					}
+
+					resolve();
+				})
+				.catch(err => {
+					reject(err);
 				});
 				});
-			})
 		});
 		});
-
-		cb();
 	}
 	}
-}
+}
+
+export default new _APIModule();

+ 488 - 143
backend/logic/app.js

@@ -1,174 +1,519 @@
-'use strict';
+import config from "config";
+import axios from "axios";
+import async from "async";
+import cors from "cors";
+import cookieParser from "cookie-parser";
+import bodyParser from "body-parser";
+import express from "express";
+import oauth from "oauth";
+import http from "http";
+import CoreClass from "../core";
 
 
-const express = require('express');
-const bodyParser = require('body-parser');
-const cors = require('cors');
-const config = require('config');
-const async = require('async');
-const logger = require('./logger');
-const mail = require('./mail');
-const request = require('request');
-const OAuth2 = require('oauth').OAuth2;
+const { OAuth2 } = oauth;
 
 
-const api = require('./api');
-const cache = require('./cache');
-const db = require('./db');
+let AppModule;
+let MailModule;
+let CacheModule;
+let DBModule;
+let ActivitiesModule;
+let PlaylistsModule;
+let UtilsModule;
 
 
-let utils;
+class _AppModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("app");
 
 
-const lib = {
+		AppModule = this;
+	}
 
 
-	app: null,
-	server: null,
+	/**
+	 * Initialises the app module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => {
+			MailModule = this.moduleManager.modules.mail;
+			CacheModule = this.moduleManager.modules.cache;
+			DBModule = this.moduleManager.modules.db;
+			ActivitiesModule = this.moduleManager.modules.activities;
+			PlaylistsModule = this.moduleManager.modules.playlists;
+			UtilsModule = this.moduleManager.modules.utils;
 
 
-	init: (cb) => {
+			const app = (this.app = express());
+			const SIDname = config.get("cookie.SIDname");
+			this.server = http.createServer(app).listen(config.get("serverPort"));
 
 
-		utils = require('./utils');
+			app.use(cookieParser());
 
 
-		let app = lib.app = express();
+			app.use(bodyParser.json());
+			app.use(bodyParser.urlencoded({ extended: true }));
 
 
-		lib.server = app.listen(config.get('serverPort'));
+			let userModel;
+			DBModule.runJob("GET_MODEL", { modelName: "user" })
+				.then(model => {
+					userModel = model;
+				})
+				.catch(console.error);
 
 
-		app.use(bodyParser.json());
-		app.use(bodyParser.urlencoded({ extended: true }));
+			const corsOptions = { ...config.get("cors"), credentials: true };
 
 
-		let corsOptions = Object.assign({}, config.get('cors'));
+			app.use(cors(corsOptions));
+			app.options("*", cors(corsOptions));
 
 
-		app.use(cors(corsOptions));
-		app.options('*', cors(corsOptions));
+			const oauth2 = new OAuth2(
+				config.get("apis.github.client"),
+				config.get("apis.github.secret"),
+				"https://github.com/",
+				"login/oauth/authorize",
+				"login/oauth/access_token",
+				null
+			);
 
 
-		let oauth2 = new OAuth2(
-			config.get('apis.github.client'),
-			config.get('apis.github.secret'),
-			'https://github.com/',
-			'login/oauth/authorize',
-			'login/oauth/access_token',
-			null
-		);
+			const redirectUri = `${config.get("serverDomain")}/auth/github/authorize/callback`;
 
 
-		let redirect_uri = config.get('serverDomain') + '/auth/github/authorize/callback';
+			/**
+			 * @param {object} res - response object from Express
+			 * @param {string} err - custom error message
+			 */
+			function redirectOnErr(res, err) {
+				res.redirect(`${config.get("domain")}?err=${encodeURIComponent(err)}`);
+			}
 
 
-		app.get('/auth/github/authorize', (req, res) => {
-			let params = [
-				`client_id=${config.get('apis.github.client')}`,
-				`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
-				`scope=user:email`
-			].join('&');
-			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-		});
+			app.get("/auth/github/authorize", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				}
 
 
-		function redirectOnErr (res, err){
-			return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
-		}
-
-		app.get('/auth/github/authorize/callback', (req, res) => {
-			let code = req.query.code;
-			oauth2.getOAuthAccessToken(code, { redirect_uri }, (err, access_token, refresh_token, results) => {
-				if (!err) request.get({
-						url: `https://api.github.com/user?access_token=${access_token}`,
-						headers: { 'User-Agent': 'request' }
-					}, (err, httpResponse, body) => {
-						if (err) return redirectOnErr(res, err.message);
-						body = JSON.parse(body);
-						db.models.user.findOne({'services.github.id': body.id}, (err, user) => {
-							if (err) return redirectOnErr(res, 'err');
-							if (user) {
-								user.services.github.access_token = access_token;
-								user.save(err => {
-									if (err) return redirectOnErr(res, err.message);
-									let sessionId = utils.guid();
-									cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), err => {
-										if (err) return redirectOnErr(res, err.message);
-										let date = new Date();
-										date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-										res.cookie('SID', sessionId, {expires: date, secure: config.get("cookie.secure"), path: "/", domain: config.get("cookie.domain")});
-										res.redirect(`${config.get('domain')}/`);
-									});
-								});
-							} else {
-								db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i') }, (err, user) => {
-									if (err) return redirectOnErr(res, err.message);
-									if (user) return redirectOnErr(res, 'An account with that username already exists.');
-									else request.get({
-										url: `https://api.github.com/user/emails?access_token=${access_token}`,
-										headers: {'User-Agent': 'request'}
-									}, (err, httpResponse, body2) => {
-										if (err) return redirectOnErr(res, err.message);
-										body2 = JSON.parse(body2);
-										let address;
-										if (!Array.isArray(body2)) return redirectOnErr(res, body2.message);
-										body2.forEach(email => {
-											if (email.primary) address = email.email.toLowerCase();
-										});
-										db.models.user.findOne({'email.address': address}, (err, user) => {
-											let verificationToken = utils.generateRandomString(64);
-											if (err) return redirectOnErr(res, err.message);
-											if (user) return redirectOnErr(res, 'An account with that email address already exists.');
-											else db.models.user.create({
-												_id: utils.generateRandomString(12),//TODO Check if exists
-												username: body.login,
-												email: {
-													address,
-													verificationToken: verificationToken
+				const params = [
+					`client_id=${config.get("apis.github.client")}`,
+					`redirect_uri=${config.get("serverDomain")}/auth/github/authorize/callback`,
+					`scope=user:email`
+				].join("&");
+				return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
+
+			app.get("/auth/github/link", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				}
+
+				const params = [
+					`client_id=${config.get("apis.github.client")}`,
+					`redirect_uri=${config.get("serverDomain")}/auth/github/authorize/callback`,
+					`scope=user:email`,
+					`state=${req.cookies[SIDname]}`
+				].join("&");
+				return res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
+
+			app.get("/auth/github/authorize/callback", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
+				}
+
+				const { code } = req.query;
+				let accessToken;
+				let body;
+				let address;
+
+				const { state } = req.query;
+
+				const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 });
+
+				return async.waterfall(
+					[
+						next => {
+							if (req.query.error) return next(req.query.error_description);
+							return next();
+						},
+
+						next => {
+							oauth2.getOAuthAccessToken(code, { redirect_uri: redirectUri }, next);
+						},
+
+						(_accessToken, refreshToken, results, next) => {
+							if (results.error) return next(results.error_description);
+
+							accessToken = _accessToken;
+
+							const options = {
+								headers: {
+									"User-Agent": "request",
+									Authorization: `token ${accessToken}`
+								}
+							};
+
+							return axios
+								.get("https://api.github.com/user", options)
+								.then(github => next(null, github))
+								.catch(err => next(err));
+						},
+
+						(github, next) => {
+							if (github.status !== 200) return next(github.data.message);
+
+							if (state) {
+								return async.waterfall(
+									[
+										next => {
+											CacheModule.runJob("HGET", {
+												table: "sessions",
+												key: state
+											})
+												.then(session => next(null, session))
+												.catch(next);
+										},
+
+										(session, next) => {
+											if (!session) return next("Invalid session.");
+											return userModel.findOne({ _id: session.userId }, next);
+										},
+
+										(user, next) => {
+											if (!user) return next("User not found.");
+											if (user.services.github && user.services.github.id)
+												return next("Account already has GitHub linked.");
+
+											return userModel.updateOne(
+												{ _id: user._id },
+												{
+													$set: {
+														"services.github": {
+															id: github.data.id,
+															access_token: accessToken
+														}
+													}
 												},
 												},
-												services: {
-													github: {id: body.id, access_token}
+												{ runValidators: true },
+												err => {
+													if (err) return next(err);
+													return next(null, user, github.data);
 												}
 												}
-											}, (err, user) => {
-												if (err) return redirectOnErr(res, err.message);
-												mail.schemas.verifyEmail(address, body.login, verificationToken);
-												let sessionId = utils.guid();
-												cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), err => {
-													if (err) return redirectOnErr(res, err.message);
-													let date = new Date();
-													date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-													res.cookie('SID', sessionId, {expires: date, secure: config.get("cookie.secure"), path: "/", domain: config.get("cookie.domain")});
-													res.redirect(`${config.get('domain')}/`);
-												});
+											);
+										},
+
+										user => {
+											CacheModule.runJob("PUB", {
+												channel: "user.linkGithub",
+												value: user._id
 											});
 											});
-										});
-									});
-								});
+
+											res.redirect(`${config.get("domain")}/settings?tab=security`);
+										}
+									],
+									next
+								);
+							}
+
+							if (!github.data.id) return next("Something went wrong, no id.");
+
+							return userModel.findOne({ "services.github.id": github.data.id }, (err, user) => {
+								next(err, user, github.data);
+							});
+						},
+
+						(user, _body, next) => {
+							body = _body;
+
+							if (user) {
+								user.services.github.access_token = accessToken;
+								return user.save(() => next(true, user._id));
+							}
+
+							return userModel.findOne({ username: new RegExp(`^${body.login}$`, "i") }, (err, user) =>
+								next(err, user)
+							);
+						},
+
+						(user, next) => {
+							if (user) return next(`An account with that username already exists.`);
+
+							return axios
+								.get("https://api.github.com/user/emails", {
+									headers: {
+										"User-Agent": "request",
+										Authorization: `token ${accessToken}`
+									}
+								})
+								.then(res => next(null, res.data))
+								.catch(err => next(err));
+						},
+
+						(body, next) => {
+							if (!Array.isArray(body)) return next(body.message);
+
+							body.forEach(email => {
+								if (email.primary) address = email.email.toLowerCase();
+							});
+
+							return userModel.findOne({ "email.address": address }, next);
+						},
+
+						(user, next) => {
+							UtilsModule.runJob("GENERATE_RANDOM_STRING", {
+								length: 12
+							}).then(_id => next(null, user, _id));
+						},
+
+						(user, _id, next) => {
+							if (user) {
+								if (Object.keys(JSON.parse(user.services.github)).length === 0)
+									return next(
+										`An account with that email address exists, but is not linked to GitHub.`
+									);
+								return next(`An account with that email address already exists.`);
 							}
 							}
+
+							return next(null, {
+								_id,
+								username: body.login,
+								name: body.name,
+								location: body.location,
+								bio: body.bio,
+								email: {
+									address,
+									verificationToken
+								},
+								services: {
+									github: { id: body.id, access_token: accessToken }
+								}
+							});
+						},
+
+						// generate the url for gravatar avatar
+						(user, next) => {
+							UtilsModule.runJob("CREATE_GRAVATAR", {
+								email: user.email.address
+							}).then(url => {
+								user.avatar = { type: "gravatar", url };
+								next(null, user);
+							});
+						},
+
+						// save the new user to the database
+						(user, next) => {
+							userModel.create(user, next);
+						},
+
+						(user, next) => {
+							MailModule.runJob("GET_SCHEMA", {
+								schemaName: "verifyEmail"
+							}).then(verifyEmailSchema => {
+								verifyEmailSchema(address, body.login, user.email.verificationToken, err => {
+									next(err, user._id);
+								});
+							});
+						},
+
+						// create a liked songs playlist for the new user
+						(userId, next) => {
+							PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+								userId,
+								displayName: "Liked Songs",
+								type: "user"
+							})
+								.then(likedSongsPlaylist => {
+									next(null, likedSongsPlaylist, userId);
+								})
+								.catch(err => next(err));
+						},
+
+						// create a disliked songs playlist for the new user
+						(likedSongsPlaylist, userId, next) => {
+							PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+								userId,
+								displayName: "Disliked Songs",
+								type: "user"
+							})
+								.then(dislikedSongsPlaylist => {
+									next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);
+								})
+								.catch(err => next(err));
+						},
+
+						// associate liked + disliked songs playlist to the user object
+						({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => {
+							userModel.updateOne(
+								{ _id: userId },
+								{ $set: { likedSongsPlaylist, dislikedSongsPlaylist } },
+								{ runValidators: true },
+								err => {
+									if (err) return next(err);
+									return next(null, userId);
+								}
+							);
+						},
+
+						// add the activity of account creation
+						(userId, next) => {
+							ActivitiesModule.runJob("ADD_ACTIVITY", {
+								userId,
+								type: "user__joined",
+								payload: { message: "Welcome to Musare!" }
+							});
+
+							next(null, userId);
+						}
+					],
+					async (err, userId) => {
+						if (err && err !== true) {
+							err = await UtilsModule.runJob("GET_ERROR", {
+								error: err
+							});
+
+							this.log(
+								"ERROR",
+								"AUTH_GITHUB_AUTHORIZE_CALLBACK",
+								`Failed to authorize with GitHub. "${err}"`
+							);
+
+							return redirectOnErr(res, err);
+						}
+
+						const sessionId = await UtilsModule.runJob("GUID", {});
+						const sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
+							schemaName: "session"
 						});
 						});
-					});
-				else return redirectOnErr(res, 'err');
-			});
-		});
 
 
-		app.get('/auth/verify_email', (req, res) => {
-			let code = req.query.code;
+						return CacheModule.runJob("HSET", {
+							table: "sessions",
+							key: sessionId,
+							value: sessionSchema(sessionId, userId)
+						})
+							.then(() => {
+								const date = new Date();
+								date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
 
 
-			async.waterfall([
-				(next) => {
-					if (!code) return next('Invalid code.');
-					next();
-				},
+								res.cookie(SIDname, sessionId, {
+									expires: date,
+									secure: config.get("cookie.secure"),
+									path: "/",
+									domain: config.get("cookie.domain")
+								});
 
 
-				(next) => {
-					db.models.user.findOne({"email.verificationToken": code}, next);
-				},
+								this.log(
+									"INFO",
+									"AUTH_GITHUB_AUTHORIZE_CALLBACK",
+									`User "${userId}" successfully authorized with GitHub.`
+								);
 
 
-				(user, next) => {
-					if (!user) return next('User not found.');
-					if (user.email.verified) return next('This email is already verified.');
-					db.models.user.update({"email.verificationToken": code}, {$set: {"email.verified": true}, $unset: {"email.verificationToken": ''}}, next);
-				}
-			], (err) => {
-				if (err) {
-					let error = 'An error occurred.';
-					if (typeof err === "string") error = err;
-					else if (err.message) error = err.message;
-					logger.error("VERIFY_EMAIL", `Verifying email failed. "${error}"`);
-					return res.json({ status: 'failure', message: error});
+								res.redirect(`${config.get("domain")}/`);
+							})
+							.catch(err => redirectOnErr(res, err.message));
+					}
+				);
+			});
+
+			app.get("/auth/verify_email", async (req, res) => {
+				if (this.getStatus() !== "READY") {
+					this.log(
+						"INFO",
+						"APP_REJECTED_GITHUB_AUTHORIZE",
+						`A user tried to use github authorize, but the APP module is currently not ready.`
+					);
+					return redirectOnErr(res, "Something went wrong on our end. Please try again later.");
 				}
 				}
-				logger.success("VERIFY_EMAIL", `Successfully verified email.`);
-				res.redirect(config.get("domain"));
+
+				const { code } = req.query;
+
+				return async.waterfall(
+					[
+						next => {
+							if (!code) return next("Invalid code.");
+							return next();
+						},
+
+						next => {
+							userModel.findOne({ "email.verificationToken": code }, next);
+						},
+
+						(user, next) => {
+							if (!user) return next("User not found.");
+							if (user.email.verified) return next("This email is already verified.");
+
+							return userModel.updateOne(
+								{ "email.verificationToken": code },
+								{
+									$set: { "email.verified": true },
+									$unset: { "email.verificationToken": "" }
+								},
+								{ runValidators: true },
+								next
+							);
+						}
+					],
+					err => {
+						if (err) {
+							let error = "An error occurred.";
+
+							if (typeof err === "string") error = err;
+							else if (err.message) error = err.message;
+
+							this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${error}"`);
+
+							return res.json({
+								status: "error",
+								message: error
+							});
+						}
+
+						this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`);
+
+						return res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
+					}
+				);
 			});
 			});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Returns the express server
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SERVER() {
+		return new Promise(resolve => {
+			resolve(AppModule.server);
 		});
 		});
+	}
 
 
-		cb();
+	/**
+	 * Returns the app object
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_APP() {
+		return new Promise(resolve => {
+			resolve({ app: AppModule.app });
+		});
 	}
 	}
-};
 
 
-module.exports = lib;
+	// EXAMPLE_JOB() {
+	// 	return new Promise((resolve, reject) => {
+	// 		if (true) resolve({});
+	// 		else reject(new Error("Nothing changed."));
+	// 	});
+	// }
+}
+
+export default new _AppModule();

+ 225 - 122
backend/logic/cache/index.js

@@ -1,182 +1,285 @@
-'use strict';
+import config from "config";
+import redis from "redis";
+import mongoose from "mongoose";
 
 
-const redis = require('redis');
+import CoreClass from "../../core";
 
 
 // Lightweight / convenience wrapper around redis module for our needs
 // Lightweight / convenience wrapper around redis module for our needs
 
 
-const pubs = {}, subs = {};
-let initialized = false;
-let callbacks = [];
+const pubs = {};
+const subs = {};
 
 
-const lib = {
+let CacheModule;
 
 
-	client: null,
-	url: '',
-	schemas: {
-		session: require('./schemas/session'),
-		station: require('./schemas/station'),
-		playlist: require('./schemas/playlist'),
-		officialPlaylist: require('./schemas/officialPlaylist'),
-		song: require('./schemas/song')
-	},
+class _CacheModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("cache");
+
+		CacheModule = this;
+	}
 
 
 	/**
 	/**
-	 * Initializes the cache module
+	 * Initialises the cache/redis module
 	 *
 	 *
-	 * @param {String} url - the url of the redis server
-	 * @param {Function} cb - gets called once we're done initializing
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	init: (url, cb) => {
-		lib.url = url;
+	async initialize() {
+		const importSchema = schemaName =>
+			new Promise(resolve => {
+				import(`./schemas/${schemaName}`).then(schema => resolve(schema.default));
+			});
 
 
-		lib.client = redis.createClient({ url: lib.url });
-		lib.client.on('error', (err) => {
-			console.error(err);
-			process.exit();
-		});
+		this.schemas = {
+			session: await importSchema("session"),
+			station: await importSchema("station"),
+			playlist: await importSchema("playlist"),
+			officialPlaylist: await importSchema("officialPlaylist"),
+			song: await importSchema("song"),
+			punishment: await importSchema("punishment"),
+			recentActivity: await importSchema("recentActivity")
+		};
 
 
-		initialized = true;
-		callbacks.forEach((callback) => {
-			callback();
-		});
+		return new Promise((resolve, reject) => {
+			this.url = config.get("redis").url;
+			this.password = config.get("redis").password;
+
+			this.log("INFO", "Connecting...");
+
+			this.client = redis.createClient({
+				url: this.url,
+				password: this.password,
+				retry_strategy: options => {
+					if (this.getStatus() === "LOCKDOWN") return;
+					if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
+
+					this.log("INFO", `Attempting to reconnect.`);
 
 
-		cb();
-	},
+					if (options.attempt >= 10) {
+						this.log("ERROR", `Stopped trying to reconnect.`);
+
+						this.setStatus("FAILED");
+					}
+				}
+			});
+
+			this.client.on("error", err => {
+				if (this.getStatus() === "INITIALIZING") reject(err);
+				if (this.getStatus() === "LOCKDOWN") return;
+
+				this.log("ERROR", `Error ${err.message}.`);
+			});
+
+			this.client.on("connect", () => {
+				this.log("INFO", "Connected succesfully.");
+
+				if (this.getStatus() === "INITIALIZING") resolve();
+				else if (this.getStatus() === "FAILED" || this.getStatus() === "RECONNECTING") this.setStatus("READY");
+			});
+		});
+	}
 
 
 	/**
 	/**
-	 * Gracefully closes all the Redis client connections
+	 * Quits redis client
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	quit: () => {
-		lib.client.quit();
-		Object.keys(pubs).forEach((channel) => pubs[channel].quit());
-		Object.keys(subs).forEach((channel) => subs[channel].client.quit());
-	},
+	QUIT() {
+		return new Promise(resolve => {
+			if (CacheModule.client.connected) {
+				CacheModule.client.quit();
+				Object.keys(pubs).forEach(channel => pubs[channel].quit());
+				Object.keys(subs).forEach(channel => subs[channel].client.quit());
+			}
+			resolve();
+		});
+	}
 
 
 	/**
 	/**
 	 * Sets a single value in a table
 	 * Sets a single value in a table
 	 *
 	 *
-	 * @param {String} table - name of the table we want to set a key of (table === redis hash)
-	 * @param {String} key -  name of the key to set
-	 * @param {*} value - the value we want to set
-	 * @param {Function} cb - gets called when the value has been set in Redis
-	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.table - name of the table we want to set a key of (table === redis hash)
+	 * @param {string} payload.key -  name of the key to set
+	 * @param {*} payload.value - the value we want to set
+	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	hset: (table, key, value, cb, stringifyJson = true) => {
+	HSET(payload) {
+		return new Promise((resolve, reject) => {
+			let { key } = payload;
+			let { value } = payload;
 
 
-		// automatically stringify objects and arrays into JSON
-		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+			// automatically stringify objects and arrays into JSON
+			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
 
 
-		lib.client.hset(table, key, value, err => {
-			if (cb !== undefined) {
-				if (err) return cb(err);
-				cb(null, JSON.parse(value));
-			}
+			CacheModule.client.hset(payload.table, key, value, err => {
+				if (err) return reject(new Error(err));
+				return resolve(JSON.parse(value));
+			});
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Gets a single value from a table
 	 * Gets a single value from a table
 	 *
 	 *
-	 * @param {String} table - name of the table to get the value from (table === redis hash)
-	 * @param {String} key - name of the key to fetch
-	 * @param {Function} cb - gets called when the value is returned from Redis
-	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.table - name of the table to get the value from (table === redis hash)
+	 * @param {string} payload.key - name of the key to fetch
+	 * @param {boolean} [payload.parseJson=true] - attempt to parse returned data as JSON
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	hget: (table, key, cb, parseJson = true) => {
-		if (!key || !table) return cb(null, null);
-		lib.client.hget(table, key, (err, value) => {
-			if (err) return typeof cb === 'function' ? cb(err) : null;
-			if (parseJson) try {
-				value = JSON.parse(value);
-			} catch (e) {
-			}
-			if (typeof cb === 'function') cb(null, value);
+	HGET(payload) {
+		return new Promise((resolve, reject) => {
+			let { key } = payload;
+
+			if (!key) return reject(new Error("Invalid key!"));
+			if (!payload.table) return reject(new Error("Invalid table!"));
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+			return CacheModule.client.hget(payload.table, key, (err, value) => {
+				if (err) return reject(new Error(err));
+				try {
+					value = JSON.parse(value);
+				} catch (e) {
+					return reject(err);
+				}
+
+				return resolve(value);
+			});
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Deletes a single value from a table
 	 * Deletes a single value from a table
 	 *
 	 *
-	 * @param {String} table - name of the table to delete the value from (table === redis hash)
-	 * @param {String} key - name of the key to delete
-	 * @param {Function} cb - gets called when the value has been deleted from Redis or when it returned an error
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.table - name of the table to delete the value from (table === redis hash)
+	 * @param {string} payload.key - name of the key to delete
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	hdel: (table, key, cb) => {
-		if (!key || !table) return cb(null, null);
-		lib.client.hdel(table, key, (err) => {
-			if (err) return typeof cb === 'function' ? cb(err) : null;
-			if (typeof cb === 'function') cb(null);
+	HDEL(payload) {
+		return new Promise((resolve, reject) => {
+			let { key } = payload;
+
+			if (!payload.table) return reject(new Error("Invalid table!"));
+			if (!key) return reject(new Error("Invalid key!"));
+
+			if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+			return CacheModule.client.hdel(payload.table, key, err => {
+				if (err) return reject(new Error(err));
+				return resolve();
+			});
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Returns all the keys for a table
 	 * Returns all the keys for a table
 	 *
 	 *
-	 * @param {String} table - name of the table to get the values from (table === redis hash)
-	 * @param {Function} cb - gets called when the values are returned from Redis
-	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.table - name of the table to get the values from (table === redis hash)
+	 * @param {boolean} [payload.parseJson=true] - attempts to parse all values as JSON by default
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	hgetall: (table, cb, parseJson = true) => {
-		if (!table) return cb(null, null);
-		lib.client.hgetall(table, (err, obj) => {
-			if (err) return typeof cb === 'function' ? cb(err) : null;
-			if (parseJson && obj) Object.keys(obj).forEach((key) => { try { obj[key] = JSON.parse(obj[key]); } catch (e) {} });
-			cb(null, obj);
+	HGETALL(payload) {
+		return new Promise((resolve, reject) => {
+			if (!payload.table) return reject(new Error("Invalid table!"));
+
+			return CacheModule.client.hgetall(payload.table, (err, obj) => {
+				if (err) return reject(new Error(err));
+				if (obj)
+					Object.keys(obj).forEach(key => {
+						obj[key] = JSON.parse(obj[key]);
+					});
+				else if (!obj) obj = [];
+
+				return resolve(obj);
+			});
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
 	 * Publish a message to a channel, caches the redis client connection
 	 * Publish a message to a channel, caches the redis client connection
 	 *
 	 *
-	 * @param {String} channel - the name of the channel we want to publish a message to
-	 * @param {*} value - the value we want to send
-	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.channel - the name of the channel we want to publish a message to
+	 * @param {*} payload.value - the value we want to send
+	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	pub: (channel, value, stringifyJson = true) => {
+	PUB(payload) {
+		return new Promise((resolve, reject) => {
+			let { value } = payload;
 
 
-		/*if (pubs[channel] === undefined) {
-		 pubs[channel] = redis.createClient({ url: lib.url });
-		 pubs[channel].on('error', (err) => console.error);
-		 }*/
+			if (!payload.channel) return reject(new Error("Invalid channel!"));
+			if (!value) return reject(new Error("Invalid value!"));
 
 
-		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
+			if (["object", "array"].includes(typeof value)) value = JSON.stringify(value);
 
 
-		//pubs[channel].publish(channel, value);
-		lib.client.publish(channel, value);
-	},
+			return CacheModule.client.publish(payload.channel, value, err => {
+				if (err) reject(err);
+				else resolve();
+			});
+		});
+	}
 
 
 	/**
 	/**
 	 * Subscribe to a channel, caches the redis client connection
 	 * Subscribe to a channel, caches the redis client connection
 	 *
 	 *
-	 * @param {String} channel - name of the channel to subscribe to
-	 * @param {Function} cb - gets called when a message is received
-	 * @param {Boolean} [parseJson=true] - parse the message as JSON
+	 * @param {object} payload - object containing payload
+	 * @param {string} payload.channel - name of the channel to subscribe to
+	 * @param {boolean} [payload.parseJson=true] - parse the message as JSON
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	sub: (channel, cb, parseJson = true) => {
-		if (initialized) subToChannel();
-		else {
-			callbacks.push(() => {
-				subToChannel();
-			});
-		}
-		function subToChannel() {
-			if (subs[channel] === undefined) {
-				subs[channel] = { client: redis.createClient({ url: lib.url }), cbs: [] };
-				subs[channel].client.on('error', (err) => {
-					console.error(err);
-					process.exit();
-				});
-				subs[channel].client.on('message', (channel, message) => {
-					if (parseJson) try { message = JSON.parse(message); } catch (e) {}
-					subs[channel].cbs.forEach((cb) => cb(message));
+	SUB(payload) {
+		return new Promise((resolve, reject) => {
+			if (!payload.channel) return reject(new Error("Invalid channel!"));
+
+			if (subs[payload.channel] === undefined) {
+				subs[payload.channel] = {
+					client: redis.createClient({
+						url: CacheModule.url,
+						password: CacheModule.password
+					}),
+					cbs: []
+				};
+
+				subs[payload.channel].client.on("message", (channel, message) => {
+					if (message.startsWith("[") || message.startsWith("{"))
+						try {
+							message = JSON.parse(message);
+						} catch (err) {
+							console.error(err);
+						}
+					else if (message.startsWith('"') && message.endsWith('"'))
+						message = message.substring(1).substring(0, message.length - 2);
+
+					return subs[channel].cbs.forEach(cb => cb(message));
 				});
 				});
-				subs[channel].client.subscribe(channel);
+
+				subs[payload.channel].client.subscribe(payload.channel);
 			}
 			}
-			
-			subs[channel].cbs.push(cb);
-		}
+
+			subs[payload.channel].cbs.push(payload.cb);
+
+			return resolve();
+		});
 	}
 	}
 
 
-};
+	/**
+	 * Returns a redis schema
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.schemaName - the name of the schema to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_SCHEMA(payload) {
+		return new Promise(resolve => {
+			resolve(CacheModule.schemas[payload.schemaName]);
+		});
+	}
+}
 
 
-module.exports = lib;
+export default new _CacheModule();

+ 4 - 8
backend/logic/cache/schemas/officialPlaylist.js

@@ -1,8 +1,4 @@
-'use strict';
-
-module.exports = (stationId, songs) => {
-	return {
-		stationId,
-		songs
-	}
-};
+export default (stationId, songs) => ({
+	stationId,
+	songs
+});

+ 3 - 7
backend/logic/cache/schemas/playlist.js

@@ -1,13 +1,9 @@
-'use strict';
-
 /**
 /**
  * Schema for a playlist stored / cached in redis,
  * Schema for a playlist stored / cached in redis,
  * gets created when a playlist is in use
  * gets created when a playlist is in use
  * and therefore is put into the redis cache
  * and therefore is put into the redis cache
  *
  *
- * @param playlist
- * @returns {Object}
+ * @param {object} playlist - object containing the playlist
+ * @returns {object} - returns same object
  */
  */
-module.exports = (playlist) => {
-	return playlist;
-};
+export default playlist => playlist;

+ 7 - 0
backend/logic/cache/schemas/punishment.js

@@ -0,0 +1,7 @@
+export default (punishment, punishmentId) => ({
+	type: punishment.type,
+	value: punishment.value,
+	reason: punishment.reason,
+	expiresAt: new Date(punishment.expiresAt).getTime(),
+	punishmentId
+});

+ 4 - 0
backend/logic/cache/schemas/recentActivity.js

@@ -0,0 +1,4 @@
+export default activity => ({
+	type: activity.type,
+	createdAt: activity.createdAt
+});

+ 6 - 9
backend/logic/cache/schemas/session.js

@@ -1,9 +1,6 @@
-'use strict';
-
-module.exports = (sessionId, userId) => {
-	return {
-		sessionId: sessionId,
-		userId: userId,
-		created: Date.now()
-	};
-};
+export default (sessionId, userId) => ({
+	sessionId,
+	userId,
+	refreshDate: Date.now(),
+	created: Date.now()
+});

+ 1 - 5
backend/logic/cache/schemas/song.js

@@ -1,5 +1 @@
-'use strict';
-
-module.exports = (song) => {
-	return song;
-};
+export default song => song;

+ 3 - 7
backend/logic/cache/schemas/station.js

@@ -1,13 +1,9 @@
-'use strict';
-
 /**
 /**
  * Schema for a station stored / cached in redis,
  * Schema for a station stored / cached in redis,
  * gets created when a station is in use
  * gets created when a station is in use
  * and therefore is put into the redis cache
  * and therefore is put into the redis cache
  *
  *
- * @param station
- * @returns {Object}
+ * @param {object} station -  object containing the station
+ * @returns {object} - returns same object
  */
  */
-module.exports = (station) => {
-	return station;
-};
+export default station => station;

+ 299 - 41
backend/logic/db/index.js

@@ -1,55 +1,313 @@
-'use strict';
+import config from "config";
+import mongoose from "mongoose";
+import bluebird from "bluebird";
+import async from "async";
 
 
-const mongoose = require('mongoose');
+import CoreClass from "../../core";
 
 
-const bluebird = require('bluebird');
+const REQUIRED_DOCUMENT_VERSIONS = {
+	activity: 2,
+	news: 2,
+	playlist: 4,
+	punishment: 1,
+	queueSong: 1,
+	report: 5,
+	song: 5,
+	station: 6,
+	user: 3
+};
+
+const regex = {
+	azAZ09_: /^[A-Za-z0-9_]+$/,
+	az09_: /^[a-z0-9_]+$/,
+	emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
+	ascii: /^[\x00-\x7F]+$/,
+	name: /^[\p{L}0-9 .'_-]+$/u,
+	custom: regex => new RegExp(`^[${regex}]+$`)
+};
+
+const isLength = (string, min, max) => !(typeof string !== "string" || string.length < min || string.length > max);
 
 
 mongoose.Promise = bluebird;
 mongoose.Promise = bluebird;
 
 
-let lib = {
+let DBModule;
+
+class _DBModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("db");
+
+		DBModule = this;
+	}
+
+	/**
+	 * Initialises the database module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.schemas = {};
+			this.models = {};
+
+			const mongoUrl = config.get("mongo").url;
+
+			mongoose
+				.connect(mongoUrl, {
+					useNewUrlParser: true,
+					useUnifiedTopology: true
+				})
+				.then(async () => {
+					this.schemas = {
+						song: {},
+						queueSong: {},
+						station: {},
+						user: {},
+						dataRequest: {},
+						activity: {},
+						playlist: {},
+						news: {},
+						report: {},
+						punishment: {}
+					};
+
+					const importSchema = schemaName =>
+						new Promise(resolve => {
+							import(`./schemas/${schemaName}`).then(schema => {
+								this.schemas[schemaName] = new mongoose.Schema(schema.default);
+								return resolve();
+							});
+						});
+
+					await importSchema("song");
+					await importSchema("queueSong");
+					await importSchema("station");
+					await importSchema("user");
+					await importSchema("dataRequest");
+					await importSchema("activity");
+					await importSchema("playlist");
+					await importSchema("news");
+					await importSchema("report");
+					await importSchema("punishment");
+
+					this.models = {
+						song: mongoose.model("song", this.schemas.song),
+						queueSong: mongoose.model("queueSong", this.schemas.queueSong),
+						station: mongoose.model("station", this.schemas.station),
+						user: mongoose.model("user", this.schemas.user),
+						dataRequest: mongoose.model("dataRequest", this.schemas.dataRequest),
+						activity: mongoose.model("activity", this.schemas.activity),
+						playlist: mongoose.model("playlist", this.schemas.playlist),
+						news: mongoose.model("news", this.schemas.news),
+						report: mongoose.model("report", this.schemas.report),
+						punishment: mongoose.model("punishment", this.schemas.punishment)
+					};
+
+					mongoose.connection.on("error", err => {
+						this.log("ERROR", err);
+					});
+
+					mongoose.connection.on("disconnected", () => {
+						this.log("ERROR", "Disconnected, going to try to reconnect...");
+						this.setStatus("RECONNECTING");
+					});
+
+					mongoose.connection.on("reconnected", () => {
+						this.log("INFO", "Reconnected.");
+						this.setStatus("READY");
+					});
+
+					mongoose.connection.on("reconnectFailed", () => {
+						this.log("INFO", "Reconnect failed, stopping reconnecting.");
+						this.setStatus("FAILED");
+					});
+
+					// User
+					this.schemas.user
+						.path("username")
+						.validate(
+							username =>
+								isLength(username, 2, 32) &&
+								regex.custom("a-zA-Z0-9_-").test(username) &&
+								username.replaceAll(/[_]/g, "").length > 0,
+							"Invalid username."
+						);
+
+					this.schemas.user.path("email.address").validate(email => {
+						if (!isLength(email, 3, 254)) return false;
+						if (email.indexOf("@") !== email.lastIndexOf("@")) return false;
+						return regex.emailSimple.test(email) && regex.ascii.test(email);
+					}, "Invalid email.");
+
+					this.schemas.user
+						.path("name")
+						.validate(
+							name =>
+								isLength(name, 1, 64) &&
+								regex.name.test(name) &&
+								name.replaceAll(/[ .'_-]/g, "").length > 0,
+							"Invalid name."
+						);
+
+					// Station
+					this.schemas.station
+						.path("name")
+						.validate(id => isLength(id, 2, 16) && regex.az09_.test(id), "Invalid station name.");
 
 
-	connection: null,
-	schemas: {},
-	models: {},
+					this.schemas.station
+						.path("displayName")
+						.validate(
+							displayName => isLength(displayName, 2, 32) && regex.ascii.test(displayName),
+							"Invalid display name."
+						);
 
 
-	init: (url, cb) => {
+					this.schemas.station.path("description").validate(description => {
+						if (!isLength(description, 2, 200)) return false;
+						const characters = description.split("");
+						return characters.filter(character => character.charCodeAt(0) === 21328).length === 0;
+					}, "Invalid display name.");
 
 
-		lib.connection = mongoose.connect(url).connection;
+					this.schemas.station.path("owner").validate({
+						validator: owner =>
+							new Promise((resolve, reject) => {
+								this.models.station.countDocuments({ owner }, (err, c) => {
+									if (err) reject(new Error("A mongo error happened."));
+									else if (c >= 25) reject(new Error("User already has 25 stations."));
+									else resolve();
+								});
+							}),
+						message: "User already has 25 stations."
+					});
 
 
-		lib.connection.on('error', err => {
-			console.error('Database error: ' + err.message)
-			process.exit();
+					// Song
+					const songTitle = title => isLength(title, 1, 100);
+					this.schemas.song.path("title").validate(songTitle, "Invalid title.");
+
+					this.schemas.song.path("artists").validate(artists => artists.length <= 10, "Invalid artists.");
+
+					const songArtists = artists =>
+						artists.filter(artist => isLength(artist, 1, 64) && artist !== "NONE").length ===
+						artists.length;
+					this.schemas.song.path("artists").validate(songArtists, "Invalid artists.");
+
+					const songGenres = genres => {
+						if (genres.length > 16) return false;
+						return (
+							genres.filter(genre => isLength(genre, 1, 32) && regex.ascii.test(genre)).length ===
+							genres.length
+						);
+					};
+					this.schemas.song.path("genres").validate(songGenres, "Invalid genres.");
+
+					const songThumbnail = thumbnail => {
+						if (!isLength(thumbnail, 1, 256)) return false;
+						if (config.get("cookie.secure") === true) return thumbnail.startsWith("https://");
+						return thumbnail.startsWith("http://") || thumbnail.startsWith("https://");
+					};
+					this.schemas.song.path("thumbnail").validate(songThumbnail, "Invalid thumbnail.");
+
+					// Playlist
+					this.schemas.playlist
+						.path("displayName")
+						.validate(displayName => isLength(displayName, 1, 96), "Invalid display name.");
+
+					this.schemas.playlist.path("createdBy").validate(createdBy => {
+						this.models.playlist.countDocuments({ createdBy }, (err, c) => !(err || c >= 100));
+					}, "Max 100 playlists per user.");
+
+					this.schemas.playlist
+						.path("songs")
+						.validate(songs => songs.length <= 10000, "Max 10000 songs per playlist.");
+
+					// this.schemas.playlist.path("songs").validate(songs => {
+					// 	if (songs.length === 0) return true;
+					// 	return songs[0].duration <= 10800;
+					// }, "Max 3 hours per song.");
+
+					this.schemas.playlist.index({ createdFor: 1, type: 1 }, { unique: true });
+
+					if (config.get("skipDbDocumentsVersionCheck")) resolve();
+					else {
+						this.runJob("CHECK_DOCUMENT_VERSIONS", {}, null, -1)
+							.then(() => {
+								resolve();
+							})
+							.catch(err => {
+								reject(err);
+							});
+					}
+				})
+				.catch(err => {
+					this.log("ERROR", err);
+					reject(err);
+				});
 		});
 		});
+	}
 
 
-		lib.connection.once('open', _ => {
-
-			lib.schemas = {
-				song: new mongoose.Schema(require(`./schemas/song`)),
-				queueSong: new mongoose.Schema(require(`./schemas/queueSong`)),
-				station: new mongoose.Schema(require(`./schemas/station`)),
-				user: new mongoose.Schema(require(`./schemas/user`)),
-				playlist: new mongoose.Schema(require(`./schemas/playlist`)),
-				news: new mongoose.Schema(require(`./schemas/news`)),
-				report: new mongoose.Schema(require(`./schemas/report`))
-			};
-
-			lib.schemas.station.path('_id').validate((id) => {
-				return /^[a-z]+$/.test(id);
-			}, 'The id can only have the letters a-z.');
-
-			lib.models = {
-				song: mongoose.model('song', lib.schemas.song),
-				queueSong: mongoose.model('queueSong', lib.schemas.queueSong),
-				station: mongoose.model('station', lib.schemas.station),
-				user: mongoose.model('user', lib.schemas.user),
-				playlist: mongoose.model('playlist', lib.schemas.playlist),
-				news: mongoose.model('news', lib.schemas.news),
-				report: mongoose.model('report', lib.schemas.report)
-			};
-
-			cb();
+	/**
+	 * Checks if all documents have the correct document version
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CHECK_DOCUMENT_VERSIONS() {
+		return new Promise((resolve, reject) => {
+			async.each(
+				Object.keys(REQUIRED_DOCUMENT_VERSIONS),
+				(modelName, next) => {
+					const model = DBModule.models[modelName];
+					const requiredDocumentVersion = REQUIRED_DOCUMENT_VERSIONS[modelName];
+					model.countDocuments({ documentVersion: { $ne: requiredDocumentVersion } }, (err, count) => {
+						if (err) next(err);
+						else if (count > 0)
+							next(
+								`Collection "${modelName}" has ${count} documents with a wrong document version. Run migration.`
+							);
+						else next();
+					});
+				},
+				err => {
+					if (err) reject(new Error(err));
+					else resolve();
+				}
+			);
 		});
 		});
 	}
 	}
-};
 
 
-module.exports = lib;
+	/**
+	 * Returns a database model
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.modelName - name of the model to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_MODEL(payload) {
+		return new Promise(resolve => {
+			resolve(DBModule.models[payload.modelName]);
+		});
+	}
+
+	/**
+	 * Returns a database schema
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.schemaName - name of the schema to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_SCHEMA(payload) {
+		return new Promise(resolve => {
+			resolve(DBModule.schemas[payload.schemaName]);
+		});
+	}
+
+	/**
+	 * Checks if a password to be stored in the database has a valid length
+	 *
+	 * @param {object} password - the password itself
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	passwordValid(password) {
+		return isLength(password, 6, 200);
+	}
+}
+
+export default new _DBModule();

+ 57 - 0
backend/logic/db/schemas/activity.js

@@ -0,0 +1,57 @@
+export default {
+	createdAt: { type: Date, default: Date.now, required: true },
+	hidden: { type: Boolean, default: false, required: true },
+	userId: { type: String, required: true },
+	type: {
+		type: String,
+		enum: [
+			/** User */
+			"user__joined",
+			"user__edit_bio",
+			"user__edit_avatar",
+			"user__edit_name",
+			"user__edit_location",
+			"user__toggle_nightmode",
+			"user__toggle_autoskip_disliked_songs",
+			"user__toggle_activity_watch",
+			/** Songs */
+			"song__report",
+			"song__like",
+			"song__dislike",
+			"song__unlike",
+			"song__undislike",
+			/** Stations */
+			"station__favorite",
+			"station__unfavorite",
+			"station__create",
+			"station__remove",
+			"station__edit_name",
+			"station__edit_display_name",
+			"station__edit_description",
+			"station__edit_theme",
+			"station__edit_privacy",
+			"station__edit_genres",
+			"station__edit_blacklisted_genres",
+			/** Playlists */
+			"playlist__create",
+			"playlist__remove",
+			"playlist__remove_song",
+			"playlist__remove_songs",
+			"playlist__add_song",
+			"playlist__add_songs",
+			"playlist__edit_privacy",
+			"playlist__edit_display_name",
+			"playlist__import_playlist"
+		],
+		required: true
+	},
+	payload: {
+		message: { type: String, default: "", required: true },
+		thumbnail: { type: String, required: false },
+		youtubeId: { type: String, required: false },
+		stationId: { type: String, required: false },
+		playlistId: { type: String, required: false },
+		reportId: { type: String, required: false }
+	},
+	documentVersion: { type: Number, default: 2, required: true }
+};

+ 7 - 0
backend/logic/db/schemas/dataRequest.js

@@ -0,0 +1,7 @@
+export default {
+	userId: { type: String, required: true },
+	createdAt: { type: Date, default: Date.now, required: true },
+	type: { type: String, required: true, enum: ["remove"] },
+	resolved: { type: Boolean, default: false },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 5 - 7
backend/logic/db/schemas/news.js

@@ -1,10 +1,8 @@
-module.exports = {
+export default {
 	title: { type: String, required: true },
 	title: { type: String, required: true },
-	description: { type: String, required: true },
-	bugs: [{ type: String }],
-	features: [{ type: String }],
-	improvements: [{ type: String }],
-	upcoming: [{ type: String }],
+	markdown: { type: String, required: true },
+	status: { type: String, enum: ["draft", "published", "archived"], required: true, default: "published" },
 	createdBy: { type: String, required: true },
 	createdBy: { type: String, required: true },
-	createdAt: { type: Number, default: Date.now(), required: true }
+	createdAt: { type: Number, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 2, required: true }
 };
 };

+ 21 - 5
backend/logic/db/schemas/playlist.js

@@ -1,7 +1,23 @@
-module.exports = {
-	_id: { type: String, min: 17, max: 17, unique: true, index: true, required: true },
-	displayName: { type: String, min: 2, max: 32, required: true },
-	songs: { type: Array },
+import mongoose from "mongoose";
+
+export default {
+	displayName: { type: String, min: 2, max: 96, required: true },
+	isUserModifiable: { type: Boolean, default: true, required: true },
+	songs: [
+		{
+			_id: { type: mongoose.Schema.Types.ObjectId, required: false },
+			youtubeId: { type: String },
+			title: { type: String },
+			duration: { type: Number },
+			thumbnail: { type: String, required: false },
+			artists: { type: Array, required: false },
+			status: { type: String }
+		}
+	],
 	createdBy: { type: String, required: true },
 	createdBy: { type: String, required: true },
-	createdAt: { type: Date, default: Date.now(), required: true }
+	createdAt: { type: Date, default: Date.now, required: true },
+	createdFor: { type: String },
+	privacy: { type: String, enum: ["public", "private"], default: "private" },
+	type: { type: String, enum: ["user", "genre", "station", "artist"], required: true },
+	documentVersion: { type: Number, default: 4, required: true }
 };
 };

+ 10 - 0
backend/logic/db/schemas/punishment.js

@@ -0,0 +1,10 @@
+export default {
+	type: { type: String, enum: ["banUserId", "banUserIp"], required: true },
+	value: { type: String, required: true },
+	reason: { type: String, required: true, default: "Unknown" },
+	active: { type: Boolean, required: true, default: true },
+	expiresAt: { type: Date, required: true },
+	punishedAt: { type: Date, default: Date.now, required: true },
+	punishedBy: { type: String, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 5 - 3
backend/logic/db/schemas/queueSong.js

@@ -1,5 +1,5 @@
-module.exports = {
-	_id: { type: String, unique: true, required: true },
+export default {
+	songId: { type: String, min: 11, max: 11, required: true, index: true },
 	title: { type: String, required: true },
 	title: { type: String, required: true },
 	artists: [{ type: String }],
 	artists: [{ type: String }],
 	genres: [{ type: String }],
 	genres: [{ type: String }],
@@ -8,5 +8,7 @@ module.exports = {
 	thumbnail: { type: String, required: true },
 	thumbnail: { type: String, required: true },
 	explicit: { type: Boolean, required: true },
 	explicit: { type: Boolean, required: true },
 	requestedBy: { type: String, required: true },
 	requestedBy: { type: String, required: true },
-	requestedAt: { type: Date, default: Date.now(), required: true }
+	requestedAt: { type: Date, default: Date.now, required: true },
+	discogs: { type: Object },
+	documentVersion: { type: Number, default: 1, required: true }
 };
 };

+ 19 - 8
backend/logic/db/schemas/report.js

@@ -1,11 +1,22 @@
-module.exports = {
+export default {
 	resolved: { type: Boolean, default: false, required: true },
 	resolved: { type: Boolean, default: false, required: true },
-	songId: { type: String, required: true },
-	description: { type: String },
-	issues: [{
-		name: String,
-		reasons: Array
-	}],
+	song: {
+		_id: { type: String, required: true },
+		youtubeId: { type: String, required: true }
+	},
+	issues: [
+		{
+			category: {
+				type: String,
+				enum: ["custom", "video", "title", "duration", "artists", "thumbnail"],
+				required: true
+			},
+			title: { type: String, required: true },
+			description: { type: String, required: false },
+			resolved: { type: Boolean, default: false, required: true }
+		}
+	],
 	createdBy: { type: String, required: true },
 	createdBy: { type: String, required: true },
-	createdAt: { type: Date, default: Date.now(), required: true }
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 5, required: true }
 };
 };

+ 16 - 13
backend/logic/db/schemas/song.js

@@ -1,16 +1,19 @@
-module.exports = {
-	_id: { type: String, unique: true, required: true },
+export default {
+	youtubeId: { type: String, min: 11, max: 11, required: true, index: true, unique: true },
 	title: { type: String, required: true },
 	title: { type: String, required: true },
-	artists: [{ type: String }],
-	genres: [{ type: String }],
-	duration: { type: Number, required: true },
-	skipDuration: { type: Number, required: true },
-	thumbnail: { type: String, required: true },
+	artists: [{ type: String, default: [] }],
+	genres: [{ type: String, default: [] }],
+	duration: { type: Number, min: 1, required: true },
+	skipDuration: { type: Number, required: true, default: 0 },
+	thumbnail: { type: String },
 	likes: { type: Number, default: 0, required: true },
 	likes: { type: Number, default: 0, required: true },
 	dislikes: { type: Number, default: 0, required: true },
 	dislikes: { type: Number, default: 0, required: true },
-	explicit: { type: Boolean, default: false, required: true },
-	requestedBy: { type: String, required: true },
-	requestedAt: { type: Date, required: true },
-	acceptedBy: { type: String, required: true },
-	acceptedAt: { type: Date, default: Date.now(), required: true }
-};
+	explicit: { type: Boolean },
+	requestedBy: { type: String },
+	requestedAt: { type: Date },
+	verifiedBy: { type: String },
+	verifiedAt: { type: Date },
+	discogs: { type: Object },
+	status: { type: String, required: true, default: "hidden", enum: ["hidden", "unverified", "verified"] },
+	documentVersion: { type: Number, default: 5, required: true }
+};

+ 28 - 20
backend/logic/db/schemas/station.js

@@ -1,41 +1,49 @@
-module.exports = {
-	_id: { type: String, lowercase: true, maxlength: 16, minlength: 2, index: true, unique: true, required: true },
+import mongoose from "mongoose";
+
+export default {
+	name: { type: String, lowercase: true, maxlength: 16, minlength: 2, index: true, unique: true, required: true },
 	type: { type: String, enum: ["official", "community"], required: true },
 	type: { type: String, enum: ["official", "community"], required: true },
 	displayName: { type: String, minlength: 2, maxlength: 32, required: true, unique: true },
 	displayName: { type: String, minlength: 2, maxlength: 32, required: true, unique: true },
 	description: { type: String, minlength: 2, maxlength: 128, required: true },
 	description: { type: String, minlength: 2, maxlength: 128, required: true },
 	paused: { type: Boolean, default: false, required: true },
 	paused: { type: Boolean, default: false, required: true },
 	currentSong: {
 	currentSong: {
 		_id: { type: String },
 		_id: { type: String },
+		youtubeId: { type: String },
 		title: { type: String },
 		title: { type: String },
 		artists: [{ type: String }],
 		artists: [{ type: String }],
 		duration: { type: Number },
 		duration: { type: Number },
 		skipDuration: { type: Number },
 		skipDuration: { type: Number },
 		thumbnail: { type: String },
 		thumbnail: { type: String },
-		likes: { type: Number, default: -1 },
-		dislikes: { type: Number, default: -1 },
 		skipVotes: [{ type: String }],
 		skipVotes: [{ type: String }],
+		requestedBy: { type: String },
+		requestedAt: { type: Date },
+		status: { type: String }
 	},
 	},
 	currentSongIndex: { type: Number, default: 0, required: true },
 	currentSongIndex: { type: Number, default: 0, required: true },
 	timePaused: { type: Number, default: 0, required: true },
 	timePaused: { type: Number, default: 0, required: true },
 	pausedAt: { type: Number, default: 0, required: true },
 	pausedAt: { type: Number, default: 0, required: true },
 	startedAt: { type: Number, default: 0, required: true },
 	startedAt: { type: Number, default: 0, required: true },
-	playlist: { type: Array },
-	genres: [{ type: String }],
-	blacklistedGenres: [{ type: String }],
+	playlist: { type: mongoose.Schema.Types.ObjectId, required: true },
 	privacy: { type: String, enum: ["public", "unlisted", "private"], default: "private" },
 	privacy: { type: String, enum: ["public", "unlisted", "private"], default: "private" },
 	locked: { type: Boolean, default: false },
 	locked: { type: Boolean, default: false },
-	queue: [{
-		_id: { type: String, required: true },
-		title: { type: String },
-		artists: [{ type: String }],
-		duration: { type: Number },
-		skipDuration: { type: Number },
-		thumbnail: { type: String },
-		likes: { type: Number, default: -1 },
-		dislikes: { type: Number, default: -1 },
-		requestedBy: { type: String, required: true }
-	}],
+	queue: [
+		{
+			youtubeId: { type: String, required: true },
+			title: { type: String },
+			artists: [{ type: String }],
+			duration: { type: Number },
+			skipDuration: { type: Number },
+			thumbnail: { type: String },
+			requestedBy: { type: String },
+			requestedAt: { type: Date },
+			status: { type: String }
+		}
+	],
 	owner: { type: String },
 	owner: { type: String },
-	privatePlaylist: { type: String },
-	partyMode: { type: Boolean }
+	partyMode: { type: Boolean },
+	playMode: { type: String, enum: ["random", "sequential"], default: "random" },
+	theme: { type: String, enum: ["blue", "purple", "teal", "orange"], default: "blue" },
+	includedPlaylists: [{ type: String }],
+	excludedPlaylists: [{ type: String }],
+	documentVersion: { type: Number, default: 6, required: true }
 };
 };

+ 30 - 12
backend/logic/db/schemas/user.js

@@ -1,34 +1,52 @@
-module.exports = {
-	_id: { type: String, required: true, index: true, unique: true, min: 12, max: 12 },
+import mongoose from "mongoose";
+
+export default {
 	username: { type: String, required: true },
 	username: { type: String, required: true },
-	role: { type: String, default: 'default', required: true },
+	role: { type: String, default: "default", required: true },
 	email: {
 	email: {
 		verified: { type: Boolean, default: false, required: true },
 		verified: { type: Boolean, default: false, required: true },
 		verificationToken: String,
 		verificationToken: String,
 		address: String
 		address: String
 	},
 	},
+	avatar: {
+		type: { type: String, enum: ["gravatar", "initials"], required: true },
+		url: { type: String, required: false },
+		color: { type: String, enum: ["blue", "orange", "green", "purple", "teal"], required: false }
+	},
 	services: {
 	services: {
 		password: {
 		password: {
 			password: String,
 			password: String,
 			reset: {
 			reset: {
 				code: { type: String, min: 8, max: 8 },
 				code: { type: String, min: 8, max: 8 },
 				expires: { type: Date }
 				expires: { type: Date }
+			},
+			set: {
+				code: { type: String, min: 8, max: 8 },
+				expires: { type: Date }
 			}
 			}
 		},
 		},
 		github: {
 		github: {
 			id: Number,
 			id: Number,
+			access_token: String
 		}
 		}
 	},
 	},
-	ban: {
-		banned: { type: Boolean, default: false, required: true },
-		reason: String,
-		bannedAt: Date,
-		bannedUntil: Date
-	},
 	statistics: {
 	statistics: {
 		songsRequested: { type: Number, default: 0, required: true }
 		songsRequested: { type: Number, default: 0, required: true }
 	},
 	},
-	liked: [{ type: String }],
-	disliked: [{ type: String }],
-	createdAt: { type: Date, default: Date.now() }
+	likedSongsPlaylist: { type: mongoose.Schema.Types.ObjectId },
+	dislikedSongsPlaylist: { type: mongoose.Schema.Types.ObjectId },
+	favoriteStations: [{ type: String }],
+	name: { type: String, required: true },
+	location: { type: String, default: "" },
+	bio: { type: String, default: "" },
+	createdAt: { type: Date, default: Date.now },
+	preferences: {
+		orderOfPlaylists: [{ type: mongoose.Schema.Types.ObjectId }],
+		nightmode: { type: Boolean, default: false, required: true },
+		autoSkipDisliked: { type: Boolean, default: true, required: true },
+		activityLogPublic: { type: Boolean, default: false, required: true },
+		anonymousSongRequests: { type: Boolean, default: false, required: true },
+		activityWatch: { type: Boolean, default: false, required: true }
+	},
+	documentVersion: { type: Number, default: 3, required: true }
 };
 };

+ 0 - 112
backend/logic/io.js

@@ -1,112 +0,0 @@
-'use strict';
-
-// This file contains all the logic for Socket.IO
-
-const app = require('./app');
-const actions = require('./actions');
-const cache = require('./cache');
-const utils = require('./utils');
-const db = require('./db');
-
-module.exports = {
-
-	io: null,
-
-	init: (cb) => {
-		//TODO Check every 30s/60s, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
-		this.io = require('socket.io')(app.server);
-
-		this.io.use((socket, next) => {
-			let cookies = socket.request.headers.cookie;
-			let SID = utils.cookies.parseCookies(cookies).SID;
-
-			if (!SID) SID = "NONE";
-			cache.hget('sessions', SID, (err, session) => {
-				if (err) SID = null;
-				socket.session = (session) ? session : {};
-				socket.session.socketId = socket.id;
-				return next();
-			});
-		});
-
-		this.io.on('connection', socket => {
-			console.info('User has connected');
-
-			// catch when the socket has been disconnected
-			socket.on('disconnect', () => {
-
-				// remove the user from their current station (if any)
-				if (socket.session) {
-					//actions.stations.leave(socket.sessionId, result => {});
-					// Remove session from Redis
-					//cache.hdel('sessions', socket.session.sessionId);
-				}
-
-				console.info('User has disconnected');
-			});
-
-			// catch errors on the socket (internal to socket.io)
-			socket.on('error', err => console.error(err));
-
-			// have the socket listen for each action
-			Object.keys(actions).forEach((namespace) => {
-				Object.keys(actions[namespace]).forEach((action) => {
-
-					// the full name of the action
-					let name = `${namespace}.${action}`;
-
-					// listen for this action to be called
-					socket.on(name, function () {
-
-						let args = Array.prototype.slice.call(arguments, 0, -1);
-						let cb = arguments[arguments.length - 1];
-
-						// load the session from the cache
-						cache.hget('sessions', socket.session.sessionId, (err, session) => {
-							if (err && err !== true) {
-								if (typeof cb === 'function') return cb({
-									status: 'error',
-									message: 'An error occurred while obtaining your session'
-								});
-							}
-
-							// make sure the sockets sessionId isn't set if there is no session
-							if (socket.session.sessionId && session === null) delete socket.session.sessionId;
-
-							// call the action, passing it the session, and the arguments socket.io passed us
-							actions[namespace][action].apply(null, [socket.session].concat(args).concat([
-								(result) => {
-									// respond to the socket with our message
-									if (typeof cb === 'function') return cb(result);
-								}
-							]));
-						});
-					})
-				})
-			});
-
-			if (socket.session.sessionId) {
-				cache.hget('sessions', socket.session.sessionId, (err, session) => {
-					if (err && err !== true) socket.emit('ready', false);
-					else if (session && session.userId) {
-						db.models.user.findOne({ _id: session.userId }, (err, user) => {
-							if (err || !user) return socket.emit('ready', false);
-							let role = '';
-							let username = '';
-							let userId = '';
-							if (user) {
-								role = user.role;
-								username = user.username;
-								userId = session.userId;
-							}
-							socket.emit('ready', true, role, username, userId);
-						});
-					} else socket.emit('ready', false);
-				})
-			} else socket.emit('ready', false);
-		});
-
-		cb();
-	}
-
-};

+ 0 - 32
backend/logic/logger.js

@@ -1,32 +0,0 @@
-'use strict';
-
-let twoDigits = (num) => {
-	return (num < 10) ? '0' + num : num;
-};
-
-let getTime = (cb) => {
-	let time = new Date();
-	return cb ({
-		year: time.getFullYear(),
-		month: time.getMonth() + 1,
-		day: time.getDate(),
-		hour: time.getHours(),
-		minute: time.getMinutes(),
-		second: time.getSeconds()
-	});
-};
-
-module.exports = {
-	success: (type, message) => {
-		getTime((time) => {
-			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-			console.info('\x1b[32m', timeString, 'SUCCESS', '-', type, '-', message, '\x1b[0m');
-		});
-	},
-	error: (type, message) => {
-		getTime((time) => {
-			let timeString = `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-			console.warn('\x1b[31m', timeString, 'ERROR', '-', type, '-', message, '\x1b[0m');
-		});
-	}
-};

+ 81 - 16
backend/logic/mail/index.js

@@ -1,25 +1,90 @@
-'use strict';
+/* eslint-disable global-require */
+import config from "config";
+import nodemailer from "nodemailer";
 
 
-const config = require('config');
-const mailgun = require('mailgun-js')({apiKey: config.get("apis.mailgun.key"), domain: config.get("apis.mailgun.domain")});
+import CoreClass from "../../core";
 
 
-let lib = {
+let MailModule;
 
 
-	schemas: {},
+class _MailModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("mail");
 
 
-	init: (cb) => {
-		lib.schemas = {
-			verifyEmail: require('./schemas/verifyEmail'),
-			resetPasswordRequest: require('./schemas/resetPasswordRequest')
+		MailModule = this;
+	}
+
+	/**
+	 * Initialises the mail module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		const importSchema = schemaName =>
+			new Promise(resolve => {
+				import(`./schemas/${schemaName}`).then(schema => resolve(schema.default));
+			});
+
+		this.schemas = {
+			verifyEmail: await importSchema("verifyEmail"),
+			resetPasswordRequest: await importSchema("resetPasswordRequest"),
+			passwordRequest: await importSchema("passwordRequest"),
+			dataRequest: await importSchema("dataRequest")
 		};
 		};
 
 
-		cb();
-	},
+		this.enabled = config.get("smtp.enabled");
+
+		if (this.enabled)
+			this.transporter = nodemailer.createTransport({
+				host: config.get("smtp.host"),
+				port: config.get("smtp.port"),
+				secure: config.get("smtp.secure"),
+				auth: {
+					user: config.get("smtp.auth.user"),
+					pass: config.get("smtp.auth.pass")
+				}
+			});
+
+		return new Promise(resolve => resolve());
+	}
+
+	/**
+	 * Sends an email
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.data - information such as to, from in order to send the email
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEND_MAIL(payload) {
+		return new Promise((resolve, reject) => {
+			if (MailModule.enabled)
+				return MailModule.transporter
+					.sendMail(payload.data)
+					.then(info => {
+						MailModule.log("SUCCESS", "MAIL_SEND", `Successfully sent email ${info.messageId}`);
+						return resolve();
+					})
+					.catch(err => {
+						MailModule.log("ERROR", "MAIL_SEND", `Failed to send email. ${err}`);
+						return reject();
+					});
+
+			return resolve();
+		});
+	}
 
 
-	sendMail: (data, cb) => {
-		if (!cb) cb = ()=>{};
-		mailgun.messages().send(data, cb);
+	/**
+	 * Returns an email schema
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.schemaName - name of the schema to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_SCHEMA(payload) {
+		return new Promise(resolve => {
+			resolve(MailModule.schemas[payload.schemaName]);
+		});
 	}
 	}
-};
+}
 
 
-module.exports = lib;
+export default new _MailModule();

+ 34 - 0
backend/logic/mail/schemas/dataRequest.js

@@ -0,0 +1,34 @@
+import config from "config";
+
+import mail from "../index";
+
+/**
+ * Sends an email to all admins that a user has submitted a data request
+ *
+ * @param {string} to - an array of email addresses of admins
+ * @param {string} userId - the id of the user the data request is for
+ * @param {string} type - the type of data request e.g. remove
+ * @param {Function} cb - gets called when an error occurred or when the operation was successful
+ */
+export default (to, userId, type, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: `Data Request - ${type}`,
+		html: `
+				Hello,
+				<br>
+				<br>
+				User ${userId} has requested to ${type} the data for their account on Musare.
+				<br>
+				<br>
+				This request can be viewed and resolved in the <a href="${config.get(
+					"domain"
+				)}/admin/users">Users tab of the admin page</a>. Note: All admins will be sent the same message.
+			`
+	};
+
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
+};

+ 30 - 0
backend/logic/mail/schemas/passwordRequest.js

@@ -0,0 +1,30 @@
+import mail from "../index";
+
+/**
+ * Sends a request password email
+ *
+ * @param {string} to - the email address of the recipient
+ * @param {string} username - the username of the recipient
+ * @param {string} code - the password code of the recipient
+ * @param {Function} cb - gets called when an error occurred or when the operation was successful
+ */
+export default (to, username, code, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: "Password request",
+		html: `
+				Hello there ${username},
+				<br>
+				<br>
+				Someone has requested to add a password to your account. If this was not you, you can ignore this email.
+				<br>
+				<br>
+				The code is <b>${code}</b>. You can enter this code on the page you requested the password on. This code will expire in 24 hours.
+			`
+	};
+
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
+};

+ 14 - 14
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -1,21 +1,19 @@
-const config = require('config');
-const mail = require('../index');
+import mail from "../index";
 
 
 /**
 /**
  * Sends a request password reset email
  * Sends a request password reset email
  *
  *
- * @param {String} to - the email address of the recipient
- * @param {String} username - the username of the recipient
- * @param {String} code - the password reset code of the recipient
+ * @param {string} to - the email address of the recipient
+ * @param {string} username - the username of the recipient
+ * @param {string} code - the password reset code of the recipient
  * @param {Function} cb - gets called when an error occurred or when the operation was successful
  * @param {Function} cb - gets called when an error occurred or when the operation was successful
  */
  */
-module.exports = function(to, username, code, cb) {
-	let data = {
-		from: 'Musare <noreply@musare.com>',
-		to: to,
-		subject: 'Password reset request',
-		html:
-			`
+export default (to, username, code, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: "Password reset request",
+		html: `
 				Hello there ${username},
 				Hello there ${username},
 				<br>
 				<br>
 				<br>
 				<br>
@@ -26,5 +24,7 @@ module.exports = function(to, username, code, cb) {
 			`
 			`
 	};
 	};
 
 
-	mail.sendMail(data, cb);
-};
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
+};

+ 18 - 15
backend/logic/mail/schemas/verifyEmail.js

@@ -1,27 +1,30 @@
-const config = require('config');
-const mail = require('../index');
+import config from "config";
+import mail from "../index";
 
 
 /**
 /**
  * Sends a verify email email
  * Sends a verify email email
  *
  *
- * @param {String} to - the email address of the recipient
- * @param {String} username - the username of the recipient
- * @param {String} code - the email reset code of the recipient
+ * @param {string} to - the email address of the recipient
+ * @param {string} username - the username of the recipient
+ * @param {string} code - the email reset code of the recipient
  * @param {Function} cb - gets called when an error occurred or when the operation was successful
  * @param {Function} cb - gets called when an error occurred or when the operation was successful
  */
  */
-module.exports = function(to, username, code, cb) {
-	let data = {
-		from: 'Musare <noreply@musare.com>',
-		to: to,
-		subject: 'Please verify your email',
-		html:
-			`
+export default (to, username, code, cb) => {
+	const data = {
+		from: "Musare <noreply@musare.com>",
+		to,
+		subject: "Please verify your email",
+		html: `
 				Hello there ${username},
 				Hello there ${username},
 				<br>
 				<br>
 				<br>
 				<br>
-				To verify your email, please visit <a href="${config.get('serverDomain')}/auth/verify_email?code=${code}">${config.get('serverDomain')}/auth/verify_email?code=${code}</a>.
+				To verify your email, please visit <a href="${config.get("serverDomain")}/auth/verify_email?code=${code}">${config.get(
+			"serverDomain"
+		)}/auth/verify_email?code=${code}</a>.
 			`
 			`
 	};
 	};
 
 
-	mail.sendMail(data, cb);
-};
+	mail.runJob("SEND_MAIL", { data })
+		.then(() => cb())
+		.catch(err => cb(err));
+};

+ 136 - 0
backend/logic/migration/index.js

@@ -0,0 +1,136 @@
+import async from "async";
+import config from "config";
+import mongoose from "mongoose";
+import bluebird from "bluebird";
+import fs from "fs";
+
+import { fileURLToPath } from "url";
+import path from "path";
+
+import CoreClass from "../../core";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+let MigrationModule;
+
+mongoose.Promise = bluebird;
+
+class _MigrationModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("migration");
+
+		MigrationModule = this;
+	}
+
+	/**
+	 * Initialises the migration module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.models = {};
+
+			const mongoUrl = config.get("mongo").url;
+
+			mongoose
+				.connect(mongoUrl, {
+					useNewUrlParser: true,
+					useUnifiedTopology: true
+				})
+				.then(async () => {
+					mongoose.connection.on("error", err => this.log("ERROR", err));
+
+					mongoose.connection.on("disconnected", () => {
+						this.log("ERROR", "Disconnected, going to try to reconnect...");
+						this.setStatus("RECONNECTING");
+					});
+
+					mongoose.connection.on("reconnected", () => {
+						this.log("INFO", "Reconnected.");
+						this.setStatus("READY");
+					});
+
+					mongoose.connection.on("reconnectFailed", () => {
+						this.log("INFO", "Reconnect failed, stopping reconnecting.");
+						this.setStatus("FAILED");
+					});
+
+					this.models = {
+						song: mongoose.model("song", new mongoose.Schema({}, { strict: false })),
+						queueSong: mongoose.model("queueSong", new mongoose.Schema({}, { strict: false })),
+						station: mongoose.model("station", new mongoose.Schema({}, { strict: false })),
+						user: mongoose.model("user", new mongoose.Schema({}, { strict: false })),
+						activity: mongoose.model("activity", new mongoose.Schema({}, { strict: false })),
+						playlist: mongoose.model("playlist", new mongoose.Schema({}, { strict: false })),
+						news: mongoose.model("news", new mongoose.Schema({}, { strict: false })),
+						report: mongoose.model("report", new mongoose.Schema({}, { strict: false })),
+						punishment: mongoose.model("punishment", new mongoose.Schema({}, { strict: false }))
+					};
+
+					const files = fs.readdirSync(path.join(__dirname, "migrations"));
+					const migrations = files.length;
+
+					async.timesLimit(
+						migrations,
+						1,
+						(index, next) => {
+							MigrationModule.runJob("RUN_MIGRATION", { index: index + 1 }, null, -1)
+								.then(() => next())
+								.catch(err => next(err));
+						},
+						err => {
+							if (err) console.log("Migration error", err);
+							else console.log("Migration completed");
+						}
+					);
+
+					resolve();
+				})
+				.catch(err => {
+					this.log("ERROR", err);
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Returns a database model
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.modelName - name of the model to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_MODEL(payload) {
+		return new Promise(resolve => {
+			resolve(MigrationModule.models[payload.modelName]);
+		});
+	}
+
+	/**
+	 * Runs migrations
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.index - migration index
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	RUN_MIGRATION(payload) {
+		return new Promise((resolve, reject) => {
+			import(`./migrations/migration${payload.index}`).then(module => {
+				this.log("INFO", `Running migration ${payload.index}`);
+				module.default
+					.apply(this, [MigrationModule])
+					.then(response => {
+						resolve(response);
+					})
+					.catch(err => {
+						reject(err);
+					});
+			});
+		});
+	}
+}
+
+export default new _MigrationModule();

+ 176 - 0
backend/logic/migration/migrations/migration1.js

@@ -0,0 +1,176 @@
+import async from "async";
+
+/**
+ * Migration 1
+ *
+ * This migration is used to set the documentVersion to 1 for all documents that don't have a documentVersion yet, meaning they were created before the migration system
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const activityModel = await MigrationModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+	const newsModel = await MigrationModule.runJob("GET_MODEL", { modelName: "news" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+	const punishmentModel = await MigrationModule.runJob("GET_MODEL", { modelName: "punishment" }, this);
+	const queueSongModel = await MigrationModule.runJob("GET_MODEL", { modelName: "queueSong" }, this);
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					activityModel.updateMany(
+						{ documentVersion: null },
+						{ $set: { documentVersion: 1 } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 1 (activity). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					newsModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (news). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					playlistModel.updateMany(
+						{ documentVersion: null },
+						{ $set: { documentVersion: 1 } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 1 (playlist). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					punishmentModel.updateMany(
+						{ documentVersion: null },
+						{ $set: { documentVersion: 1 } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 1 (punishment). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					queueSongModel.updateMany(
+						{ documentVersion: null },
+						{ $set: { documentVersion: 1 } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 1 (queueSong). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					reportModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (report). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					songModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (song). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					stationModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (station). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					userModel.updateMany({ documentVersion: null }, { $set: { documentVersion: 1 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 1 (user). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				}
+			],
+			(err, response) => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve(response);
+				}
+			}
+		);
+	});
+}

+ 62 - 0
backend/logic/migration/migrations/migration10.js

@@ -0,0 +1,62 @@
+import async from "async";
+
+/**
+ * Migration 10
+ *
+ * Migration for changes in how the order of songs in a playlist is handled
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 10. Finding playlists with document version 3.`);
+					playlistModel.find({ documentVersion: 3 }, (err, playlists) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								playlists.map(playlisti => playlisti._doc),
+								1,
+								(playlisti, next) => {
+									// sort playlists by the position property
+									playlisti.songs.sort((song1, song2) => song1.position - song2.position);
+
+									// delete the position property for each song
+									playlisti.songs.forEach(song => delete song.position);
+
+									// update the database
+									playlistModel.updateOne(
+										{ _id: playlisti._id },
+										{
+											$set: {
+												songs: playlisti.songs,
+												documentVersion: 4
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 10. Playlists found: ${playlists.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 59 - 0
backend/logic/migration/migrations/migration11.js

@@ -0,0 +1,59 @@
+import async from "async";
+
+/**
+ * Migration 11
+ *
+ * Migration for changing language of verifying a song from 'accepted' to 'verified' for songs
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 11. Finding songs with document version 4.`);
+					songModel.find({ documentVersion: 4 }, (err, songs) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								songs.map(songi => songi._doc),
+								1,
+								(songi, next) =>
+									songModel.updateOne(
+										{ _id: songi._id },
+										{
+											$set: {
+												verifiedBy: songi.acceptedBy,
+												verifiedAt: songi.acceptedAt,
+												documentVersion: 5
+											},
+											$unset: {
+												acceptedBy: "",
+												acceptedAt: ""
+											}
+										},
+										next
+									),
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 11. Songs found: ${songs.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 67 - 0
backend/logic/migration/migrations/migration12.js

@@ -0,0 +1,67 @@
+import async from "async";
+
+/**
+ * Migration 12
+ *
+ * Migration for updated style of reports
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 12. Finding reports with document version 2.`);
+					reportModel.find({ documentVersion: 2 }, (err, reports) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								reports.map(reporti => reporti._doc),
+								1,
+								(reporti, next) => {
+									const issues = [];
+
+									if (reporti.description !== "")
+										issues.push({ category: "custom", info: reporti.description });
+
+									reporti.issues.forEach(category =>
+										category.reasons.forEach(info => issues.push({ category: category.name, info }))
+									);
+
+									reportModel.updateOne(
+										{ _id: reporti._id },
+										{
+											$set: {
+												documentVersion: 4,
+												issues
+											},
+											$unset: {
+												description: ""
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 12. Reports found: ${reports.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 60 - 0
backend/logic/migration/migrations/migration13.js

@@ -0,0 +1,60 @@
+import async from "async";
+
+/**
+ * Migration 13
+ *
+ * Migration for allowing titles, descriptions and individual resolving for report issues
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 13. Finding reports with document version 4.`);
+					reportModel.find({ documentVersion: 4 }, (err, reports) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								reports.map(reporti => reporti._doc),
+								1,
+								(reporti, next) => {
+									const { issues } = reporti;
+
+									issues.forEach(issue => {
+										issue.title = issue.info;
+										issue.resolved = reporti.resolved;
+										delete issue.info;
+									});
+
+									reportModel.updateOne(
+										{ _id: reporti._id },
+										{
+											$set: {
+												documentVersion: 5,
+												issues
+											}
+										},
+										next
+									);
+								},
+								err => {
+									this.log("INFO", `Migration 13. Reports found: ${reports.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 65 - 0
backend/logic/migration/migrations/migration14.js

@@ -0,0 +1,65 @@
+import async from "async";
+
+/**
+ * Migration 14
+ *
+ * Migration for removing some data from stations
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 14. Finding stations with document version 5.`);
+					stationModel.find({ documentVersion: 5 }, (err, stations) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								stations.map(station => station._doc),
+								1,
+								(station, next) => {
+									const { queue, currentSong } = station;
+
+									if (currentSong && currentSong.likes) {
+										delete currentSong.likes;
+										delete currentSong.dislikes;
+									}
+
+									queue.forEach(song => {
+										delete song.likes;
+										delete song.dislikes;
+									});
+
+									stationModel.updateOne(
+										{ _id: station._id },
+										{
+											$set: {
+												documentVersion: 6,
+												queue,
+												currentSong
+											}
+										},
+										next
+									);
+								},
+								err => {
+									this.log("INFO", `Migration 14. Stations found: ${stations.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 46 - 0
backend/logic/migration/migrations/migration15.js

@@ -0,0 +1,46 @@
+import async from "async";
+
+/**
+ * Migration 15
+ *
+ * Migration for setting user name to username if not set
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 15. Finding users with document version 3.`);
+					userModel.find({ documentVersion: 3, name: { $in: [null, ""] } }, (err, users) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								users.map(user => user._doc),
+								1,
+								(user, next) => {
+									userModel.updateOne({ _id: user._id }, { $set: { name: user.username } }, next);
+								},
+								err => {
+									this.log("INFO", `Migration 15. Users found: ${users.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 91 - 0
backend/logic/migration/migrations/migration2.js

@@ -0,0 +1,91 @@
+import async from "async";
+
+/**
+ * Migration 2
+ *
+ * Updates the document version 1 stations to add the includedPlaylists and excludedPlaylists properties, and to create a station playlist and link that playlist with the playlist2 property.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 2. Finding stations with document version 1.`);
+					stationModel.find({ documentVersion: 1 }, (err, stations) => {
+						this.log("INFO", `Migration 2. Found ${stations.length} stations with document version 1.`);
+
+						next(
+							null,
+							stations.map(station => station._doc)
+						);
+					});
+				},
+
+				(stations, next) => {
+					async.eachLimit(
+						stations,
+						1,
+						(station, next) => {
+							this.log("INFO", `Migration 2. Creating station playlist for station ${station._id}.`);
+							playlistModel.create(
+								{
+									isUserModifiable: false,
+									displayName: `Station - ${station.displayName}`,
+									songs: [],
+									createdBy: station.type === "official" ? "Musare" : station.createdBy,
+									createdFor: `${station._id}`,
+									createdAt: Date.now(),
+									type: "station",
+									documentVersion: 1
+								},
+								(err, playlist2) => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 2. Updating station ${station._id}.`);
+										stationModel.updateOne(
+											{ _id: station._id },
+											{
+												$set: {
+													playlist2: playlist2._id,
+													includedPlaylists: [],
+													excludedPlaylists: [],
+													playlist: station.type === "official" ? [] : station.playlist,
+													genres: [],
+													documentVersion: 2
+												}
+											},
+											(err, res) => {
+												if (err) next(err);
+												else {
+													this.log(
+														"INFO",
+														`Migration 2. Updating station ${station._id} done. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+													);
+												}
+											}
+										);
+										next();
+									}
+								}
+							);
+						},
+						next
+					);
+				}
+			],
+			(err, response) => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve(response);
+				}
+			}
+		);
+	});
+}

+ 75 - 0
backend/logic/migration/migrations/migration3.js

@@ -0,0 +1,75 @@
+import async from "async";
+
+/**
+ * Migration 3
+ *
+ * Clean up station object from playlist2 property (replacing old playlist property with playlist2 property), adding a playMode property and removing genres/blacklisted genres
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 3. Finding stations with document version 2.`);
+					stationModel.find({ documentVersion: 2 }, (err, stations) => {
+						this.log("INFO", `Migration 3. Found ${stations.length} stations with document version 2.`);
+
+						next(
+							null,
+							stations.map(station => station._doc)
+						);
+					});
+				},
+
+				(stations, next) => {
+					async.eachLimit(
+						stations,
+						1,
+						(station, next) => {
+							this.log("INFO", `Migration 3. Updating station ${station._id}.`);
+							stationModel.updateOne(
+								{ _id: station._id },
+								{
+									$set: {
+										playlist: station.playlist2,
+										playMode: "random",
+										documentVersion: 3
+									},
+									$unset: {
+										genres: "",
+										blacklistedGenres: "",
+										playlist2: "",
+										privatePlaylist: ""
+									}
+								},
+								(err, res) => {
+									if (err) next(err);
+									else {
+										this.log(
+											"INFO",
+											`Migration 3. Updating station ${station._id} done. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+										);
+										next();
+									}
+								}
+							);
+						},
+						next
+					);
+				}
+			],
+			(err, response) => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve(response);
+				}
+			}
+		);
+	});
+}

+ 99 - 0
backend/logic/migration/migrations/migration4.js

@@ -0,0 +1,99 @@
+import async from "async";
+
+/**
+ * Migration 4
+ *
+ * Migration for song merging. Merges queueSongs into songs database, and adds verified property to all songs.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const queueSongModel = await MigrationModule.runJob("GET_MODEL", { modelName: "queueSong" }, this);
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 4. Finding songs with document version 1.`);
+					songModel.updateMany(
+						{ documentVersion: 1 },
+						{ $set: { documentVersion: 2, verified: true } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 4 (song). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 4. Finding queue songs.`);
+					queueSongModel.find({}, next);
+				},
+
+				(queueSongs, next) => {
+					this.log("INFO", `Migration 4. Found ${queueSongs.length} queue songs.`);
+					async.eachLimit(
+						queueSongs,
+						1,
+						(_queueSong, next) => {
+							const queueSong = JSON.parse(JSON.stringify(_queueSong));
+
+							songModel.findOne({ songId: queueSong.songId }, (err, song) => {
+								if (err) next(err);
+								else if (song) {
+									this.log(
+										"INFO",
+										`Migration 4. Skipping creating song for queue song ${queueSong.songId} (${queueSong._id}) since it already exists.`
+									);
+									next(null, song);
+								} else {
+									this.log(
+										"INFO",
+										`Migration 4. Creating song for queue song ${queueSong.songId} (${queueSong._id}).`
+									);
+									queueSong.verified = false;
+									queueSong.documentVersion = 2;
+									delete queueSong._id;
+									songModel.create(queueSong, next);
+								}
+							});
+						},
+						err => {
+							if (err) next(err);
+							else {
+								this.log("INFO", `Migration 4. Deleting queue songs.`);
+								queueSongModel.deleteMany({}, (err, res) => {
+									if (err) next(err);
+									else {
+										this.log(
+											"INFO",
+											`Migration 4 (queueSong). Matched: ${res.n}, deleted: ${res.deletedCount}, ok: ${res.ok}.`
+										);
+
+										next();
+									}
+								});
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 152 - 0
backend/logic/migration/migrations/migration5.js

@@ -0,0 +1,152 @@
+import async from "async";
+
+/**
+ * Migration 5
+ *
+ * Migration for song status property.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 5. Finding unverified songs with document version 2.`);
+					songModel.updateMany(
+						{ documentVersion: 2, verified: false },
+						{ $set: { documentVersion: 3, status: "unverified" }, $unset: { verified: "" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 5 (unverified songs). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 5. Finding verified songs with document version 2.`);
+					songModel.updateMany(
+						{ documentVersion: 2, verified: true },
+						{ $set: { documentVersion: 3, status: "verified" }, $unset: { verified: "" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 5 (verified songs). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 5. Updating playlist songs and queue songs.`);
+					songModel.find({ documentVersion: 3 }, (err, songs) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								songs.map(song => song._doc),
+								1,
+								(song, next) => {
+									const { _id, songId, title, artists, thumbnail, duration, status } = song;
+									const trimmedSong = {
+										_id,
+										songId,
+										title,
+										artists,
+										thumbnail,
+										duration,
+										status
+									};
+									async.waterfall(
+										[
+											next => {
+												playlistModel.updateMany(
+													{ "songs._id": song._id, documentVersion: 1 },
+													{ $set: { "songs.$": trimmedSong } },
+													next
+												);
+											},
+
+											(res, next) => {
+												stationModel.updateMany(
+													{ "queue._id": song._id, documentVersion: 3 },
+													{ $set: { "queue.$": trimmedSong } },
+													next
+												);
+											},
+
+											(res, next) => {
+												stationModel.updateMany(
+													{ "currentSong._id": song._id, documentVersion: 3 },
+													{ $set: { currentSong: null } },
+													next
+												);
+											}
+										],
+										err => {
+											next(err);
+										}
+									);
+								},
+								err => {
+									next(err);
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					playlistModel.updateMany({ documentVersion: 1 }, { $set: { documentVersion: 2 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 5 (playlist). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					stationModel.updateMany({ documentVersion: 3 }, { $set: { documentVersion: 4 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 5 (station). Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 45 - 0
backend/logic/migration/migrations/migration6.js

@@ -0,0 +1,45 @@
+import async from "async";
+
+/**
+ * Migration 6
+ *
+ * Migration for adding activityWatch preference to user object
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 6. Finding users with document version 1.`);
+					userModel.updateMany(
+						{ documentVersion: 1 },
+						{ $set: { documentVersion: 2, "preferences.activityWatch": false } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 6. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 45 - 0
backend/logic/migration/migrations/migration7.js

@@ -0,0 +1,45 @@
+import async from "async";
+
+/**
+ * Migration 7
+ *
+ * Migration for adding anonymous song requests preference to user object
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const userModel = await MigrationModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 7. Finding users with document version 2.`);
+					userModel.updateMany(
+						{ documentVersion: 2 },
+						{ $set: { documentVersion: 3, "preferences.anonymousSongRequests": false } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 7. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 214 - 0
backend/logic/migration/migrations/migration8.js

@@ -0,0 +1,214 @@
+import async from "async";
+
+/**
+ * Migration 8
+ *
+ * Migration for replacing songId with youtubeId whereever it is used, and using songId for any song's _id uses
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const activityModel = await MigrationModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 8. Finding activities with document version 1.`);
+					activityModel.find({ documentVersion: 1 }, (err, activities) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								activities.map(activity => activity._doc),
+								1,
+								(activity, next) => {
+									const { payload } = activity;
+									if (payload.songId) {
+										payload.youtubeId = payload.songId;
+										delete payload.songId;
+									}
+									if (payload.message)
+										payload.message = payload.message
+											.replaceAll("<songId", "<youtubeId")
+											.replaceAll("</songId", "</youtubeId");
+
+									activityModel.updateOne(
+										{ _id: activity._id },
+										{ $set: { payload, documentVersion: 2 } },
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 8. Activities found: ${activities.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Finding playlists with document version 2.`);
+					playlistModel.find({ documentVersion: 2 }, (err, playlists) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								playlists.map(playlist => playlist._doc),
+								1,
+								(playlist, next) => {
+									const songs = playlist.songs.map(song => {
+										song.youtubeId = song.songId;
+										delete song.songId;
+										return song;
+									});
+
+									playlistModel.updateOne({ _id: playlist._id }, { $set: { songs } }, next);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										playlistModel.updateMany(
+											{ documentVersion: 2 },
+											{ $set: { documentVersion: 3 } },
+											(err, res) => {
+												if (err) next(err);
+												else {
+													this.log(
+														"INFO",
+														`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+													);
+													next();
+												}
+											}
+										);
+									}
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Finding reports with document version 1.`);
+					reportModel.updateMany(
+						{ documentVersion: 1 },
+						{ $set: { documentVersion: 2 }, $rename: { "song.songId": "song.youtubeId" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Dropping indexes.`);
+					songModel.collection.dropIndexes((err, res) => {
+						if (err) next(err);
+						else {
+							this.log("INFO", `Migration 8. Dropped indexes: ${res}.`);
+							next();
+						}
+					});
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Finding spmgs with document version 3.`);
+					songModel.updateMany(
+						{ documentVersion: 3 },
+						{ $set: { documentVersion: 4 }, $rename: { songId: "youtubeId" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+								);
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 8. Finding stations with document version 3.`);
+					stationModel.find({ documentVersion: 4 }, (err, stations) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								stations.map(station => station._doc),
+								1,
+								(station, next) => {
+									const queue = station.queue.map(song => {
+										song.youtubeId = song.songId;
+										delete song.songId;
+										return song;
+									});
+
+									stationModel.updateOne({ _id: station._id }, { $set: { queue } }, next);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										stationModel.updateMany(
+											{ documentVersion: 4, currentSong: { $ne: null } },
+											{
+												$set: { documentVersion: 5 },
+												$rename: { "currentSong.songId": "currentSong.youtubeId" }
+											},
+											(err, res) => {
+												if (err) next(err);
+												else {
+													this.log(
+														"INFO",
+														`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+													);
+													stationModel.updateMany(
+														{ documentVersion: 4, currentSong: null },
+														{
+															$set: { documentVersion: 5 }
+														},
+														(err, res) => {
+															if (err) next(err);
+															else {
+																this.log(
+																	"INFO",
+																	`Migration 8. Matched: ${res.n}, modified: ${res.nModified}, ok: ${res.ok}.`
+																);
+																next();
+															}
+														}
+													);
+												}
+											}
+										);
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 85 - 0
backend/logic/migration/migrations/migration9.js

@@ -0,0 +1,85 @@
+import async from "async";
+
+/**
+ * Migration 9
+ *
+ * Migration for news
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const newsModel = await MigrationModule.runJob("GET_MODEL", { modelName: "news" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 9. Finding news with document version 1.`);
+					newsModel.find({ documentVersion: 1 }, (err, news) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								news.map(newi => newi._doc),
+								1,
+								(newi, next) => {
+									newi.markdown = `# ${newi.title}\n\n`;
+									newi.markdown += `## ${newi.description}\n\n`;
+
+									if (newi.bugs) {
+										newi.markdown += `**Bugs:**\n\n${newi.bugs.join(", ")}\n\n`;
+									}
+
+									if (newi.features) {
+										newi.markdown += `**Features:**\n\n${newi.features.join(", ")}\n\n`;
+									}
+
+									if (newi.improvements) {
+										newi.markdown += `**Improvements:**\n\n${newi.improvements.join(", ")}\n\n`;
+									}
+
+									if (newi.upcoming) {
+										newi.markdown += `**Upcoming:**\n\n${newi.upcoming.join(", ")}\n`;
+									}
+
+									newsModel.updateOne(
+										{ _id: newi._id },
+										{
+											$set: {
+												markdown: newi.markdown,
+												status: "published",
+												documentVersion: 2
+											},
+											$unset: {
+												description: "",
+												bugs: "",
+												features: "",
+												improvements: "",
+												upcoming: ""
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 9. News found: ${news.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 249 - 51
backend/logic/notifications.js

@@ -1,80 +1,278 @@
-'use strict';
+import config from "config";
 
 
-const crypto = require('crypto');
-const redis = require('redis');
+import crypto from "crypto";
+import redis from "redis";
 
 
-let pub = null;
-let sub = null;
+import CoreClass from "../core";
 
 
-const subscriptions = [];
+let NotificationsModule;
+let UtilsModule;
 
 
-const lib = {
+class _NotificationsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("notifications");
+
+		this.subscriptions = [];
+
+		NotificationsModule = this;
+	}
 
 
 	/**
 	/**
-	 * Initializes the notifications module
+	 * Initialises the notifications module
 	 *
 	 *
-	 * @param {String} url - the url of the redis server
-	 * @param {Function} cb - gets called once we're done initializing
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	init: (url, cb) => {
-		pub = redis.createClient({ url: url });
-		sub = redis.createClient({ url: url });
-		sub.on('error', (err) => {
-			console.error(err);
-			process.exit();
-		});
-		sub.on('pmessage', (pattern, channel, expiredKey) => {
-			subscriptions.forEach((sub) => {
-				if (sub.name !== expiredKey) return;
-				sub.cb();
+	initialize() {
+		return new Promise((resolve, reject) => {
+			const url = (this.url = config.get("redis").url);
+			const password = (this.password = config.get("redis").password);
+
+			UtilsModule = this.moduleManager.modules.utils;
+
+			this.pub = redis.createClient({
+				url,
+				password,
+				retry_strategy: options => {
+					if (this.getStatus() === "LOCKDOWN") return;
+					if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
+
+					this.log("INFO", `Attempting to reconnect.`);
+
+					if (options.attempt >= 10) {
+						this.log("ERROR", `Stopped trying to reconnect.`);
+
+						this.setStatus("FAILED");
+					}
+				}
+			});
+			this.sub = redis.createClient({
+				url,
+				password,
+				retry_strategy: options => {
+					if (this.getStatus() === "LOCKDOWN") return;
+					if (this.getStatus() !== "RECONNECTING") this.setStatus("RECONNECTING");
+
+					this.log("INFO", `Attempting to reconnect.`);
+
+					if (options.attempt >= 10) {
+						this.log("ERROR", `Stopped trying to reconnect.`);
+
+						this.setStatus("FAILED");
+					}
+				}
+			});
+
+			this.sub.on("error", err => {
+				if (this.getStatus() === "INITIALIZING") reject(err);
+				if (this.getStatus() === "LOCKDOWN") return;
+
+				this.log("ERROR", `Error ${err.message}.`);
 			});
 			});
+
+			this.pub.on("error", err => {
+				if (this.getStatus() === "INITIALIZING") reject(err);
+				if (this.getStatus() === "LOCKDOWN") return;
+
+				this.log("ERROR", `Error ${err.message}.`);
+			});
+
+			this.sub.on("connect", () => {
+				this.log("INFO", "Sub connected succesfully.");
+
+				if (this.getStatus() === "INITIALIZING") resolve();
+				else if (this.getStatus() === "LOCKDOWN" || this.getStatus() === "RECONNECTING")
+					this.setStatus("READY");
+			});
+
+			this.pub.on("connect", () => {
+				this.log("INFO", "Pub connected succesfully.");
+
+				this.pub.config("GET", "notify-keyspace-events", async (err, response) => {
+					if (err) {
+						const formattedErr = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: err
+							},
+							this
+						);
+						this.log(
+							"ERROR",
+							"NOTIFICATIONS_INITIALIZE",
+							`Getting notify-keyspace-events gave an error. ${formattedErr}`
+						);
+						this.log(
+							"STATION_ISSUE",
+							`Getting notify-keyspace-events gave an error. ${formattedErr}. ${response}`
+						);
+						return;
+					}
+					if (response[1] === "xE") {
+						this.log("INFO", "NOTIFICATIONS_INITIALIZE", `notify-keyspace-events is set correctly`);
+						this.log("STATION_ISSUE", `notify-keyspace-events is set correctly`);
+					} else {
+						this.log(
+							"ERROR",
+							"NOTIFICATIONS_INITIALIZE",
+							`notify-keyspace-events is NOT correctly! It is set to: ${response[1]}`
+						);
+						this.log(
+							"STATION_ISSUE",
+							`notify-keyspace-events is NOT correctly! It is set to: ${response[1]}`
+						);
+					}
+				});
+
+				if (this.getStatus() === "INITIALIZING") resolve();
+				else if (this.getStatus() === "LOCKDOWN" || this.getStatus() === "RECONNECTING")
+					this.setStatus("INITIALIZED");
+			});
+
+			this.sub.on("pmessage", (pattern, channel, expiredKey) => {
+				this.log(
+					"STATION_ISSUE",
+					`PMESSAGE1 - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`
+				);
+
+				this.subscriptions.forEach(sub => {
+					this.log(
+						"STATION_ISSUE",
+						`PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(sub.name !== expiredKey)}`
+					);
+					if (sub.name !== expiredKey) return;
+					sub.cb();
+				});
+			});
+
+			this.sub.psubscribe(`__keyevent@${this.pub.options.db}__:expired`);
 		});
 		});
-		sub.psubscribe('__keyevent@0__:expired');
-		cb();
-	},
+	}
 
 
 	/**
 	/**
 	 * Schedules a notification to be dispatched in a specific amount of milliseconds,
 	 * Schedules a notification to be dispatched in a specific amount of milliseconds,
 	 * notifications are unique by name, and the first one is always kept, as in
 	 * notifications are unique by name, and the first one is always kept, as in
 	 * attempting to schedule a notification that already exists won't do anything
 	 * attempting to schedule a notification that already exists won't do anything
 	 *
 	 *
-	 * @param {String} name - the name of the notification we want to schedule
-	 * @param {Integer} time - how long in milliseconds until the notification should be fired
-	 * @param {Function} cb - gets called when the notification has been scheduled
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.name - the name of the notification we want to schedule
+	 * @param {number} payload.time - how long in milliseconds until the notification should be fired
+	 * @param {object} payload.station - the station object related to the notification
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	schedule: (name, time, cb) => {
-		time = Math.round(time);
-		pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
-	},
+	SCHEDULE(payload) {
+		return new Promise((resolve, reject) => {
+			const time = Math.round(payload.time);
+			if (time <= 0) reject(new Error("Time has to be higher than 0"));
+			else {
+				NotificationsModule.log(
+					"STATION_ISSUE",
+					`SCHEDULE - Time: ${time}; Name: ${payload.name}; Key: ${crypto
+						.createHash("md5")
+						.update(`_notification:${payload.name}_`)
+						.digest("hex")}; StationId: ${payload.station._id}; StationName: ${payload.station.name}`
+				);
+				NotificationsModule.pub.set(
+					crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
+					"",
+					"PX",
+					time,
+					"NX",
+					err => {
+						if (err) reject(err);
+						else resolve();
+					}
+				);
+			}
+		});
+	}
 
 
 	/**
 	/**
 	 * Subscribes a callback function to be called when a notification gets called
 	 * Subscribes a callback function to be called when a notification gets called
 	 *
 	 *
-	 * @param {String} name - the name of the notification we want to subscribe to
-	 * @param {Function} cb - gets called when the subscribed notification gets called
-	 * @param {Boolean} unique - only subscribe if another subscription with the same name doesn't already exist
-	 * @return {Object} - the subscription object
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.name - the name of the notification we want to subscribe to
+	 * @param {boolean} payload.unique - only subscribe if another subscription with the same name doesn't already exist
+	 * @param {object} payload.station - the station object related to the notification
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	subscribe: (name, cb, unique = false) => {
-		if (unique && subscriptions.find((subscription) => subscription.originalName == name)) return;
-		let subscription = { originalName: name, name: crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), cb };
-		subscriptions.push(subscription);
-		return subscription;
-	},
+	SUBSCRIBE(payload) {
+		return new Promise(resolve => {
+			NotificationsModule.log(
+				"STATION_ISSUE",
+				`SUBSCRIBE - Name: ${payload.name}; Key: ${crypto
+					.createHash("md5")
+					.update(`_notification:${payload.name}_`)
+					.digest("hex")}, StationId: ${payload.station._id}; StationName: ${payload.station.name}; Unique: ${
+					payload.unique
+				}; SubscriptionExists: ${!!NotificationsModule.subscriptions.find(
+					subscription => subscription.originalName === payload.name
+				)};`
+			);
+			if (
+				payload.unique &&
+				!!NotificationsModule.subscriptions.find(subscription => subscription.originalName === payload.name)
+			)
+				return resolve({
+					subscription: NotificationsModule.subscriptions.find(
+						subscription => subscription.originalName === payload.name
+					)
+				});
+
+			const subscription = {
+				originalName: payload.name,
+				name: crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
+				cb: payload.cb
+			};
+
+			NotificationsModule.subscriptions.push(subscription);
+
+			return resolve({ subscription });
+		});
+	}
 
 
 	/**
 	/**
 	 * Remove a notification subscription
 	 * Remove a notification subscription
 	 *
 	 *
-	 * @param {Object} subscription - the subscription object returned by {@link subscribe}
+	 * @param {object} payload - object containing the payload
+	 * @param {object} payload.subscription - the subscription object returned by {@link subscribe}
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	remove: (subscription) => {
-		let index = subscriptions.indexOf(subscription);
-		if (index) subscriptions.splice(index, 1);
-	},
+	REMOVE(payload) {
+		// subscription
+		return new Promise(resolve => {
+			const index = NotificationsModule.subscriptions.indexOf(payload.subscription);
+			if (index) NotificationsModule.subscriptions.splice(index, 1);
+			resolve();
+		});
+	}
 
 
-	unschedule: (name) => {
-		pub.del(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'));
-	},
-};
+	/**
+	 * Unschedules a notification by name (each notification has a unique name)
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.name - the name of the notification we want to schedule
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UNSCHEDULE(payload) {
+		// name
+		return new Promise((resolve, reject) => {
+			NotificationsModule.log(
+				"STATION_ISSUE",
+				`UNSCHEDULE - Name: ${payload.name}; Key: ${crypto
+					.createHash("md5")
+					.update(`_notification:${payload.name}_`)
+					.digest("hex")}`
+			);
+			NotificationsModule.pub.del(
+				crypto.createHash("md5").update(`_notification:${payload.name}_`).digest("hex"),
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+}
 
 
-module.exports = lib;
+export default new _NotificationsModule();

+ 1490 - 111
backend/logic/playlists.js

@@ -1,149 +1,1528 @@
-'use strict';
+import async from "async";
 
 
-const cache = require('./cache');
-const db = require('./db');
-const async = require('async');
+import CoreClass from "../core";
 
 
-module.exports = {
+let PlaylistsModule;
+let StationsModule;
+let SongsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+
+class _PlaylistsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("playlists");
+
+		PlaylistsModule = this;
+	}
+
+	/**
+	 * Initialises the playlists module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		StationsModule = this.moduleManager.modules.stations;
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+		SongsModule = this.moduleManager.modules.songs;
+
+		this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
+		this.playlistSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "playlist" });
+
+		this.setStage(2);
+
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						this.setStage(3);
+						CacheModule.runJob("HGETALL", { table: "playlists" })
+							.then(playlists => {
+								next(null, playlists);
+							})
+							.catch(next);
+					},
+
+					(playlists, next) => {
+						this.setStage(4);
+
+						if (!playlists) return next();
+
+						const playlistIds = Object.keys(playlists);
+
+						return async.each(
+							playlistIds,
+							(playlistId, next) => {
+								PlaylistsModule.playlistModel.findOne({ _id: playlistId }, (err, playlist) => {
+									if (err) next(err);
+									else if (!playlist) {
+										CacheModule.runJob("HDEL", {
+											table: "playlists",
+											key: playlistId
+										})
+											.then(() => next())
+											.catch(next);
+									} else next();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(5);
+						PlaylistsModule.playlistModel.find({}, next);
+					},
+
+					(playlists, next) => {
+						this.setStage(6);
+						async.each(
+							playlists,
+							(playlist, cb) => {
+								CacheModule.runJob("HSET", {
+									table: "playlists",
+									key: playlist._id,
+									value: PlaylistsModule.playlistSchemaCache(playlist)
+								})
+									.then(() => cb())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						const formattedErr = await UtilsModule.runJob("GET_ERROR", {
+							error: err
+						});
+						reject(new Error(formattedErr));
+					} else {
+						resolve();
+					}
+				}
+			)
+		);
+	}
+
+	// /**
+	//  * Returns a list of playlists that include a specific song
+	//  *
+	//  * @param {object} payload - object that contains the payload
+	//  * @param {string} payload.songId - the song id
+	//  * @param {string} payload.includeSongs - include the songs
+	//  * @returns {Promise} - returns promise (reject, resolve)
+	//  */
+	// GET_PLAYLISTS_WITH_SONG(payload) {
+	// 	return new Promise((resolve, reject) => {
+	// 		async.waterfall([
+	// 			next => {
+	// 				const includeObject = payload.includeSongs ? null : { songs: false };
+	// 				PlaylistsModule.playlistModel.find({ "songs._id": payload.songId }, includeObject, next);
+	// 			},
+
+	// 			(playlists, next) => {
+	// 				console.log(playlists);
+	// 			}
+	// 		]);
+	// 	});
+	// }
+
+	/**
+	 * Creates a playlist that is not generated or editable by a user e.g. liked songs playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user to create the playlist for
+	 * @param {string} payload.displayName - the display name of the playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_READ_ONLY_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.create(
+				{
+					isUserModifiable: false,
+					displayName: payload.displayName,
+					songs: [],
+					createdBy: payload.userId,
+					createdAt: Date.now(),
+					createdFor: null,
+					type: payload.type
+				},
+				(err, playlist) => {
+					if (err) return reject(new Error(err));
+					return resolve(playlist._id);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Creates a playlist that contains all songs of a specific genre
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.genre - the genre
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_GENRE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_GENRE_PLAYLIST", { genre: payload.genre.toLowerCase() }, this)
+				.then(() => {
+					reject(new Error("Playlist already exists"));
+				})
+				.catch(err => {
+					if (err.message === "Playlist not found") {
+						PlaylistsModule.playlistModel.create(
+							{
+								isUserModifiable: false,
+								displayName: `Genre - ${payload.genre}`,
+								songs: [],
+								createdBy: "Musare",
+								createdFor: `${payload.genre.toLowerCase()}`,
+								createdAt: Date.now(),
+								type: "genre"
+							},
+							(err, playlist) => {
+								if (err) return reject(new Error(err));
+								return resolve(playlist._id);
+							}
+						);
+					} else reject(new Error(err));
+				});
+		});
+	}
+
+	/**
+	 * Creates a playlist that contains all songs of a specific artist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artist - the artist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_ARTIST_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_ARTIST_PLAYLIST", { artist: payload.artist.toLowerCase() }, this)
+				.then(() => {
+					reject(new Error("Playlist already exists"));
+				})
+				.catch(err => {
+					if (err.message === "Playlist not found") {
+						PlaylistsModule.playlistModel.create(
+							{
+								isUserModifiable: false,
+								displayName: `Artist - ${payload.artist}`,
+								songs: [],
+								createdBy: "Musare",
+								createdFor: `${payload.artist.toLowerCase()}`,
+								createdAt: Date.now(),
+								type: "artist",
+								privacy: "public"
+							},
+							(err, playlist) => {
+								if (err) return reject(new Error(err));
+								return resolve(playlist._id);
+							}
+						);
+					} else reject(new Error(err));
+				});
+		});
+	}
 
 
 	/**
 	/**
-	 * Initializes the playlists module, and exits if it is unsuccessful
+	 * Gets all genre playlists
 	 *
 	 *
-	 * @param {Function} cb - gets called once we're done initializing
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('playlists', next);
-			},
+	GET_ALL_GENRE_PLAYLISTS(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.find({ type: "genre" }, includeObject, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else resolve({ playlists });
+			});
+		});
+	}
 
 
-			(playlists, next) => {
-				if (!playlists) return next();
-				let playlistIds = Object.keys(playlists);
-				async.each(playlistIds, (playlistId, next) => {
-					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-						if (err) next(err);
-						else if (!playlist) {
-							cache.hdel('playlists', playlistId, next);
+	/**
+	 * Gets all artist playlists
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ALL_ARTIST_PLAYLISTS(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.find({ type: "artist" }, includeObject, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else resolve({ playlists });
+			});
+		});
+	}
+
+	/**
+	 * Gets all station playlists
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ALL_STATION_PLAYLISTS(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.find({ type: "station" }, includeObject, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else resolve({ playlists });
+			});
+		});
+	}
+
+	/**
+	 * Gets a genre playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.genre - the genre
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_GENRE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.findOne(
+				{ type: "genre", createdFor: payload.genre },
+				includeObject,
+				(err, playlist) => {
+					if (err) reject(new Error(err));
+					else if (!playlist) reject(new Error("Playlist not found"));
+					else resolve({ playlist });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets all missing genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_MISSING_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			SongsModule.runJob("GET_ALL_GENRES", {}, this)
+				.then(response => {
+					const { genres } = response;
+					const missingGenres = [];
+					async.eachLimit(
+						genres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob(
+								"GET_GENRE_PLAYLIST",
+								{ genre: genre.toLowerCase(), includeSongs: false },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found") {
+										missingGenres.push(genre);
+										next();
+									} else next(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else resolve({ genres: missingGenres });
 						}
 						}
-						else next();
-					});
-				}, next);
-			},
-
-			(next) => {
-				db.models.playlist.find({}, next);
-			},
-
-			(playlists, next) => {
-				async.each(playlists, (playlist, next) => {
-					cache.hset('playlists', playlist._id, cache.schemas.playlist(playlist), next);
-				}, next);
-			}
-		], (err) => {
-			if (err) {
-				console.log(`FAILED TO INITIALIZE PLAYLISTS. ABORTING. "${err.message}"`);
-				process.exit();
-			} else {
-				cb();
-			}
-		});
-	},
+					);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
 
 
 	/**
 	/**
-	 * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 * Creates all missing genre playlists
 	 *
 	 *
-	 * @param {String} playlistId - the id of the playlist we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	getPlaylist: (playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('playlists', next);
-			},
-
-			(playlists, next) => {
-				let playlistIds = Object.keys(playlists);
-				async.each(playlistIds, (playlistId, next) => {
-					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-						if (err) next(err);
-						else if (!playlist) {
-							cache.hdel('playlists', playlistId, next);
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_MISSING_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_MISSING_GENRE_PLAYLISTS", {}, this)
+				.then(response => {
+					const { genres } = response;
+					async.eachLimit(
+						genres,
+						1,
+						(genre, next) => {
+							PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre }, this)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else resolve();
 						}
 						}
+					);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Gets a artist playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artist - the artist
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ARTIST_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.findOne(
+				{ type: "artist", createdFor: payload.artist },
+				includeObject,
+				(err, playlist) => {
+					if (err) reject(new Error(err));
+					else if (!playlist) reject(new Error("Playlist not found"));
+					else resolve({ playlist });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets all missing artist playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_MISSING_ARTIST_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			SongsModule.runJob("GET_ALL_ARTISTS", {}, this)
+				.then(response => {
+					const { artists } = response;
+					const missingArtists = [];
+					async.eachLimit(
+						artists,
+						1,
+						(artist, next) => {
+							PlaylistsModule.runJob(
+								"GET_ARTIST_PLAYLIST",
+								{ artist: artist.toLowerCase(), includeSongs: false },
+								this
+							)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Playlist not found") {
+										missingArtists.push(artist);
+										next();
+									} else next(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else resolve({ artists: missingArtists });
+						}
+					);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Creates all missing artist playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_MISSING_ARTIST_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_MISSING_ARTIST_PLAYLISTS", {}, this)
+				.then(response => {
+					const { artists } = response;
+					async.eachLimit(
+						artists,
+						1,
+						(artist, next) => {
+							PlaylistsModule.runJob("CREATE_ARTIST_PLAYLIST", { artist }, this)
+								.then(() => {
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(err);
+							else resolve();
+						}
+					);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Gets a station playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.staationId - the station id
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.findOne(
+				{ type: "station", createdFor: payload.stationId },
+				includeObject,
+				(err, playlist) => {
+					if (err) reject(new Error(err));
+					else if (!playlist) reject(new Error("Playlist not found"));
+					else resolve({ playlist });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Adds a song to a playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @param {string} payload.song - the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	ADD_SONG_TO_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { _id, youtubeId, title, artists, thumbnail, duration, status } = payload.song;
+			const trimmedSong = {
+				_id,
+				youtubeId,
+				title,
+				artists,
+				thumbnail,
+				duration,
+				status
+			};
+
+			PlaylistsModule.playlistModel.updateOne(
+				{ _id: payload.playlistId },
+				{ $push: { songs: trimmedSong } },
+				{ runValidators: true },
+				err => {
+					if (err) reject(new Error(err));
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: payload.playlistId }, this)
+							.then(() => resolve())
+							.catch(err => {
+								reject(new Error(err));
+							});
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Deletes a song from a playlist based on the youtube id
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @param {string} payload.youtubeId - the youtube id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_SONG_FROM_PLAYLIST_BY_YOUTUBE_ID(payload) {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.updateOne(
+				{ _id: payload.playlistId },
+				{ $pull: { songs: { youtubeId: payload.youtubeId } } },
+				err => {
+					if (err) reject(new Error(err));
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: payload.playlistId }, this)
+							.then(() => resolve())
+							.catch(err => {
+								reject(new Error(err));
+							});
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Fills a genre playlist with songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.genre - the genre
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	AUTOFILL_GENRE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob(
+							"GET_GENRE_PLAYLIST",
+							{ genre: payload.genre.toLowerCase(), includeSongs: true },
+							this
+						)
+							.then(response => {
+								next(null, response.playlist._id);
+							})
+							.catch(err => {
+								if (err.message === "Playlist not found") {
+									PlaylistsModule.runJob("CREATE_GENRE_PLAYLIST", { genre: payload.genre }, this)
+										.then(playlistId => {
+											next(null, playlistId);
+										})
+										.catch(err => {
+											next(err);
+										});
+								} else next(err);
+							});
+					},
+
+					(playlistId, next) => {
+						SongsModule.runJob("GET_ALL_SONGS_WITH_GENRE", { genre: payload.genre }, this)
+							.then(response => {
+								next(null, playlistId, response.songs);
+							})
+							.catch(err => {
+								console.log(err);
+								next(err);
+							});
+					},
+
+					(playlistId, _songs, next) => {
+						const songs = _songs.map(song => {
+							const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+							return {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								status
+							};
+						});
+
+						PlaylistsModule.playlistModel.updateOne({ _id: playlistId }, { $set: { songs } }, err => {
+							next(err, playlistId);
+						});
+					},
+
+					(playlistId, next) => {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(() => {
+								next(null, playlistId);
+							})
+							.catch(next);
+					},
+
+					(playlistId, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId }, this)
+							.then(response => {
+								async.eachLimit(
+									response.stationIds,
+									1,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }, this)
+											.then(() => {
+												next();
+											})
+											.catch(err => {
+												next(err);
+											});
+									},
+									err => {
+										if (err) next(err);
+										else next();
+									}
+								);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({});
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets orphaned genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.find({ type: "genre" }, { songs: false }, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else {
+					const orphanedPlaylists = [];
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							SongsModule.runJob("GET_ALL_SONGS_WITH_GENRE", { genre: playlist.createdFor }, this)
+								.then(response => {
+									if (response.songs.length === 0) {
+										StationsModule.runJob(
+											"GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST",
+											{ playlistId: playlist._id },
+											this
+										)
+											.then(response => {
+												if (response.stationIds.length === 0) orphanedPlaylists.push(playlist);
+												next();
+											})
+											.catch(next);
+									} else next();
+								})
+								.catch(next);
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({ playlists: orphanedPlaylists });
+						}
+					);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Deletes all orphaned genre playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_ORPHANED_GENRE_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_ORPHANED_GENRE_PLAYLISTS", {}, this)
+				.then(response => {
+					async.eachLimit(
+						response.playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
+								.then(() => {
+									this.log("INFO", "Deleting orphaned genre playlist");
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({});
+						}
+					);
+				})
+				.catch(err => {
+					reject(new Error(err));
+				});
+		});
+	}
+
+	/**
+	 * Fills a artist playlist with songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.artist - the artist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	AUTOFILL_ARTIST_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob(
+							"GET_ARTIST_PLAYLIST",
+							{ artist: payload.artist.toLowerCase(), includeSongs: true },
+							this
+						)
+							.then(response => {
+								next(null, response.playlist._id);
+							})
+							.catch(err => {
+								if (err.message === "Playlist not found") {
+									PlaylistsModule.runJob("CREATE_ARTIST_PLAYLIST", { artist: payload.artist }, this)
+										.then(playlistId => {
+											next(null, playlistId);
+										})
+										.catch(err => {
+											next(err);
+										});
+								} else next(err);
+							});
+					},
+
+					(playlistId, next) => {
+						SongsModule.runJob("GET_ALL_SONGS_WITH_ARTIST", { artist: payload.artist }, this)
+							.then(response => {
+								next(null, playlistId, response.songs);
+							})
+							.catch(err => {
+								console.log(err);
+								next(err);
+							});
+					},
+
+					(playlistId, _songs, next) => {
+						const songs = _songs.map(song => {
+							const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+							return {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								status
+							};
+						});
+
+						PlaylistsModule.playlistModel.updateOne({ _id: playlistId }, { $set: { songs } }, err => {
+							next(err, playlistId);
+						});
+					},
+
+					(playlistId, next) => {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(() => {
+								next(null, playlistId);
+							})
+							.catch(next);
+					},
+
+					(playlistId, next) => {
+						StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId }, this)
+							.then(response => {
+								async.eachLimit(
+									response.stationIds,
+									1,
+									(stationId, next) => {
+										PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }, this)
+											.then(() => {
+												next();
+											})
+											.catch(err => {
+												next(err);
+											});
+									},
+									err => {
+										if (err) next(err);
+										else next();
+									}
+								);
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({});
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets orphaned artist playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_ARTIST_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.find({ type: "artist" }, { songs: false }, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else {
+					const orphanedPlaylists = [];
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							SongsModule.runJob("GET_ALL_SONGS_WITH_ARTIST", { artist: playlist.createdFor }, this)
+								.then(response => {
+									if (response.songs.length === 0) {
+										StationsModule.runJob(
+											"GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST",
+											{ playlistId: playlist._id },
+											this
+										)
+											.then(response => {
+												if (response.stationIds.length === 0) orphanedPlaylists.push(playlist);
+												next();
+											})
+											.catch(next);
+									} else next();
+								})
+								.catch(next);
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({ playlists: orphanedPlaylists });
+						}
+					);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Deletes all orphaned artist playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_ORPHANED_ARTIST_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_ORPHANED_ARTIST_PLAYLISTS", {}, this)
+				.then(response => {
+					async.eachLimit(
+						response.playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
+								.then(() => {
+									this.log("INFO", "Deleting orphaned artist playlist");
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({});
+						}
+					);
+				})
+				.catch(err => {
+					reject(new Error(err));
+				});
+		});
+	}
+
+	/**
+	 * Gets a orphaned station playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_STATION_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.playlistModel.find({ type: "station" }, { songs: false }, (err, playlists) => {
+				if (err) reject(new Error(err));
+				else {
+					const orphanedPlaylists = [];
+					async.eachLimit(
+						playlists,
+						1,
+						(playlist, next) => {
+							StationsModule.runJob("GET_STATION", { stationId: playlist.createdFor }, this)
+								.then(station => {
+									if (station.playlist !== playlist._id.toString()) {
+										orphanedPlaylists.push(playlist);
+									}
+									next();
+								})
+								.catch(err => {
+									if (err.message === "Station not found") {
+										orphanedPlaylists.push(playlist);
+										next();
+									} else next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({ playlists: orphanedPlaylists });
+						}
+					);
+				}
+			});
+		});
+	}
+
+	/**
+	 * Deletes all orphaned station playlists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_ORPHANED_STATION_PLAYLISTS() {
+		return new Promise((resolve, reject) => {
+			PlaylistsModule.runJob("GET_ORPHANED_STATION_PLAYLISTS", {}, this)
+				.then(response => {
+					async.eachLimit(
+						response.playlists,
+						1,
+						(playlist, next) => {
+							PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: playlist._id }, this)
+								.then(() => {
+									this.log("INFO", "Deleting orphaned station playlist");
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) reject(new Error(err));
+							else resolve({});
+						}
+					);
+				})
+				.catch(err => {
+					reject(new Error(err));
+				});
+		});
+	}
+
+	/**
+	 * Fills a station playlist with songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	AUTOFILL_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			let originalPlaylist = null;
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
 						else next();
 						else next();
-					});
-				}, next);
-			},
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: station.playlist }, this)
+							.then(playlist => {
+								originalPlaylist = playlist;
+								next(null, station);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(station, next) => {
+						const includedPlaylists = [];
+						async.eachLimit(
+							station.includedPlaylists,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										includedPlaylists.push(playlist);
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								next(err, station, includedPlaylists);
+							}
+						);
+					},
+
+					(station, includedPlaylists, next) => {
+						const excludedPlaylists = [];
+						async.eachLimit(
+							station.excludedPlaylists,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+									.then(playlist => {
+										excludedPlaylists.push(playlist);
+										next();
+									})
+									.catch(next);
+							},
+							err => {
+								next(err, station, includedPlaylists, excludedPlaylists);
+							}
+						);
+					},
+
+					(station, includedPlaylists, excludedPlaylists, next) => {
+						const excludedSongs = excludedPlaylists
+							.flatMap(excludedPlaylist => excludedPlaylist.songs)
+							.reduce(
+								(items, item) =>
+									items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+								[]
+							);
+						const includedSongs = includedPlaylists
+							.flatMap(includedPlaylist => includedPlaylist.songs)
+							.reduce(
+								(songs, song) =>
+									songs.find(x => x.youtubeId === song.youtubeId) ? [...songs] : [...songs, song],
+								[]
+							)
+							.filter(song => !excludedSongs.find(x => x.youtubeId === song.youtubeId));
 
 
-			(next) => {
-				cache.hget('playlists', playlistId, next);
-			},
+						next(null, station, includedSongs);
+					},
 
 
-			(playlist, next) => {
-				if (playlist) return next(true, playlist);
-				db.models.playlist.findOne({ _id: playlistId }, next);
-			},
+					(station, includedSongs, next) => {
+						PlaylistsModule.playlistModel.updateOne(
+							{ _id: station.playlist },
+							{ $set: { songs: includedSongs } },
+							err => {
+								next(err, includedSongs);
+							}
+						);
+					},
 
 
-			(playlist, next) => {
-				if (playlist) {
-					cache.hset('playlists', playlistId, playlist, next);
-				} else next('Playlist not found');
-			},
+					(includedSongs, next) => {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId: originalPlaylist._id }, this)
+							.then(() => {
+								next(null, includedSongs);
+							})
+							.catch(next);
+					},
 
 
-		], (err, playlist) => {
-			if (err && err !== true) return cb(err);
-			else cb(null, playlist);
+					(includedSongs, next) => {
+						if (originalPlaylist.songs.length === 0 && includedSongs.length > 0)
+							StationsModule.runJob("SKIP_STATION", { stationId: payload.stationId, natural: false });
+						next();
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({});
+				}
+			);
 		});
 		});
-	},
+	}
+
+	/**
+	 * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob(
+							"HGET",
+							{
+								table: "playlists",
+								key: payload.playlistId
+							},
+							this
+						)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					},
+
+					(playlist, next) => {
+						if (playlist)
+							PlaylistsModule.playlistModel.exists({ _id: payload.playlistId }, (err, exists) => {
+								if (err) next(err);
+								else if (exists) next(null, playlist);
+								else {
+									CacheModule.runJob(
+										"HDEL",
+										{
+											table: "playlists",
+											key: payload.playlistId
+										},
+										this
+									)
+										.then(() => next())
+										.catch(next);
+								}
+							});
+						else PlaylistsModule.playlistModel.findOne({ _id: payload.playlistId }, next);
+					},
+
+					(playlist, next) => {
+						if (playlist) {
+							CacheModule.runJob(
+								"HSET",
+								{
+									table: "playlists",
+									key: payload.playlistId,
+									value: playlist
+								},
+								this
+							)
+								.then(playlist => {
+									next(null, playlist);
+								})
+								.catch(next);
+						} else next("Playlist not found");
+					}
+				],
+				(err, playlist) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(playlist);
+				}
+			)
+		);
+	}
 
 
 	/**
 	/**
 	 * Gets a playlist from id from Mongo and updates the cache with it
 	 * Gets a playlist from id from Mongo and updates the cache with it
 	 *
 	 *
-	 * @param {String} playlistId - the id of the playlist we are trying to update
-	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to update
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	updatePlaylist: (playlistId, cb) => {
-		async.waterfall([
+	UPDATE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.playlistModel.findOne({ _id: payload.playlistId }, next);
+					},
+
+					(playlist, next) => {
+						if (!playlist) {
+							CacheModule.runJob("HDEL", {
+								table: "playlists",
+								key: payload.playlistId
+							});
 
 
-			(next) => {
-				db.models.playlist.findOne({ _id: playlistId }, next);
-			},
+							return next("Playlist not found");
+						}
 
 
-			(playlist, next) => {
-				if (!playlist) {
-					cache.hdel('playlists', playlistId);
-					return next('Playlist not found');
+						return CacheModule.runJob(
+							"HSET",
+							{
+								table: "playlists",
+								key: payload.playlistId,
+								value: playlist
+							},
+							this
+						)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(next);
+					}
+				],
+				(err, playlist) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(playlist);
 				}
 				}
-				cache.hset('playlists', playlistId, playlist, next);
-			}
+			)
+		);
+	}
+
+	/**
+	 * Deletes playlist from id from Mongo and cache
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to delete
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DELETE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.playlistModel.deleteOne({ _id: payload.playlistId }, next);
+					},
+
+					(res, next) => {
+						CacheModule.runJob(
+							"HDEL",
+							{
+								table: "playlists",
+								key: payload.playlistId
+							},
+							this
+						)
+							.then(() => next())
+							.catch(next);
+					},
+
+					next => {
+						StationsModule.runJob(
+							"REMOVE_INCLUDED_OR_EXCLUDED_PLAYLIST_FROM_STATIONS",
+							{ playlistId: payload.playlistId },
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(err => next(err));
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			)
+		);
+	}
 
 
-		], (err, playlist) => {
-			if (err && err !== true) cb(err);
-			cb(null, playlist);
+	/**
+	 * Searches through playlists
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the query
+	 * @param {string} payload.includePrivate - include private playlists
+	 * @param {string} payload.includeStation - include station playlists
+	 * @param {string} payload.includeUser - include user playlists
+	 * @param {string} payload.includeGenre - include genre playlists
+	 * @param {string} payload.includeArtist - include artist playlists
+	 * @param {string} payload.includeOwn - include own user playlists
+	 * @param {string} payload.userId - the user id of the person requesting
+	 * @param {string} payload.includeSongs - include songs
+	 * @param {string} payload.page - page (default 1)
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEARCH(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						const types = [];
+						if (payload.includeStation) types.push("station");
+						if (payload.includeUser) types.push("user");
+						if (payload.includeGenre) types.push("genre");
+						if (payload.includeArtist) types.push("artist");
+						if (types.length === 0 && !payload.includeOwn) return next("No types have been included.");
+
+						const privacies = ["public"];
+						if (payload.includePrivate) privacies.push("private");
+
+						const includeObject = payload.includeSongs ? null : { songs: false };
+						const filterArray = [
+							{
+								displayName: new RegExp(`${payload.query}`, "i"),
+								privacy: { $in: privacies },
+								type: { $in: types }
+							}
+						];
+
+						if (payload.includeOwn && payload.userId)
+							filterArray.push({
+								displayName: new RegExp(`${payload.query}`, "i"),
+								type: "user",
+								createdBy: payload.userId
+							});
+
+						return next(null, filterArray, includeObject);
+					},
+
+					(filterArray, includeObject, next) => {
+						const page = payload.page ? payload.page : 1;
+						const pageSize = 15;
+						const skipAmount = pageSize * (page - 1);
+
+						PlaylistsModule.playlistModel.find({ $or: filterArray }).count((err, count) => {
+							if (err) next(err);
+							else {
+								PlaylistsModule.playlistModel
+									.find({ $or: filterArray }, includeObject)
+									.skip(skipAmount)
+									.limit(pageSize)
+									.exec((err, playlists) => {
+										if (err) next(err);
+										else {
+											next(null, {
+												playlists,
+												page,
+												pageSize,
+												skipAmount,
+												count
+											});
+										}
+									});
+							}
+						});
+					},
+
+					(data, next) => {
+						if (data.playlists.length > 0) next(null, data);
+						else next("No playlists found");
+					}
+				],
+				(err, data) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(data);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Clears and refills a station playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to clear and refill
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CLEAR_AND_REFILL_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(playlist, next) => {
+						if (playlist.type !== "station") next("This playlist is not a station playlist.");
+						else next(null, playlist.createdFor);
+					},
+
+					(stationId, next) => {
+						PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
-	 * Deletes playlist from id from Mongo and cache
+	 * Clears and refills a genre playlist
 	 *
 	 *
-	 * @param {String} playlistId - the id of the playlist we are trying to delete
-	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to clear and refill
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	deletePlaylist: (playlistId, cb) => {
-		async.waterfall([
+	CLEAR_AND_REFILL_GENRE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
 
 
-			(next) => {
-				db.models.playlist.remove({ _id: playlistId }, next);
-			},
+					(playlist, next) => {
+						if (playlist.type !== "genre") next("This playlist is not a genre playlist.");
+						else next(null, playlist.createdFor);
+					},
 
 
-			(res, next) => {
-				cache.hdel('playlists', playlistId, next);
-			}
+					(genre, next) => {
+						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			);
+		});
+	}
 
 
-		], (err) => {
-			if (err && err !== true) cb(err);
+	/**
+	 * Clears and refills a artist playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the id of the playlist we are trying to clear and refill
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CLEAR_AND_REFILL_ARTIST_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { playlistId } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+							.then(playlist => {
+								next(null, playlist);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
 
 
-			cb(null);
+					(playlist, next) => {
+						if (playlist.type !== "artist") next("This playlist is not a artist playlist.");
+						else next(null, playlist.createdFor);
+					},
+
+					(artist, next) => {
+						PlaylistsModule.runJob("AUTOFILL_ARTIST_PLAYLIST", { artist }, this)
+							.then(() => {
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			);
 		});
 		});
 	}
 	}
-};
+}
+
+export default new _PlaylistsModule();

+ 306 - 0
backend/logic/punishments.js

@@ -0,0 +1,306 @@
+import async from "async";
+import mongoose from "mongoose";
+import CoreClass from "../core";
+
+let PunishmentsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+
+class _PunishmentsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("punishments");
+
+		PunishmentsModule = this;
+	}
+
+	/**
+	 * Initialises the punishments module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+
+		this.punishmentModel = this.PunishmentModel = await DBModule.runJob("GET_MODEL", { modelName: "punishment" });
+		this.punishmentSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "punishment" });
+
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						this.setStage(2);
+						CacheModule.runJob("HGETALL", { table: "punishments" })
+							.then(punishments => {
+								next(null, punishments);
+							})
+							.catch(next);
+					},
+
+					(punishments, next) => {
+						this.setStage(3);
+
+						if (!punishments) return next();
+
+						const punishmentIds = Object.keys(punishments);
+
+						return async.each(
+							punishmentIds,
+							(punishmentId, cb) => {
+								PunishmentsModule.punishmentModel.findOne({ _id: punishmentId }, (err, punishment) => {
+									if (err) next(err);
+									else if (!punishment)
+										CacheModule.runJob("HDEL", {
+											table: "punishments",
+											key: punishmentId
+										})
+											.then(() => cb())
+											.catch(next);
+									else cb();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(4);
+						PunishmentsModule.punishmentModel.find({}, next);
+					},
+
+					(punishments, next) => {
+						this.setStage(5);
+						async.each(
+							punishments,
+							(punishment, next) => {
+								if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
+
+								return CacheModule.runJob("HSET", {
+									table: "punishments",
+									key: punishment._id,
+									value: PunishmentsModule.punishmentSchemaCache(punishment, punishment._id)
+								})
+									.then(() => next())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						const formattedErr = await UtilsModule.runJob("GET_ERROR", { error: err });
+						reject(new Error(formattedErr));
+					} else resolve();
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets all punishments in the cache that are active, and removes those that have expired
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PUNISHMENTS() {
+		return new Promise((resolve, reject) => {
+			const punishmentsToRemove = [];
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob("HGETALL", { table: "punishments" }, this)
+							.then(punishmentsObj => next(null, punishmentsObj))
+							.catch(next);
+					},
+
+					(punishmentsObj, next) => {
+						const punishments = Object.keys(punishmentsObj).map(punishmentKey => {
+							const punishment = punishmentsObj[punishmentKey];
+							punishment.punishmentId = punishmentKey;
+							return punishment;
+						});
+
+						const filteredPunishments = punishments.filter(punishment => {
+							if (punishment.expiresAt < Date.now()) punishmentsToRemove.push(punishment);
+							return punishment.expiresAt > Date.now();
+						});
+
+						next(null, filteredPunishments);
+					},
+
+					(punishments, next) => {
+						async.each(
+							punishmentsToRemove,
+							(punishment, next2) => {
+								CacheModule.runJob(
+									"HDEL",
+									{
+										table: "punishments",
+										key: punishment.punishmentId
+									},
+									this
+								).finally(() => next2());
+							},
+							() => {
+								next(null, punishments);
+							}
+						);
+					}
+				],
+				(err, punishments) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(punishments);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets a punishment by id
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.id - the id of the punishment we are trying to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PUNISHMENT(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						if (!mongoose.Types.ObjectId.isValid(payload.id)) return next("Id is not a valid ObjectId.");
+						return CacheModule.runJob(
+							"HGET",
+							{
+								table: "punishments",
+								key: payload.id
+							},
+							this
+						)
+							.then(punishment => next(null, punishment))
+							.catch(next);
+					},
+
+					(punishment, next) => {
+						if (punishment) return next(true, punishment);
+						return PunishmentsModule.punishmentModel.findOne({ _id: payload.id }, next);
+					},
+
+					(punishment, next) => {
+						if (punishment) {
+							CacheModule.runJob(
+								"HSET",
+								{
+									table: "punishments",
+									key: payload.id,
+									value: punishment
+								},
+								this
+							)
+								.then(punishment => {
+									next(null, punishment);
+								})
+								.catch(next);
+						} else next("Punishment not found.");
+					}
+				],
+				(err, punishment) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(punishment);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets all punishments from a userId
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.userId - the userId of the punishment(s) we are trying to get
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PUNISHMENTS_FROM_USER_ID(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						PunishmentsModule.runJob("GET_PUNISHMENTS", {}, this)
+							.then(punishments => {
+								next(null, punishments);
+							})
+							.catch(next);
+					},
+					(punishments, next) => {
+						const filteredPunishments = punishments.filter(
+							punishment => punishment.type === "banUserId" && punishment.value === payload.userId
+						);
+						next(null, filteredPunishments);
+					}
+				],
+				(err, punishments) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(punishments);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Adds a new punishment to the database
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.reason - the reason for the punishment e.g. spam
+	 * @param {string} payload.type - the type of punishment (enum: ["banUserId", "banUserIp"])
+	 * @param {string} payload.value - the user id/ip address for the ban (depends on punishment type)
+	 * @param {Date} payload.expiresAt - the date at which the punishment expires at
+	 * @param {string} payload.punishedBy - the userId of the who initiated the punishment
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	ADD_PUNISHMENT(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						const punishment = new PunishmentsModule.PunishmentModel({
+							type: payload.type,
+							value: payload.value,
+							reason: payload.reason,
+							active: true,
+							expiresAt: payload.expiresAt,
+							punishedAt: Date.now(),
+							punishedBy: payload.punishedBy
+						});
+						punishment.save((err, punishment) => {
+							if (err) return next(err);
+							return next(null, punishment);
+						});
+					},
+
+					(punishment, next) => {
+						CacheModule.runJob(
+							"HSET",
+							{
+								table: "punishments",
+								key: punishment._id,
+								value: PunishmentsModule.punishmentSchemaCache(punishment, punishment._id)
+							},
+							this
+						)
+							.then(() => next(null, punishment))
+							.catch(next);
+					}
+				],
+				(err, punishment) => {
+					if (err) return reject(new Error(err));
+					return resolve(punishment);
+				}
+			)
+		);
+	}
+}
+
+export default new _PunishmentsModule();

+ 1200 - 93
backend/logic/songs.js

@@ -1,135 +1,1242 @@
-'use strict';
+import async from "async";
+import config from "config";
+import mongoose from "mongoose";
+import CoreClass from "../core";
 
 
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
-const async = require('async');
+let SongsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+let YouTubeModule;
+let StationsModule;
+let PlaylistsModule;
 
 
-module.exports = {
+class ErrorWithData extends Error {
+	/**
+	 * @param {string} message - the error message
+	 * @param {object} data - the error data
+	 */
+	constructor(message, data) {
+		super(message);
+		this.data = data;
+	}
+}
+
+class _SongsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("songs");
+
+		SongsModule = this;
+	}
+
+	/**
+	 * Initialises the songs module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+		YouTubeModule = this.moduleManager.modules.youtube;
+		StationsModule = this.moduleManager.modules.stations;
+		PlaylistsModule = this.moduleManager.modules.playlists;
+
+		this.SongModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
+		this.SongSchemaCache = await CacheModule.runJob("GET_SCHEMA", { schemaName: "song" });
+
+		this.setStage(2);
+
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						this.setStage(2);
+						CacheModule.runJob("HGETALL", { table: "songs" })
+							.then(songs => {
+								next(null, songs);
+							})
+							.catch(next);
+					},
+
+					(songs, next) => {
+						this.setStage(3);
+
+						if (!songs) return next();
+
+						const youtubeIds = Object.keys(songs);
+
+						return async.each(
+							youtubeIds,
+							(youtubeId, next) => {
+								SongsModule.SongModel.findOne({ youtubeId }, (err, song) => {
+									if (err) next(err);
+									else if (!song)
+										CacheModule.runJob("HDEL", {
+											table: "songs",
+											key: youtubeId
+										})
+											.then(() => next())
+											.catch(next);
+									else next();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(4);
+						SongsModule.SongModel.find({}, next);
+					},
+
+					(songs, next) => {
+						this.setStage(5);
+						async.each(
+							songs,
+							(song, next) => {
+								CacheModule.runJob("HSET", {
+									table: "songs",
+									key: song.youtubeId,
+									value: SongsModule.SongSchemaCache(song)
+								})
+									.then(() => next())
+									.catch(next);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err });
+						reject(new Error(err));
+					} else resolve();
+				}
+			)
+		);
+	}
 
 
 	/**
 	/**
-	 * Initializes the songs module, and exits if it is unsuccessful
+	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
 	 *
 	 *
-	 * @param {Function} cb - gets called once we're done initializing
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.songId - the id of the song we are trying to get
+	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('songs', next);
-			},
+	GET_SONG(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						if (!mongoose.Types.ObjectId.isValid(payload.songId))
+							return next("songId is not a valid ObjectId.");
+						return CacheModule.runJob("HGET", { table: "songs", key: payload.songId }, this)
+							.then(song => next(null, song))
+							.catch(next);
+					},
+
+					(song, next) => {
+						if (song) return next(true, song);
+						return SongsModule.SongModel.findOne({ _id: payload.songId }, next);
+					},
+
+					(song, next) => {
+						if (song) {
+							CacheModule.runJob(
+								"HSET",
+								{
+									table: "songs",
+									key: payload.songId,
+									value: song
+								},
+								this
+							).then(song => next(null, song));
+						} else next("Song not found.");
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			)
+		);
+	}
 
 
-			(songs, next) => {
-				if (!songs) return next();
-				let songIds = Object.keys(songs);
-				async.each(songIds, (songId, next) => {
-					db.models.song.findOne({ _id: songId }, (err, song) => {
-						if (err) next(err);
-						else if (!song) cache.hdel('songs', songId, next);
+	/**
+	 * Gets songs by id from Mongo
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.songIds - the ids of the songs we are trying to get
+	 * @param {string} payload.properties - the properties to return
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_SONGS(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						if (!payload.songIds.every(songId => mongoose.Types.ObjectId.isValid(songId)))
+							next("One or more songIds are not a valid ObjectId.");
 						else next();
 						else next();
+					},
+
+					next => {
+						const includeProperties = {};
+						payload.properties.forEach(property => {
+							includeProperties[property] = true;
+						});
+						return SongsModule.SongModel.find(
+							{
+								_id: { $in: payload.songIds }
+							},
+							includeProperties,
+							next
+						);
+					}
+				],
+				(err, songs) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ songs });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Makes sure that if a song is not currently in the songs db, to add it
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube song id of the song we are trying to ensure is in the songs db
+	 * @param {string} payload.userId - the youtube song id of the song we are trying to ensure is in the songs db
+	 * @param {string} payload.automaticallyRequested - whether the song was automatically requested or not
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	ENSURE_SONG_EXISTS_BY_YOUTUBE_ID(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
+					},
+
+					(song, next) => {
+						if (song && song.duration > 0) next(true, song);
+						else {
+							YouTubeModule.runJob("GET_SONG", { youtubeId: payload.youtubeId }, this)
+								.then(response => {
+									next(null, song, response.song);
+								})
+								.catch(next);
+						}
+					},
+
+					(song, youtubeSong, next) => {
+						if (song && song.duration <= 0) {
+							song.duration = youtubeSong.duration;
+							song.save({ validateBeforeSave: true }, err => {
+								if (err) return next(err, song);
+								return next(null, song);
+							});
+						} else {
+							const status =
+								(!payload.userId && config.get("hideAnonymousSongs")) ||
+								(payload.automaticallyRequested && config.get("hideAutomaticallyRequestedSongs"))
+									? "hidden"
+									: "unverified";
+
+							const song = new SongsModule.SongModel({
+								...youtubeSong,
+								status,
+								requestedBy: payload.userId,
+								requestedAt: Date.now()
+							});
+							song.save({ validateBeforeSave: true }, err => {
+								if (err) return next(err, song);
+								return next(null, song);
+							});
+						}
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets a song by youtube id
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id of the song we are trying to get
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_SONG_FROM_YOUTUBE_ID(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ youtubeId: payload.youtubeId }, next);
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ song });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets a song from id from Mongo and updates the cache with it
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.songId - the id of the song we are trying to update
+	 * @param {string} payload.oldStatus - old status of song being updated (optional)
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UPDATE_SONG(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ _id: payload.songId }, next);
+					},
+
+					(song, next) => {
+						if (!song) {
+							CacheModule.runJob("HDEL", {
+								table: "songs",
+								key: payload.songId
+							});
+							return next("Song not found.");
+						}
+
+						return CacheModule.runJob(
+							"HSET",
+							{
+								table: "songs",
+								key: payload.songId,
+								value: song
+							},
+							this
+						)
+							.then(song => {
+								next(null, song);
+							})
+							.catch(next);
+					},
+
+					(song, next) => {
+						const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+						const trimmedSong = {
+							_id,
+							youtubeId,
+							title,
+							artists,
+							thumbnail,
+							duration,
+							status
+						};
+						this.log("INFO", `Going to update playlists now for song ${_id}`);
+						DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this)
+							.then(playlistModel => {
+								playlistModel.updateMany(
+									{ "songs._id": song._id },
+									{ $set: { "songs.$": trimmedSong } },
+									err => {
+										if (err) next(err);
+										else
+											playlistModel.find({ "songs._id": song._id }, (err, playlists) => {
+												if (err) next(err);
+												else {
+													async.eachLimit(
+														playlists,
+														1,
+														(playlist, next) => {
+															PlaylistsModule.runJob(
+																"UPDATE_PLAYLIST",
+																{
+																	playlistId: playlist._id
+																},
+																this
+															)
+																.then(() => {
+																	next();
+																})
+																.catch(err => {
+																	next(err);
+																});
+														},
+														err => {
+															if (err) next(err);
+															else next(null, song);
+														}
+													);
+												}
+											});
+									}
+								);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(song, next) => {
+						const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+						this.log("INFO", `Going to update stations now for song ${_id}`);
+						DBModule.runJob("GET_MODEL", { modelName: "station" }, this)
+							.then(stationModel => {
+								stationModel.updateMany(
+									{ "queue._id": song._id },
+									{
+										$set: {
+											"queue.$.youtubeId": youtubeId,
+											"queue.$.title": title,
+											"queue.$.artists": artists,
+											"queue.$.thumbnail": thumbnail,
+											"queue.$.duration": duration,
+											"queue.$.status": status
+										}
+									},
+									err => {
+										if (err) this.log("ERROR", err);
+										else
+											stationModel.find({ "queue._id": song._id }, (err, stations) => {
+												if (err) next(err);
+												else {
+													async.eachLimit(
+														stations,
+														1,
+														(station, next) => {
+															StationsModule.runJob(
+																"UPDATE_STATION",
+																{ stationId: station._id },
+																this
+															)
+																.then(() => {
+																	next();
+																})
+																.catch(err => {
+																	next(err);
+																});
+														},
+														err => {
+															if (err) next(err);
+															else next(null, song);
+														}
+													);
+												}
+											});
+									}
+								);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(song, next) => {
+						async.eachLimit(
+							song.genres,
+							1,
+							(genre, next) => {
+								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => next(err));
+							},
+							err => {
+								next(err, song);
+							}
+						);
+						async.eachLimit(
+							song.artists,
+							1,
+							(artist, next) => {
+								PlaylistsModule.runJob("AUTOFILL_ARTIST_PLAYLIST", { artist }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => next(err));
+							},
+							err => {
+								next(err, song);
+							}
+						);
+					}
+				],
+				(err, song) => {
+					if (err && err !== true) return reject(new Error(err));
+
+					if (!payload.oldStatus) payload.oldStatus = null;
+
+					CacheModule.runJob("PUB", {
+						channel: "song.updated",
+						value: { songId: song._id, oldStatus: payload.oldStatus }
 					});
 					});
-				}, next);
-			},
-
-			(next) => {
-				db.models.song.find({}, next);
-			},
-
-			(songs, next) => {
-				async.each(songs, (song, next) => {
-					cache.hset('songs', song._id, cache.schemas.song(song), next);
-				}, next);
-			}
-		], (err) => {
-			if (err) {
-				console.log(`FAILED TO INITIALIZE SONGS. ABORTING. "${err.message}"`);
-				process.exit();
-			} else cb();
+
+					return resolve(song);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Updates all songs
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UPDATE_ALL_SONGS() {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find({}, next);
+					},
+
+					(songs, next) => {
+						let index = 0;
+						const { length } = songs;
+						async.eachLimit(
+							songs,
+							2,
+							(song, next) => {
+								index += 1;
+								console.log(`Updating song #${index} out of ${length}: ${song._id}`);
+								SongsModule.runJob("UPDATE_SONG", { songId: song._id }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							err => {
+								next(err);
+							}
+						);
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve();
+				}
+			)
+		);
+	}
+
+	// /**
+	//  * Deletes song from id from Mongo and cache
+	//  *
+	//  * @param {object} payload - returns an object containing the payload
+	//  * @param {string} payload.songId - the song id of the song we are trying to delete
+	//  * @returns {Promise} - returns a promise (resolve, reject)
+	//  */
+	// DELETE_SONG(payload) {
+	// 	return new Promise((resolve, reject) =>
+	// 		async.waterfall(
+	// 			[
+	// 				next => {
+	// 					SongsModule.SongModel.deleteOne({ _id: payload.songId }, next);
+	// 				},
+
+	// 				next => {
+	// 					CacheModule.runJob(
+	// 						"HDEL",
+	// 						{
+	// 							table: "songs",
+	// 							key: payload.songId
+	// 						},
+	// 						this
+	// 					)
+	// 						.then(() => next())
+	// 						.catch(next);
+	// 				},
+
+	// 				next => {
+	// 					this.log("INFO", `Going to update playlists and stations now for deleted song ${payload.songId}`);
+	// 					DBModule.runJob("GET_MODEL", { modelName: "playlist" }).then(playlistModel => {
+	// 						playlistModel.find({ "songs._id": song._id }, (err, playlists) => {
+	// 							if (err) this.log("ERROR", err);
+	// 							else {
+	// 								playlistModel.updateMany(
+	// 									{ "songs._id": payload.songId },
+	// 									{ $pull: { "songs.$._id": payload.songId} },
+	// 									err => {
+	// 										if (err) this.log("ERROR", err);
+	// 										else {
+	// 											playlists.forEach(playlist => {
+	// 												PlaylistsModule.runJob("UPDATE_PLAYLIST", {
+	// 													playlistId: playlist._id
+	// 												});
+	// 											});
+	// 										}
+	// 									}
+	// 								);
+
+	// 							}
+	// 						});
+	// 					});
+	// 					DBModule.runJob("GET_MODEL", { modelName: "station" }).then(stationModel => {
+	// 						stationModel.find({ "queue._id": payload.songId }, (err, stations) => {
+	// 							stationModel.updateMany(
+	// 								{ "queue._id": payload.songId },
+	// 								{
+	// 									$pull: { "queue._id":  }
+	// 								},
+	// 								err => {
+	// 									if (err) this.log("ERROR", err);
+	// 									else {
+	// 										stations.forEach(station => {
+	// 											StationsModule.runJob("UPDATE_STATION", { stationId: station._id });
+	// 										});
+	// 									}
+	// 								}
+	// 							);
+	// 						});
+	// 					});
+	// 				}
+	// 			],
+	// 			err => {
+	// 				if (err && err !== true) return reject(new Error(err));
+	// 				return resolve();
+	// 			}
+	// 		)
+	// 	);
+	// }
+
+	/**
+	 * Searches through songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the query
+	 * @param {string} payload.includeHidden - include hidden songs
+	 * @param {string} payload.includeUnverified - include unverified songs
+	 * @param {string} payload.includeVerified - include verified songs
+	 * @param {string} payload.trimmed - include trimmed songs
+	 * @param {string} payload.page - page (default 1)
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEARCH(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						const statuses = [];
+						if (payload.includeHidden) statuses.push("hidden");
+						if (payload.includeUnverified) statuses.push("unverified");
+						if (payload.includeVerified) statuses.push("verified");
+						if (statuses.length === 0) return next("No statuses have been included.");
+
+						let { query } = payload;
+
+						const isRegex =
+							query.length > 2 && query.indexOf("/") === 0 && query.lastIndexOf("/") === query.length - 1;
+						if (isRegex) query = query.slice(1, query.length - 1);
+						else query = query.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+						const filterArray = [
+							{
+								title: new RegExp(`${query}`, "i"),
+								status: { $in: statuses }
+							},
+							{
+								artists: new RegExp(`${query}`, "i"),
+								status: { $in: statuses }
+							}
+						];
+
+						return next(null, filterArray);
+					},
+
+					(filterArray, next) => {
+						const page = payload.page ? payload.page : 1;
+						const pageSize = 15;
+						const skipAmount = pageSize * (page - 1);
+
+						SongsModule.SongModel.find({ $or: filterArray }).count((err, count) => {
+							if (err) next(err);
+							else {
+								SongsModule.SongModel.find({ $or: filterArray })
+									.skip(skipAmount)
+									.limit(pageSize)
+									.exec((err, songs) => {
+										if (err) next(err);
+										else {
+											next(null, {
+												songs,
+												page,
+												pageSize,
+												skipAmount,
+												count
+											});
+										}
+									});
+							}
+						});
+					},
+
+					(data, next) => {
+						if (data.songs.length === 0) next("No songs found");
+						else if (payload.trimmed) {
+							next(null, {
+								songs: data.songs.map(song => {
+									const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+									return {
+										_id,
+										youtubeId,
+										title,
+										artists,
+										thumbnail,
+										duration,
+										status
+									};
+								}),
+								...data
+							});
+						} else next(null, data);
+					}
+				],
+				(err, data) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(data);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Recalculates dislikes and likes for a song
+	 *
+	 * @param {object} payload - returns an object containing the payload
+	 * @param {string} payload.youtubeId - the youtube id of the song
+	 * @param {string} payload.songId - the song id of the song
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async RECALCULATE_SONG_RATINGS(payload) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						playlistModel.countDocuments(
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, displayName: "Liked Songs" },
+							(err, likes) => {
+								if (err) return next(err);
+								return next(null, likes);
+							}
+						);
+					},
+
+					(likes, next) => {
+						playlistModel.countDocuments(
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, displayName: "Disliked Songs" },
+							(err, dislikes) => {
+								if (err) return next(err);
+								return next(err, { likes, dislikes });
+							}
+						);
+					},
+
+					({ likes, dislikes }, next) => {
+						SongsModule.SongModel.updateOne(
+							{ _id: payload.songId },
+							{
+								$set: {
+									likes,
+									dislikes
+								}
+							},
+							err => next(err, { likes, dislikes })
+						);
+					}
+				],
+				(err, { likes, dislikes }) => {
+					if (err) return reject(new Error(err));
+					return resolve({ likes, dislikes });
+				}
+			);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
-	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 * Gets an array of all genres
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_ALL_GENRES() {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find({ status: "verified" }, { genres: 1, _id: false }, next);
+					},
+
+					(songs, next) => {
+						let allGenres = [];
+						songs.forEach(song => {
+							allGenres = allGenres.concat(song.genres);
+						});
+
+						const lowerCaseGenres = allGenres.map(genre => genre.toLowerCase());
+						const uniqueGenres = lowerCaseGenres.filter(
+							(value, index, self) => self.indexOf(value) === index
+						);
+
+						next(null, uniqueGenres);
+					}
+				],
+				(err, genres) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ genres });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets an array of all artists
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_ALL_ARTISTS() {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find({ status: "verified" }, { artists: 1, _id: false }, next);
+					},
+
+					(songs, next) => {
+						let allArtists = [];
+						songs.forEach(song => {
+							allArtists = allArtists.concat(song.artists);
+						});
+
+						const lowerCaseArtists = allArtists.map(artist => artist.toLowerCase());
+						const uniqueArtists = lowerCaseArtists.filter(
+							(value, index, self) => self.indexOf(value) === index
+						);
+
+						next(null, uniqueArtists);
+					}
+				],
+				(err, artists) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ artists });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets an array of all songs with a specific genre
+	 *
+	 * @param {object} payload - returns an object containing the payload
+	 * @param {string} payload.genre - the genre
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_ALL_SONGS_WITH_GENRE(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find(
+							{
+								status: "verified",
+								genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") }
+							},
+							next
+						);
+					}
+				],
+				(err, songs) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ songs });
+				}
+			)
+		);
+	}
+
+	/**
+	 * Gets an array of all songs with a specific artist
+	 *
+	 * @param {object} payload - returns an object containing the payload
+	 * @param {string} payload.artist - the artist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_ALL_SONGS_WITH_ARTIST(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find(
+							{
+								status: "verified",
+								artists: { $regex: new RegExp(`^${payload.artist.toLowerCase()}$`, "i") }
+							},
+							next
+						);
+					}
+				],
+				(err, songs) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ songs });
+				}
+			)
+		);
+	}
+
+	// runjob songs GET_ORPHANED_PLAYLIST_SONGS {}
+
+	/**
+	 * Gets a orphaned playlist songs
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ORPHANED_PLAYLIST_SONGS() {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this).then(playlistModel => {
+				playlistModel.find({}, (err, playlists) => {
+					if (err) reject(new Error(err));
+					else {
+						SongsModule.SongModel.find({}, { _id: true, youtubeId: true }, (err, songs) => {
+							if (err) reject(new Error(err));
+							else {
+								const songIds = songs.map(song => song._id.toString());
+								const orphanedYoutubeIds = new Set();
+								async.eachLimit(
+									playlists,
+									1,
+									(playlist, next) => {
+										playlist.songs.forEach(song => {
+											if (
+												(!song._id || songIds.indexOf(song._id.toString() === -1)) &&
+												!orphanedYoutubeIds.has(song.youtubeId)
+											) {
+												orphanedYoutubeIds.add(song.youtubeId);
+											}
+										});
+										next();
+									},
+									() => {
+										resolve({ youtubeIds: Array.from(orphanedYoutubeIds) });
+									}
+								);
+							}
+						});
+					}
+				});
+			});
+		});
+	}
+
+	/**
+	 * Requests a song, adding it to the DB
 	 *
 	 *
-	 * @param {String} songId - the id of the song we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
+	 * @param {object} payload - The payload
+	 * @param {string} payload.youtubeId - The YouTube song id of the song
+	 * @param {string} payload.userId - The user id of the person requesting the song
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	getSong: function(songId, cb) {
-		async.waterfall([
+	REQUEST_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { youtubeId, userId } = payload;
+			const requestedAt = Date.now();
+
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+							.then(UserModel => {
+								UserModel.findOne({ _id: userId }, { "preferences.anonymousSongRequests": 1 }, next);
+							})
+							.catch(next);
+					},
 
 
-			(next) => {
-				cache.hget('songs', songId, next);
-			},
+					(user, next) => {
+						SongsModule.SongModel.findOne({ youtubeId }, (err, song) => next(err, user, song));
+					},
 
 
-			(song, next) => {
-				if (song) return next(true, song);
+					// Get YouTube data from id
+					(user, song, next) => {
+						if (song) return next("This song is already in the database.", song);
+						// TODO Add err object as first param of callback
 
 
-				db.models.song.findOne({ _id: songId }, next);
-			},
+						const requestedBy = user.preferences.anonymousSongRequests ? null : userId;
+						const status = !requestedBy && config.get("hideAnonymousSongs") ? "hidden" : "unverified";
 
 
-			(song, next) => {
-				if (song) {
-					cache.hset('songs', songId, song, next);
-				} else next('Song not found.');
-			},
+						return YouTubeModule.runJob("GET_SONG", { youtubeId }, this)
+							.then(response => {
+								const { song } = response;
+								song.artists = [];
+								song.genres = [];
+								song.skipDuration = 0;
+								song.explicit = false;
+								song.requestedBy = user.preferences.anonymousSongRequests ? null : userId;
+								song.requestedAt = requestedAt;
+								song.status = status;
+								next(null, song);
+							})
+							.catch(next);
+					},
+					(newSong, next) => {
+						const song = new SongsModule.SongModel(newSong);
+						song.save({ validateBeforeSave: false }, err => {
+							if (err) return next(err, song);
+							return next(null, song);
+						});
+					},
+					(song, next) => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+							.then(UserModel => {
+								UserModel.findOne({ _id: userId }, (err, user) => {
+									if (err) return next(err);
+									if (!user) return next(null, song);
 
 
-		], (err, song) => {
-			if (err && err !== true) return cb(err);
+									user.statistics.songsRequested += 1;
 
 
-			cb(null, song);
+									return user.save(err => {
+										if (err) return next(err);
+										return next(null, song);
+									});
+								});
+							})
+							.catch(next);
+					}
+				],
+				async (err, song) => {
+					if (err && err !== "This song is already in the database.") return reject(err);
+
+					const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+					const trimmedSong = {
+						_id,
+						youtubeId,
+						title,
+						artists,
+						thumbnail,
+						duration,
+						status
+					};
+
+					if (err && err === "This song is already in the database.")
+						return reject(new ErrorWithData(err, { song: trimmedSong }));
+
+					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
+
+					return resolve({ song: trimmedSong });
+				}
+			);
 		});
 		});
-	},
+	}
 
 
 	/**
 	/**
-	 * Gets a song from id from Mongo and updates the cache with it
+	 * Hides a song
 	 *
 	 *
-	 * @param {String} songId - the id of the song we are trying to update
-	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
+	 * @param {object} payload - The payload
+	 * @param {string} payload.songId - The song id of the song
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	updateSong: (songId, cb) => {
-		async.waterfall([
+	HIDE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { songId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ _id: songId }, next);
+					},
 
 
-			(next) => {
-				db.models.song.findOne({ _id: songId }, next);
-			},
+					// Get YouTube data from id
+					(song, next) => {
+						if (!song) return next("This song does not exist.");
+						if (song.status === "hidden") return next("This song is already hidden.");
+						// TODO Add err object as first param of callback
+						return next(null, song.status);
+					},
 
 
-			(song, next) => {
-				if (!song) {
-					cache.hdel('songs', songId);
-					return next('Song not found.');
+					(oldStatus, next) => {
+						SongsModule.SongModel.updateOne({ _id: songId }, { status: "hidden" }, res =>
+							next(null, res, oldStatus)
+						);
+					},
+
+					(res, oldStatus, next) => {
+						SongsModule.runJob("UPDATE_SONG", { songId, oldStatus });
+						next();
+					}
+				],
+				async err => {
+					if (err) reject(err);
+					resolve();
 				}
 				}
+			);
+		});
+	}
+
+	/**
+	 * Unhides a song
+	 *
+	 * @param {object} payload - The payload
+	 * @param {string} payload.songId - The song id of the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	UNHIDE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { songId } = payload;
 
 
-				cache.hset('songs', songId, song, next);
-			}
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.findOne({ _id: songId }, next);
+					},
 
 
-		], (err, song) => {
-			if (err && err !== true) cb(err);
+					// Get YouTube data from id
+					(song, next) => {
+						if (!song) return next("This song does not exist.");
+						if (song.status !== "hidden") return next("This song is not hidden.");
+						// TODO Add err object as first param of callback
+						return next();
+					},
 
 
-			cb(null, song);
+					next => {
+						SongsModule.SongModel.updateOne({ _id: songId }, { status: "unverified" }, next);
+					},
+
+					(res, next) => {
+						SongsModule.runJob("UPDATE_SONG", { songId, oldStatus: "hidden" });
+						next();
+					}
+				],
+				async err => {
+					if (err) reject(err);
+					resolve();
+				}
+			);
 		});
 		});
-	},
+	}
+
+	// runjob songs REQUEST_ORPHANED_PLAYLIST_SONGS {}
 
 
 	/**
 	/**
-	 * Deletes song from id from Mongo and cache
+	 * Requests all orphaned playlist songs, adding them to the database
 	 *
 	 *
-	 * @param {String} songId - the id of the song we are trying to delete
-	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
+	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
 	 */
-	deleteSong: (songId, cb) => {
-		async.waterfall([
+	REQUEST_ORPHANED_PLAYLIST_SONGS() {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob("GET_MODEL", { modelName: "playlist" })
+				.then(playlistModel => {
+					SongsModule.runJob("GET_ORPHANED_PLAYLIST_SONGS", {}, this).then(response => {
+						const { youtubeIds } = response;
+						const playlistsToUpdate = new Set();
+
+						async.eachLimit(
+							youtubeIds,
+							1,
+							(youtubeId, next) => {
+								async.waterfall(
+									[
+										next => {
+											console.log(
+												youtubeId,
+												`this is song ${youtubeIds.indexOf(youtubeId) + 1}/${youtubeIds.length}`
+											);
+											setTimeout(next, 150);
+										},
 
 
-			(next) => {
-				db.models.song.remove({ _id: songId }, next);
-			},
+										next => {
+											SongsModule.runJob(
+												"ENSURE_SONG_EXISTS_BY_SONG_ID",
+												{ youtubeId, automaticallyRequested: true },
+												this
+											)
+												.then(() => next())
+												.catch(next);
+										},
 
 
-			(next) => {
-				cache.hdel('songs', songId, next);
-			}
+										next => {
+											console.log(444, youtubeId);
 
 
-		], (err) => {
-			if (err && err !== true) cb(err);
+											SongsModule.SongModel.findOne({ youtubeId }, next);
+										},
 
 
-			cb(null);
+										(song, next) => {
+											const { _id, title, artists, thumbnail, duration, status } = song;
+											const trimmedSong = {
+												_id,
+												youtubeId,
+												title,
+												artists,
+												thumbnail,
+												duration,
+												status
+											};
+											playlistModel.updateMany(
+												{ "songs.youtubeId": song.youtubeId },
+												{ $set: { "songs.$": trimmedSong } },
+												err => {
+													next(err, song);
+												}
+											);
+										},
+
+										(song, next) => {
+											playlistModel.find({ "songs._id": song._id }, next);
+										},
+
+										(playlists, next) => {
+											playlists.forEach(playlist => {
+												playlistsToUpdate.add(playlist._id.toString());
+											});
+
+											next();
+										}
+									],
+									next
+								);
+							},
+							err => {
+								if (err) reject(err);
+								else {
+									async.eachLimit(
+										Array.from(playlistsToUpdate),
+										1,
+										(playlistId, next) => {
+											PlaylistsModule.runJob(
+												"UPDATE_PLAYLIST",
+												{
+													playlistId
+												},
+												this
+											)
+												.then(() => {
+													next();
+												})
+												.catch(next);
+										},
+										err => {
+											if (err) reject(err);
+											else resolve();
+										}
+									);
+								}
+							}
+						);
+					});
+				})
+				.catch(reject);
 		});
 		});
 	}
 	}
-};
+}
+
+export default new _SongsModule();

+ 1625 - 457
backend/logic/stations.js

@@ -1,504 +1,1672 @@
-'use strict';
-
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
-const logger = require('./logger');
-const songs = require('./songs');
-const notifications = require('./notifications');
-const async = require('async');
-
-//TEMP
-cache.sub('station.pause', (stationId) => {
-	notifications.remove(`stations.nextSong?id=${stationId}`);
-});
-
-cache.sub('station.resume', (stationId) => {
-	module.exports.initializeStation(stationId)
-});
-
-cache.sub('station.queueUpdate', (stationId) => {
-	module.exports.getStation(stationId, (err, station) => {
-		if (!station.currentSong && station.queue.length > 0) {
-			module.exports.initializeStation(stationId);
-		}
-	});
-});
-
-cache.sub('station.newOfficialPlaylist', (stationId) => {
-	cache.hget("officialPlaylists", stationId, (err, playlistObj) => {
-		if (!err && playlistObj) {
-			utils.emitToRoom(`station.${stationId}`, "event:newOfficialPlaylist", playlistObj.songs);
-		}
-	})
-});
-
-module.exports = {
-
-	init: function(cb) {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('stations', next);
-			},
-
-			(stations, next) => {
-				if (!stations) return next();
-				let stationIds = Object.keys(stations);
-				async.each(stationIds, (stationId, next) => {
-					db.models.station.findOne({_id: stationId}, (err, station) => {
-						if (err) next(err);
-						else if (!station) {
-							cache.hdel('stations', stationId, next);
-						} else next();
-					});
-				}, next);
-			},
-
-			(next) => {
-				db.models.station.find({}, next);
-			},
-
-			(stations, next) => {
-				async.each(stations, (station, next) => {
-					async.waterfall([
-						(next) => {
-							cache.hset('stations', station._id, cache.schemas.station(station), next);
-						},
+import async from "async";
 
 
-						(station, next) => {
-							this.initializeStation(station._id, next);
-						}
-					], (err) => {
-						next(err);
-					});
-				}, next);
+import CoreClass from "../core";
+
+let StationsModule;
+let CacheModule;
+let DBModule;
+let UtilsModule;
+let WSModule;
+let SongsModule;
+let PlaylistsModule;
+let NotificationsModule;
+
+class _StationsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("stations");
+
+		StationsModule = this;
+	}
+
+	/**
+	 * Initialises the stations module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		UtilsModule = this.moduleManager.modules.utils;
+		WSModule = this.moduleManager.modules.ws;
+		SongsModule = this.moduleManager.modules.songs;
+		PlaylistsModule = this.moduleManager.modules.playlists;
+		NotificationsModule = this.moduleManager.modules.notifications;
+
+		this.userList = {};
+		this.usersPerStation = {};
+		this.usersPerStationCount = {};
+
+		// TEMP
+		CacheModule.runJob("SUB", {
+			channel: "station.pause",
+			cb: async stationId => {
+				NotificationsModule.runJob("REMOVE", {
+					subscription: `stations.nextSong?id=${stationId}`
+				}).then();
 			}
 			}
-		], (err) => {
-			if (err) {
-				console.log(`FAILED TO INITIALIZE STATIONS. ABORTING. "${err.message}"`);
-				process.exit();
-			} else cb();
 		});
 		});
-	},
-
-	initializeStation: function(stationId, cb) {
-		if (typeof cb !== 'function') cb = ()=>{};
-		let _this = this;
-		_this.getStation(stationId, (err, station) => {
-			if (!err) {
-				if (station) {
-					let notification = notifications.subscribe(`stations.nextSong?id=${station._id}`, _this.skipStation(station._id), true);
-					if (!station.paused ) {
-						/*if (!station.startedAt) {
-						 station.startedAt = Date.now();
-						 station.timePaused = 0;
-						 cache.hset('stations', stationId, station);
-						 }*/
-						if (station.currentSong) {
-							let timeLeft = ((station.currentSong.duration * 1000) - (Date.now() - station.startedAt - station.timePaused));
-							if (isNaN(timeLeft)) timeLeft = -1;
-							if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
-								this.skipStation(station._id)((err, station) => {
-									cb(err, station);
-								});
-							} else {
-								notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft);
-								cb(null, station);
-							}
-						} else {
-							_this.skipStation(station._id)((err, station) => {
-								cb(err, station);
-							});
-						}
-					} else {
-						notifications.unschedule(`stations.nextSong?id${station._id}`);
-						cb(null, station);
+
+		CacheModule.runJob("SUB", {
+			channel: "station.resume",
+			cb: async stationId => {
+				StationsModule.runJob("INITIALIZE_STATION", { stationId }).then();
+			}
+		});
+
+		CacheModule.runJob("SUB", {
+			channel: "station.queueUpdate",
+			cb: async stationId => {
+				StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+					if (!station.currentSong && station.queue.length > 0) {
+						StationsModule.runJob("INITIALIZE_STATION", {
+							stationId
+						}).then();
 					}
 					}
-				} else cb("Station not found");
-			} else cb(err);
+				});
+			}
 		});
 		});
-	},
-
-	calculateSongForStation: function(station, cb) {
-		let _this = this;
-		let songList = [];
-		async.waterfall([
-
-			(next) => {
-				let genresDone = [];
-				station.genres.forEach((genre) => {
-					db.models.song.find({genres: genre}, (err, songs) => {
-						if (!err) {
-							songs.forEach((song) => {
-								if (songList.indexOf(song._id) === -1) {
-									let found = false;
-									song.genres.forEach((songGenre) => {
-										if (station.blacklistedGenres.indexOf(songGenre) !== -1) found = true;
-									});
-									if (!found) {
-										songList.push(song._id);
+
+		CacheModule.runJob("SUB", {
+			channel: "station.newOfficialPlaylist",
+			cb: async stationId => {
+				CacheModule.runJob("HGET", {
+					table: "officialPlaylists",
+					key: stationId
+				}).then(playlistObj => {
+					if (playlistObj) {
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: `station.${stationId}`,
+							args: ["event:newOfficialPlaylist", { data: { playlist: playlistObj.songs } }]
+						});
+					}
+				});
+			}
+		});
+
+		const stationModel = (this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }));
+		const stationSchema = (this.stationSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "station" }));
+
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						this.setStage(2);
+						CacheModule.runJob("HGETALL", { table: "stations" })
+							.then(stations => {
+								next(null, stations);
+							})
+							.catch(next);
+					},
+
+					(stations, next) => {
+						this.setStage(3);
+
+						if (!stations) return next();
+
+						const stationIds = Object.keys(stations);
+
+						return async.each(
+							stationIds,
+							(stationId, next) => {
+								stationModel.findOne({ _id: stationId }, (err, station) => {
+									if (err) next(err);
+									else if (!station) {
+										CacheModule.runJob("HDEL", {
+											table: "stations",
+											key: stationId
+										})
+											.then(() => {
+												next();
+											})
+											.catch(next);
+									} else next();
+								});
+							},
+							next
+						);
+					},
+
+					next => {
+						this.setStage(4);
+						stationModel.find({}, next);
+					},
+
+					(stations, next) => {
+						this.setStage(5);
+						async.each(
+							stations,
+							(station, next2) => {
+								async.waterfall(
+									[
+										next => {
+											CacheModule.runJob("HSET", {
+												table: "stations",
+												key: station._id,
+												value: stationSchema(station)
+											})
+												.then(station => next(null, station))
+												.catch(next);
+										},
+
+										(station, next) => {
+											StationsModule.runJob(
+												"INITIALIZE_STATION",
+												{
+													stationId: station._id
+												},
+												null,
+												-1
+											)
+												.then(() => {
+													next();
+												})
+												.catch(next);
+										}
+									],
+									err => {
+										next2(err);
 									}
 									}
-								}
+								);
+							},
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", {
+							error: err
+						});
+						reject(new Error(err));
+					} else {
+						resolve();
+					}
+				}
+			)
+		);
+	}
+
+	/**
+	 * Initialises a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - id of the station to initialise
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	INITIALIZE_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob(
+							"GET_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+					(station, next) => {
+						if (!station) return next("Station not found.");
+
+						return NotificationsModule.runJob(
+							"UNSCHEDULE",
+							{
+								name: `stations.nextSong?id=${station._id}`
+							},
+							this
+						)
+							.then()
+							.catch()
+							.finally(() => {
+								NotificationsModule.runJob("SUBSCRIBE", {
+									name: `stations.nextSong?id=${station._id}`,
+									cb: () =>
+										StationsModule.runJob("SKIP_STATION", {
+											stationId: station._id,
+											natural: true
+										}),
+									unique: true,
+									station
+								})
+									.then()
+									.catch();
+
+								if (station.paused) return next(true, station);
+
+								return next(null, station);
 							});
 							});
+					},
+					(station, next) => {
+						if (!station.currentSong) {
+							return StationsModule.runJob(
+								"SKIP_STATION",
+								{
+									stationId: station._id,
+									natural: false
+								},
+								this
+							)
+								.then(station => {
+									next(true, station);
+								})
+								.catch(next)
+								.finally(() => {});
 						}
 						}
-						genresDone.push(genre);
-						if (genresDone.length === station.genres.length) next();
-					});
-				});
-			},
 
 
-			(next) => {
-				let playlist = [];
-				songList.forEach(function(songId) {
-					if(station.playlist.indexOf(songId) === -1) playlist.push(songId);
-				});
-				station.playlist.filter((songId) => {
-					if (songList.indexOf(songId) !== -1) playlist.push(songId);
-				});
+						let timeLeft =
+							station.currentSong.duration * 1000 - (Date.now() - station.startedAt - station.timePaused);
 
 
-				playlist = utils.shuffle(playlist);
+						if (Number.isNaN(timeLeft)) timeLeft = -1;
 
 
-				_this.calculateOfficialPlaylistList(station._id, playlist, () => {
-					next(null, playlist);
-				});
-			},
+						if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
+							return StationsModule.runJob(
+								"SKIP_STATION",
+								{
+									stationId: station._id,
+									natural: false
+								},
+								this
+							)
+								.then(station => {
+									next(null, station);
+								})
+								.catch(next);
+						}
+						// name, time, cb, station
+						NotificationsModule.runJob("SCHEDULE", {
+							name: `stations.nextSong?id=${station._id}`,
+							time: timeLeft,
+							station
+						});
 
 
-			(playlist, next) => {
-				db.models.station.update({_id: station._id}, {$set: {playlist: playlist}}, (err) => {
-					_this.updateStation(station._id, () => {
-						next(err, playlist);
-					});
-				});
-			}
+						return next(null, station);
+					}
+				],
+				async (err, station) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: err
+							},
+							this
+						);
+						reject(new Error(err));
+					} else resolve(station);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - id of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob("HGET", { table: "stations", key: payload.stationId }, this)
+							.then(station => next(null, station))
+							.catch(next);
+					},
 
 
-		], (err, newPlaylist) => {
-			cb(err, newPlaylist);
+					(station, next) => {
+						if (station) return next(true, station);
+						return StationsModule.stationModel.findOne({ _id: payload.stationId }, next);
+					},
+
+					(station, next) => {
+						if (station) {
+							station = StationsModule.stationSchema(station);
+							CacheModule.runJob("HSET", {
+								table: "stations",
+								key: payload.stationId,
+								value: station
+							})
+								.then()
+								.catch();
+							next(true, station);
+						} else next("Station not found");
+					}
+				],
+				async (err, station) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: err
+							},
+							this
+						);
+						reject(new Error(err));
+					} else resolve(station);
+				}
+			);
 		});
 		});
-	},
-
-	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	getStation: function(stationId, cb) {
-		let _this = this;
-		async.waterfall([
-
-			(next) => {
-				cache.hget('stations', stationId, next);
-			},
-
-			(station, next) => {
-				if (station) return next(true, station);
-				db.models.station.findOne({ _id: stationId }, next);
-			},
-
-			(station, next) => {
-				if (station) {
-					if (station.type === 'official') {
-						_this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
+	}
+
+	/**
+	 * Attempts to get a station by name, firstly from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationName - the unique name of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async GET_STATION_BY_NAME(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.findOne({ name: payload.stationName }, next);
+					},
+
+					(station, next) => {
+						if (station) {
+							station = StationsModule.stationSchema(station);
+							CacheModule.runJob("HSET", {
+								table: "stations",
+								key: station._id,
+								value: station
+							});
+							next(true, station);
+						} else next("Station not found");
+					}
+				],
+				(err, station) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(station);
+				}
+			)
+		);
+	}
+
+	/**
+	 * Updates the station in cache from mongo or deletes station in cache if no longer in mongo.
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station to update
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	UPDATE_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.findOne({ _id: payload.stationId }, next);
+					},
+
+					(station, next) => {
+						if (!station) {
+							CacheModule.runJob("HDEL", {
+								table: "stations",
+								key: payload.stationId
+							})
+								.then()
+								.catch();
+							return next("Station not found");
+						}
+
+						return CacheModule.runJob(
+							"HSET",
+							{
+								table: "stations",
+								key: payload.stationId,
+								value: station
+							},
+							this
+						)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
 					}
 					}
-					station = cache.schemas.station(station);
-					cache.hset('stations', stationId, station);
-					next(true, station);
-				} else next('Station not found');
-			},
-
-		], (err, station) => {
-			if (err && err !== true) cb(err);
-			cb(null, station);
+				],
+				async (err, station) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: err
+							},
+							this
+						);
+						reject(new Error(err));
+					} else resolve(station);
+				}
+			);
 		});
 		});
-	},
+	}
 
 
-	updateStation: function(stationId, cb) {
-		let _this = this;
-		async.waterfall([
+	/**
+	 * Fills up the official station playlist queue using the songs from the official station playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station
+	 * @param {string} payload.ignoreExistingQueue - ignore the existing queue songs, replacing the old queue with a completely fresh one
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId, ignoreExistingQueue } = payload;
+			async.waterfall(
+				[
+					next => {
+						PlaylistsModule.runJob("GET_STATION_PLAYLIST", { stationId, includeSongs: true }, this)
+							.then(response => {
+								next(null, response.playlist);
+							})
+							.catch(next);
+					},
 
 
-			(next) => {
-				db.models.station.findOne({ _id: stationId }, next);
-			},
+					(playlist, next) => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								if (ignoreExistingQueue) station.queue = [];
+								next(null, playlist, station);
+							})
+							.catch(next);
+					},
 
 
-			(station, next) => {
-				if (!station) {
-					cache.hdel('stations', stationId);
-					return next('Station not found');
+					(playlist, station, next) => {
+						if (station.playMode === "random") {
+							UtilsModule.runJob("SHUFFLE", { array: playlist.songs }, this)
+								.then(response => {
+									next(null, response.array, station);
+								})
+								.catch(next);
+						} else next(null, playlist.songs, station);
+					},
+
+					(_playlistSongs, station, next) => {
+						let playlistSongs = JSON.parse(JSON.stringify(_playlistSongs));
+						if (station.playMode === "sequential") {
+							if (station.currentSongIndex <= playlistSongs.length) {
+								const songsToAddToEnd = playlistSongs.splice(0, station.currentSongIndex);
+								playlistSongs = [...playlistSongs, ...songsToAddToEnd];
+							}
+						}
+						const songsStillNeeded = 50 - station.queue.length;
+						const currentSongs = station.queue;
+						const currentYoutubeIds = station.queue.map(song => song.youtubeId);
+						const songsToAdd = [];
+						let lastSongAdded = null;
+
+						playlistSongs.every(song => {
+							if (
+								songsToAdd.length < songsStillNeeded &&
+								currentYoutubeIds.indexOf(song.youtubeId) === -1
+							) {
+								lastSongAdded = song;
+								songsToAdd.push(song);
+								return true;
+							}
+							if (songsToAdd.length >= songsStillNeeded) return false;
+							return true;
+						});
+
+						let { currentSongIndex } = station;
+
+						if (station.playMode === "sequential" && lastSongAdded) {
+							const indexOfLastSong = _playlistSongs
+								.map(song => song.youtubeId)
+								.indexOf(lastSongAdded.youtubeId);
+
+							if (indexOfLastSong !== -1) currentSongIndex = indexOfLastSong;
+						}
+
+						next(null, currentSongs, songsToAdd, currentSongIndex);
+					},
+
+					(currentSongs, songsToAdd, currentSongIndex, next) => {
+						SongsModule.runJob("GET_SONGS", {
+							songIds: songsToAdd.map(song => song._id),
+							properties: [
+								"youtubeId",
+								"title",
+								"duration",
+								"skipDuration",
+								"artists",
+								"thumbnail",
+								"status"
+							]
+						})
+							.then(response => {
+								const newSongsToAdd = songsToAdd.map(song =>
+									response.songs.find(newSong => newSong._id.toString() === song._id.toString())
+								);
+								next(null, currentSongs, newSongsToAdd, currentSongIndex);
+							})
+							.catch(err => next(err));
+					},
+
+					(currentSongs, songsToAdd, currentSongIndex, next) => {
+						const newPlaylist = [...currentSongs, ...songsToAdd].map(song => {
+							if (!song._id) song._id = null;
+							return song;
+						});
+						next(null, newPlaylist, currentSongIndex);
+					},
+
+					(newPlaylist, currentSongIndex, next) => {
+						StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $set: { queue: newPlaylist, currentSongIndex } },
+							{ runValidators: true },
+							err => {
+								if (err) next(err);
+								else
+									StationsModule.runJob(
+										"UPDATE_STATION",
+										{
+											stationId
+										},
+										this
+									)
+										.then(() => {
+											next(null);
+										})
+										.catch(next);
+							}
+						);
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
 				}
 				}
-				cache.hset('stations', stationId, station, next);
-			}
+			);
+		});
+	}
+
+	/**
+	 * Gets next station song
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_NEXT_STATION_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.queue.length === 0) next("No songs available.");
+						else {
+							next(null, station.queue[0]);
+						}
+					},
+
+					(queueSong, next) => {
+						if (!queueSong._id) next(null, queueSong);
+						else
+							SongsModule.runJob("GET_SONG", { songId: queueSong._id }, this)
+								.then(response => {
+									const { song } = response;
+
+									if (song) {
+										const newSong = {
+											_id: song._id,
+											youtubeId: song.youtubeId,
+											title: song.title,
+											artists: song.artists,
+											duration: song.duration,
+											skipDuration: song.skipDuration,
+											thumbnail: song.thumbnail,
+											requestedAt: queueSong.requestedAt,
+											requestedBy: queueSong.requestedBy,
+											likes: song.likes,
+											dislikes: song.dislikes,
+											status: song.status
+										};
 
 
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
+										return next(null, newSong);
+									}
+
+									return next(null, song);
+								})
+								.catch(err => {
+									next(err);
+								});
+					}
+				],
+				(err, song) => {
+					if (err) console.log(33333, err, payload);
+					if (err) reject(err);
+					else resolve({ song });
+				}
+			);
 		});
 		});
-	},
-
-	calculateOfficialPlaylistList: (stationId, songList, cb) => {
-		let lessInfoPlaylist = [];
-
-		function getSongInfo(index) {
-			if (songList.length > index) {
-				songs.getSong(songList[index], (err, song) => {
-					if (!err && song) {
-						let newSong = {
-							_id: song._id,
-							title: song.title,
-							artists: song.artists,
-							duration: song.duration
-						};
-						lessInfoPlaylist.push(newSong);
+	}
+
+	/**
+	 * Removes first station queue song
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_FIRST_QUEUE_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const { stationId } = payload;
+
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.updateOne(
+							{ _id: stationId },
+							{ $pop: { queue: -1 } },
+							{ runValidators: true },
+							err => {
+								if (err) next(err);
+								else
+									StationsModule.runJob(
+										"UPDATE_STATION",
+										{
+											stationId
+										},
+										this
+									)
+										.then(() => {
+											next(null);
+										})
+										.catch(next);
+							}
+						);
 					}
 					}
-					getSongInfo(index + 1);
-				})
-			} else {
-				cache.hset("officialPlaylists", stationId, cache.schemas.officialPlaylist(stationId, lessInfoPlaylist), () => {
-					cache.pub("station.newOfficialPlaylist", stationId);
-					cb();
-				});
-			}
-		}
-		getSongInfo(0);
-	},
-
-	skipStation: function(stationId) {
-		console.log("SKIP!", stationId);
-		let _this = this;
-		return (cb) => {
-			if (typeof cb !== 'function') cb = ()=>{};
-			_this.getStation(stationId, (err, station) => {
-				if (station) {
-					// notify all the sockets on this station to go to the next song
-					async.waterfall([
-
-						(next) => {
-							if (station.type === "official") {
-								if (station.playlist.length > 0) {
-									function setCurrentSong() {
-										if (station.currentSongIndex < station.playlist.length - 1) {
-											songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
-												if (!err) {
-													let $set = {};
-
-													$set.currentSong = {
-														_id: song._id,
-														title: song.title,
-														artists: song.artists,
-														duration: song.duration,
-														likes: song.likes,
-														dislikes: song.dislikes,
-														skipDuration: song.skipDuration,
-														thumbnail: song.thumbnail
-													};
-													$set.startedAt = Date.now();
-													$set.timePaused = 0;
-													$set.currentSongIndex = station.currentSongIndex + 1;
-													next(null, $set);
-												} else {
-													db.models.station.update({ _id: station._id }, { $inc: { currentSongIndex: 1 } }, (err) => {
-														_this.updateStation(station._id, () => {
-															setCurrentSong();
-														});
-													});
-												}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Skips a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the id of the station to skip
+	 * @param {string} payload.natural - whether to skip naturally or forcefully
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	SKIP_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			StationsModule.log("INFO", `Skipping station ${payload.stationId}.`);
+
+			StationsModule.log("STATION_ISSUE", `SKIP_STATION_CB - Station ID: ${payload.stationId}.`);
+
+			async.waterfall(
+				[
+					// Clears up any existing timers that would skip the station if the song ends
+					next => {
+						NotificationsModule.runJob("UNSCHEDULE", {
+							name: `stations.nextSong?id=${payload.stationId}`
+						})
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					},
+
+					// Gets the station object
+					next => {
+						StationsModule.runJob(
+							"GET_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(station => next(null, station))
+							.catch(next);
+					},
+
+					// eslint-disable-next-line consistent-return
+					(station, next) => {
+						if (!station) return next("Station not found.");
+
+						if (station.type === "community" && station.partyMode && station.queue.length === 0)
+							return next(null, null, station); // Community station with party mode enabled and no songs in the queue
+
+						if (station.type === "community" && station.partyMode && station.queue.length > 0) {
+							// Community station with party mode enabled and songs in the queue
+							if (station.paused) return next(null, null, station);
+
+							StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
+								.then(response => {
+									StationsModule.runJob(
+										"REMOVE_FIRST_QUEUE_SONG",
+										{ stationId: station._id },
+										this
+									).then(() => {
+										next(null, response.song, station);
+									});
+								})
+								.catch(err => {
+									if (err === "No songs available.") next(null, null, station);
+									else next(err);
+								});
+						}
+
+						if (station.type === "community" && !station.partyMode) {
+							StationsModule.runJob(
+								"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
+								{ stationId: station._id },
+								this
+							)
+								.then(() => {
+									StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
+										.then(response => {
+											StationsModule.runJob(
+												"REMOVE_FIRST_QUEUE_SONG",
+												{ stationId: station._id },
+												this
+											).then(() => {
+												next(null, response.song, station);
 											});
 											});
-										} else {
-											db.models.station.update({_id: station._id}, {$set: {currentSongIndex: 0}}, (err) => {
-												_this.updateStation(station._id, (err, station) => {
-													_this.calculateSongForStation(station, (err, newPlaylist) => {
-														if (!err) {
-															songs.getSong(newPlaylist[0], (err, song) => {
-																let $set = {};
-																if (song) {
-																	$set.currentSong = {
-																		_id: song._id,
-																		title: song.title,
-																		artists: song.artists,
-																		duration: song.duration,
-																		likes: song.likes,
-																		dislikes: song.dislikes,
-																		skipDuration: song.skipDuration,
-																		thumbnail: song.thumbnail
-																	};
-																	station.playlist = newPlaylist;
-																} else $set.currentSong = _this.defaultSong;
-																$set.startedAt = Date.now();
-																$set.timePaused = 0;
-																next(null, $set);
+										})
+										.catch(err => {
+											if (err === "No songs available.") next(null, null, station);
+											else next(err);
+										});
+								})
+								.catch(next);
+						}
+
+						if (station.type === "official") {
+							StationsModule.runJob(
+								"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
+								{ stationId: station._id },
+								this
+							)
+								.then(() => {
+									StationsModule.runJob("GET_NEXT_STATION_SONG", { stationId: station._id }, this)
+										.then(response => {
+											StationsModule.runJob(
+												"REMOVE_FIRST_QUEUE_SONG",
+												{ stationId: station._id },
+												this
+											)
+												.then(() => {
+													next(null, response.song, station);
+												})
+												.catch(next);
+										})
+										.catch(err => {
+											if (err === "No songs available.") next(null, null, station);
+											else next(err);
+										});
+								})
+								.catch(next);
+						}
+					},
+					(song, station, next) => {
+						const $set = {};
+
+						if (song === null) $set.currentSong = null;
+						else {
+							$set.currentSong = {
+								_id: song._id,
+								youtubeId: song.youtubeId,
+								title: song.title,
+								artists: song.artists,
+								duration: song.duration,
+								skipDuration: song.skipDuration,
+								thumbnail: song.thumbnail,
+								requestedAt: song.requestedAt,
+								requestedBy: song.requestedBy,
+								status: song.status
+							};
+						}
+
+						$set.startedAt = Date.now();
+						$set.timePaused = 0;
+						if (station.paused) $set.pausedAt = Date.now();
+						next(null, $set, song, station);
+					},
+
+					($set, song, station, next) => {
+						StationsModule.stationModel.updateOne({ _id: station._id }, { $set }, err => {
+							if (err) return next(err);
+
+							return StationsModule.runJob("UPDATE_STATION", { stationId: station._id }, this)
+								.then(station => {
+									CacheModule.runJob("PUB", {
+										channel: "station.queueUpdate",
+										value: payload.stationId
+									})
+										.then()
+										.catch();
+									next(null, station, song);
+								})
+								.catch(next);
+						});
+					},
+
+					(station, song, next) => {
+						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
+							station.currentSong.likes = song.likes;
+							station.currentSong.dislikes = song.dislikes;
+							station.currentSong.skipVotes = 0;
+						}
+						next(null, station);
+					}
+				],
+				async (err, station) => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						StationsModule.log("ERROR", `Skipping station "${payload.stationId}" failed. "${err}"`);
+						return reject(new Error(err));
+					}
+
+					// TODO Pub/Sub this
+
+					const { currentSong } = station;
+
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: `station.${station._id}`,
+						args: [
+							"event:station.nextSong",
+							{
+								data: {
+									currentSong,
+									startedAt: station.startedAt,
+									paused: station.paused,
+									timePaused: 0,
+									natural: payload.natural
+								}
+							}
+						]
+					});
+
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: `manage-station.${station._id}`,
+						args: ["event:station.nextSong", { data: { stationId: station._id, currentSong } }]
+					});
+
+					if (station.privacy === "public")
+						WSModule.runJob("EMIT_TO_ROOM", {
+							room: "home",
+							args: ["event:station.nextSong", { data: { stationId: station._id, currentSong } }]
+						});
+					else {
+						const sockets = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: "home" }, this);
+
+						sockets.forEach(async socketId => {
+							const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId });
+							if (!socket) return;
+							const { session } = socket;
+
+							if (session.sessionId) {
+								CacheModule.runJob("HGET", { table: "sessions", key: session.sessionId }).then(
+									session => {
+										if (session) {
+											DBModule.runJob("GET_MODEL", { modelName: "user" }).then(userModel => {
+												userModel.findOne({ _id: session.userId }, (err, user) => {
+													if (!err && user) {
+														if (user.role === "admin")
+															socket.dispatch("event:station.nextSong", {
+																data: {
+																	stationId: station._id,
+																	currentSong
+																}
 															});
 															});
-														} else {
-															let $set = {};
-															$set.currentSong = _this.defaultSong;
-															$set.startedAt = Date.now();
-															$set.timePaused = 0;
-															next(null, $set);
-														}
-													})
+														else if (
+															station.type === "community" &&
+															station.owner === session.userId
+														)
+															socket.dispatch("event:station.nextSong", {
+																data: {
+																	stationId: station._id,
+																	currentSong
+																}
+															});
+													}
 												});
 												});
 											});
 											});
 										}
 										}
 									}
 									}
+								);
+							}
+						});
+					}
 
 
-									setCurrentSong();
-								} else {
-									_this.calculateSongForStation(station, (err, playlist) => {
-										if (!err && playlist.length === 0) {
-											let $set = {};
-											$set.currentSongIndex = 0;
-											$set.currentSong = _this.defaultSong;
-											$set.startedAt = Date.now();
-											$set.timePaused = 0;
-											next(null, $set);
-										} else {
-											songs.getSong(playlist[0], (err, song) => {
-												let $set = {};
-												if (!err) {
-													$set.currentSong = {
-														_id: song._id,
-														title: song.title,
-														artists: song.artists,
-														duration: song.duration,
-														likes: song.likes,
-														dislikes: song.dislikes,
-														skipDuration: song.skipDuration,
-														thumbnail: song.thumbnail
-													};
-												} else {
-													$set.currentSong = _this.defaultSong;
-												}
-												$set.currentSongIndex = 0;
-												$set.startedAt = Date.now();
-												$set.timePaused = 0;
-												next(null, $set);
-											});
-										}
-									});
-								}
-							} else {
-								if (station.partyMode === true) if (station.queue.length > 0) {
-										db.models.station.update({ _id: stationId }, { $pull: { queue: { _id: station.queue[0]._id } } }, (err) => {
-											if (err) return next(err);
-											let $set = {};
-											$set.currentSong = station.queue[0];
-											$set.startedAt = Date.now();
-											$set.timePaused = 0;
-											if (station.paused) $set.pausedAt = Date.now();
-											next(null, $set);
-										});
-									} else next(null, {currentSong: null});
-								else {
-									db.models.playlist.findOne({_id: station.privatePlaylist}, (err, playlist) => {
-										if (err || !playlist) return next(null, {currentSong: null});
-										playlist = playlist.songs;
-										if (playlist.length > 0) {
-											let $set = {};
-											if (station.currentSongIndex < playlist.length - 1) $set.currentSongIndex = station.currentSongIndex + 1;
-											else $set.currentSongIndex = 0;
-											songs.getSong(playlist[$set.currentSongIndex]._id, (err, song) => {
-												if (!err && song) {
-													$set.currentSong = {
-														_id: song._id,
-														title: song.title,
-														artists: song.artists,
-														duration: song.duration,
-														likes: song.likes,
-														dislikes: song.dislikes,
-														skipDuration: song.skipDuration,
-														thumbnail: song.thumbnail
-													};
-												} else {
-													let song = playlist[$set.currentSongIndex];
-													$set.currentSong = {
-														_id: song._id,
-														title: song.title,
-														duration: song.duration,
-														likes: -1,
-														dislikes: -1
-													};
-												}
-												$set.startedAt = Date.now();
-												$set.timePaused = 0;
-												next(null, $set);
-											});
-										} else next(null, {currentSong: null});
-									});
-								}
+					WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: `station.${station._id}` }).then(sockets => {
+						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
+							WSModule.runJob("SOCKETS_JOIN_SONG_ROOM", {
+								sockets,
+								room: `song.${station.currentSong.youtubeId}`
+							});
+							if (!station.paused) {
+								NotificationsModule.runJob("SCHEDULE", {
+									name: `stations.nextSong?id=${station._id}`,
+									time: station.currentSong.duration * 1000,
+									station
+								});
 							}
 							}
-						},
+						} else WSModule.runJob("SOCKETS_LEAVE_SONG_ROOMS", { sockets });
+					});
 
 
-						($set, next) => {
-							db.models.station.update({_id: station._id}, {$set}, (err) => {
-								_this.updateStation(station._id, (err, station) => {
-									if (station.type === 'community' && station.partyMode === true)
-										cache.pub('station.queueUpdate', stationId);
-									next(null, station);
+					return resolve({ station });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Checks if a user can view/access a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.station - the station object of the station in question
+	 * @param {string} payload.userId - the id of the user in question
+	 * @param {boolean} payload.hideUnlisted - whether the user is allowed to see unlisted stations or not
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CAN_USER_VIEW_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (payload.station.privacy === "public") return next(true);
+						if (payload.station.privacy === "unlisted")
+							if (payload.hideUnlisted === true) return next();
+							else return next(true);
+						if (!payload.userId) return next("Not allowed");
+
+						return next();
+					},
+
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+							userModel.findOne({ _id: payload.userId }, next);
+						});
+					},
+
+					(user, next) => {
+						if (!user) return next("Not allowed");
+						if (user.role === "admin") return next(true);
+						if (payload.station.type === "official") return next("Not allowed");
+						if (payload.station.owner === payload.userId) return next(true);
+
+						return next("Not allowed");
+					}
+				],
+				async errOrResult => {
+					if (errOrResult !== true && errOrResult !== "Not allowed") {
+						errOrResult = await UtilsModule.runJob(
+							"GET_ERROR",
+							{
+								error: errOrResult
+							},
+							this
+						);
+						reject(new Error(errOrResult));
+					} else {
+						resolve(errOrResult === true);
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Checks if a user has favorited a station or not
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station in question
+	 * @param {string} payload.userId - the id of the user in question
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	HAS_USER_FAVORITED_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+							userModel.findOne({ _id: payload.userId }, next);
+						});
+					},
+
+					(user, next) => {
+						if (!user) return next("User not found.");
+						if (user.favoriteStations.indexOf(payload.stationId) !== -1) return next(null, true);
+						return next(null, false);
+					}
+				],
+				async (err, isStationFavorited) => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve(isStationFavorited);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns a list of sockets in a room that can and can't know about a station
+	 *
+	 * @param {object} payload - the payload object
+	 * @param {object} payload.station - the station object
+	 * @param {string} payload.room - the websockets room to get the sockets from
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION(payload) {
+		return new Promise((resolve, reject) => {
+			WSModule.runJob("GET_SOCKETS_FOR_ROOM", { room: payload.room }, this)
+				.then(socketIds => {
+					const sockets = [];
+					async.eachLimit(
+						socketIds,
+						1,
+						(socketId, next) => {
+							WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
+								.then(socket => {
+									if (socket) sockets.push(socket);
+									next();
+								})
+								.catch(err => {
+									reject(err);
 								});
 								});
-							});
 						},
 						},
+						err => {
+							if (err) reject(err);
+							else {
+								let socketsThatCan = [];
+								const socketsThatCannot = [];
 
 
+								if (payload.station.privacy === "public") {
+									socketsThatCan = sockets;
+									resolve({ socketsThatCan, socketsThatCannot });
+								} else {
+									async.eachLimit(
+										sockets,
+										1,
+										(socket, next) => {
+											const { session } = socket;
 
 
-					], (err, station) => {
-						if (!err) {
-							if (station.currentSong !== null && station.currentSong._id !== undefined) {
-								station.currentSong.skipVotes = 0;
-							}
-							//TODO Pub/Sub this
-							utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
-								currentSong: station.currentSong,
-								startedAt: station.startedAt,
-								paused: station.paused,
-								timePaused: 0
-							});
+											async.waterfall(
+												[
+													next => {
+														if (!session.sessionId) next("No session id");
+														else next();
+													},
 
 
-							if (station.privacy === 'public') utils.emitToRoom('home', "event:station.nextSong", station._id, station.currentSong);
-							else {
-								let sockets = utils.getRoomSockets('home');
-								for (let socketId in sockets) {
-									let socket = sockets[socketId];
-									let session = sockets[socketId].session;
-									if (session.sessionId) {
-										cache.hget('sessions', session.sessionId, (err, session) => {
-											if (!err && session) {
-												db.models.user.findOne({_id: session.userId}, (err, user) => {
-													if (!err && user) {
-														if (user.role === 'admin') socket.emit("event:station.nextSong", station._id, station.currentSong);
-														else if (station.type === "community" && station.owner === session.userId) socket.emit("event:station.nextSong", station._id, station.currentSong);
+													next => {
+														CacheModule.runJob(
+															"HGET",
+															{
+																table: "sessions",
+																key: session.sessionId
+															},
+															this
+														)
+															.then(response => {
+																next(null, response);
+															})
+															.catch(next);
+													},
+
+													(session, next) => {
+														if (!session) next("No session");
+														else {
+															DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
+																.then(userModel => {
+																	next(null, userModel);
+																})
+																.catch(next);
+														}
+													},
+
+													(userModel, next) => {
+														if (!userModel) next("No user model");
+														else
+															userModel.findOne(
+																{
+																	_id: session.userId
+																},
+																next
+															);
+													},
+
+													(user, next) => {
+														if (!user) next("No user found");
+														else if (user.role === "admin") {
+															socketsThatCan.push(socket);
+															next();
+														} else if (
+															payload.station.type === "community" &&
+															payload.station.owner === session.userId
+														) {
+															socketsThatCan.push(socket);
+															next();
+														}
 													}
 													}
-												});
-											}
-										});
-									}
+												],
+												err => {
+													if (err) socketsThatCannot.push(socket);
+													next();
+												}
+											);
+										},
+										err => {
+											if (err) reject(err);
+											else resolve({ socketsThatCan, socketsThatCannot });
+										}
+									);
 								}
 								}
 							}
 							}
-							console.log(
-								Date.now(),
-								(station) ? station._id : "STATION_NULL",
-								station.currentSong !== null && station.currentSong._id !== undefined,
-								station.currentSong !== null,
-								(station.currentSong) ? station.currentSong._id !== undefined : "CURRENTSONG_NULL"
+						}
+					);
+				})
+				.catch(reject);
+		});
+	}
+
+	/**
+	 * Adds a playlist to be included in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station to include the playlist in
+	 * @param {object} payload.playlistId - the id of the playlist to be included
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	INCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.playlist === payload.playlistId) next("You cannot include the station playlist");
+						else if (station.includedPlaylists.indexOf(payload.playlistId) !== -1)
+							next("This playlist is already included");
+						else if (station.excludedPlaylists.indexOf(payload.playlistId) !== -1)
+							next(
+								"This playlist is currently excluded, please remove it from there before including it"
+							);
+						else
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: payload.playlistId }, this)
+								.then(() => {
+									next(null);
+								})
+								.catch(next);
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $push: { includedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes a playlist that is included in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_INCLUDED_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.includedPlaylists.indexOf(payload.playlistId) === -1)
+							next("This playlist isn't included");
+						else next();
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $pull: { includedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Adds a playlist to be excluded in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	EXCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.playlist === payload.playlistId) next("You cannot exclude the station playlist");
+						else if (station.excludedPlaylists.indexOf(payload.playlistId) !== -1)
+							next("This playlist is already excluded");
+						else if (station.includedPlaylists.indexOf(payload.playlistId) !== -1)
+							next(
+								"This playlist is currently included, please remove it from there before excluding it"
+							);
+						else
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId: payload.playlistId }, this)
+								.then(() => {
+									next(null);
+								})
+								.catch(next);
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $push: { excludedPlaylists: payload.playlistId } },
+								next
+							);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes a playlist that is excluded in a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.stationId - the id of the station
+	 * @param {object} payload.playlistId - the id of the playlist
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	REMOVE_EXCLUDED_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.stationId) next("Please specify a station id");
+						else if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.runJob("GET_STATION", { stationId: payload.stationId }, this)
+							.then(station => {
+								next(null, station);
+							})
+							.catch(next);
+					},
+
+					(station, next) => {
+						if (station.excludedPlaylists.indexOf(payload.playlistId) === -1)
+							next("This playlist isn't excluded");
+						else next();
+					},
+
+					next => {
+						DBModule.runJob(
+							"GET_MODEL",
+							{
+								modelName: "station"
+							},
+							this
+						).then(stationModel => {
+							stationModel.updateOne(
+								{ _id: payload.stationId },
+								{ $pull: { excludedPlaylists: payload.playlistId } },
+								next
 							);
 							);
-							if (station.currentSong !== null && station.currentSong._id !== undefined) {
-								utils.socketsJoinSongRoom(utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong._id}`);
-								if (!station.paused) {
-									notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000);
+						});
+					},
+
+					(res, next) => {
+						StationsModule.runJob(
+							"UPDATE_STATION",
+							{
+								stationId: payload.stationId
+							},
+							this
+						)
+							.then(() => {
+								next();
+							})
+							.catch(next);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Removes included or excluded playlist from a station
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REMOVE_INCLUDED_OR_EXCLUDED_PLAYLIST_FROM_STATIONS(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						if (!payload.playlistId) next("Please specify a playlist id");
+						else next();
+					},
+
+					next => {
+						StationsModule.stationModel.updateMany(
+							{
+								$or: [
+									{ includedPlaylists: payload.playlistId },
+									{ excludedPlaylists: payload.playlistId }
+								]
+							},
+							{
+								$pull: {
+									includedPlaylists: payload.playlistId,
+									excludedPlaylists: payload.playlistId
 								}
 								}
-							} else {
-								utils.socketsLeaveSongRooms(utils.getRoomSockets(`station.${station._id}`));
+							},
+							err => {
+								if (err) next(err);
+								else next();
 							}
 							}
-							cb(null, station);
-						} else cb(err);
-					});
-				}
-				// the station doesn't exist anymore, unsubscribe from it
-				else {
-					cb("Station not found.");
+						);
+					}
+				],
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+						return reject(new Error(err));
+					}
+
+					return resolve();
 				}
 				}
+			);
+		});
+	}
+
+	/**
+	 * Gets stations that include or exclude a specific playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			DBModule.runJob(
+				"GET_MODEL",
+				{
+					modelName: "station"
+				},
+				this
+			).then(stationModel => {
+				stationModel.find(
+					{
+						$or: [{ includedPlaylists: payload.playlistId }, { excludedPlaylists: payload.playlistId }]
+					},
+					(err, stations) => {
+						if (err) reject(err);
+						else resolve({ stationIds: stations.map(station => station._id) });
+					}
+				);
 			});
 			});
-		}
-	},
-
-	defaultSong: {
-		_id: '60ItHLz5WEA',
-		title: 'Faded - Alan Walker',
-		duration: 212,
-		likes: -1,
-		dislikes: -1
+		});
+	}
+
+	/**
+	 * Clears every queue
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CLEAR_EVERY_STATION_QUEUE() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.stationModel.updateMany({}, { $set: { queue: [] } }, err => {
+							if (err) next(err);
+							else {
+								StationsModule.stationModel.find({}, (err, stations) => {
+									if (err) next(err);
+									else {
+										async.eachLimit(
+											stations,
+											1,
+											(station, next) => {
+												StationsModule.runJob("UPDATE_STATION", {
+													stationId: station._id
+												})
+													.then(() => next())
+													.catch(next);
+												CacheModule.runJob("PUB", {
+													channel: "station.queueUpdate",
+													value: station._id
+												})
+													.then()
+													.catch();
+											},
+											next
+										);
+									}
+								});
+							}
+						});
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Clears and refills a station queue
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.stationId - the station id
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CLEAR_AND_REFILL_STATION_QUEUE(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						StationsModule.runJob(
+							"FILL_UP_STATION_QUEUE_FROM_STATION_PLAYLIST",
+							{ stationId: payload.stationId, ignoreExistingQueue: true },
+							this
+						)
+							.then(() => {
+								CacheModule.runJob("PUB", {
+									channel: "station.queueUpdate",
+									value: payload.stationId
+								})
+									.then()
+									.catch();
+								next();
+							})
+							.catch(err => {
+								next(err);
+							});
+					}
+				],
+				err => {
+					if (err) reject(err);
+					else resolve();
+				}
+			);
+		});
 	}
 	}
+}
 
 
-};
+export default new _StationsModule();

+ 470 - 0
backend/logic/tasks.js

@@ -0,0 +1,470 @@
+import async from "async";
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+
+import CoreClass from "../core";
+import Timer from "../classes/Timer.class";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+let TasksModule;
+let CacheModule;
+let StationsModule;
+let UtilsModule;
+let WSModule;
+let DBModule;
+
+class _TasksModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("tasks");
+
+		this.tasks = {};
+
+		TasksModule = this;
+	}
+
+	/**
+	 * Initialises the tasks module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => {
+			CacheModule = this.moduleManager.modules.cache;
+			StationsModule = this.moduleManager.modules.stations;
+			UtilsModule = this.moduleManager.modules.utils;
+			WSModule = this.moduleManager.modules.ws;
+			DBModule = this.moduleManager.modules.db;
+
+			TasksModule.runJob("CREATE_TASK", {
+				name: "stationSkipTask",
+				fn: TasksModule.checkStationSkipTask,
+				timeout: 1000 * 60 * 30
+			});
+
+			TasksModule.runJob("CREATE_TASK", {
+				name: "sessionClearTask",
+				fn: TasksModule.sessionClearingTask,
+				timeout: 1000 * 60 * 60 * 6
+			});
+
+			// TasksModule.runJob("CREATE_TASK", {
+			// 	name: "logFileSizeCheckTask",
+			// 	fn: TasksModule.logFileSizeCheckTask,
+			// 	timeout: 1000 * 60 * 60
+			// });
+
+			TasksModule.runJob("CREATE_TASK", {
+				name: "collectStationUsersTask",
+				fn: TasksModule.collectStationUsersTask,
+				timeout: 1000 * 3
+			});
+
+			resolve();
+		});
+	}
+
+	/**
+	 * Creates a new task
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.name - the name of the task
+	 * @param {string} payload.fn - the function the task will run
+	 * @param {string} payload.paused - if the task is currently paused
+	 * @param {boolean} payload.timeout - how often to run the task
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_TASK(payload) {
+		return new Promise((resolve, reject) => {
+			TasksModule.tasks[payload.name] = {
+				name: payload.name,
+				fn: payload.fn,
+				timeout: payload.timeout,
+				lastRan: 0,
+				timer: null
+			};
+
+			if (!payload.paused) {
+				TasksModule.runJob("RUN_TASK", { name: payload.name }, this)
+					.then(() => resolve())
+					.catch(err => reject(err));
+			} else resolve();
+		});
+	}
+
+	/**
+	 * Pauses a task
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.taskName - the name of the task to pause
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	PAUSE_TASK(payload) {
+		const taskName = { payload };
+
+		return new Promise(resolve => {
+			if (TasksModule.tasks[taskName].timer) TasksModule.tasks[taskName].timer.pause();
+			resolve();
+		});
+	}
+
+	/**
+	 * Resumes a task
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.name - the name of the task to resume
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	RESUME_TASK(payload) {
+		return new Promise(resolve => {
+			TasksModule.tasks[payload.name].timer.resume();
+			resolve();
+		});
+	}
+
+	/**
+	 * Runs a task's function and restarts the timer
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.name - the name of the task to run
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	RUN_TASK(payload) {
+		return new Promise(resolve => {
+			const task = TasksModule.tasks[payload.name];
+			if (task.timer) task.timer.pause();
+
+			task.fn.apply(this).then(() => {
+				task.lastRan = Date.now();
+				task.timer = new Timer(
+					() => TasksModule.runJob("RUN_TASK", { name: payload.name }),
+					task.timeout,
+					false
+				);
+				resolve();
+			});
+		});
+	}
+
+	/**
+	 * Periodically checks if any stations need to be skipped
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	checkStationSkipTask() {
+		return new Promise(resolve => {
+			TasksModule.log("INFO", "TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob("HGETALL", { table: "stations" })
+							.then(response => next(null, response))
+							.catch(next);
+					},
+					(stations, next) => {
+						async.each(
+							stations,
+							(station, next2) => {
+								if (station.paused || !station.currentSong || !station.currentSong.title)
+									return next2();
+								const timeElapsed = Date.now() - station.startedAt - station.timePaused;
+								if (timeElapsed <= station.currentSong.duration) return next2();
+
+								TasksModule.log(
+									"ERROR",
+									"TASK_STATIONS_SKIP_CHECK",
+									`Skipping ${station._id} as it should have skipped already.`
+								);
+								return StationsModule.runJob("INITIALIZE_STATION", {
+									stationId: station._id
+								}).then(() => next2());
+							},
+							() => next()
+						);
+					}
+				],
+				() => resolve()
+			);
+		});
+	}
+
+	/**
+	 * Periodically checks if any sessions are out of date and need to be cleared
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	sessionClearingTask() {
+		return new Promise(resolve => {
+			TasksModule.log("INFO", "TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`);
+
+			async.waterfall(
+				[
+					next => {
+						CacheModule.runJob("HGETALL", { table: "sessions" })
+							.then(sessions => next(null, sessions))
+							.catch(next);
+					},
+					(sessions, next) => {
+						if (!sessions) return next();
+
+						const keys = Object.keys(sessions);
+
+						return async.each(
+							keys,
+							(sessionId, next2) => {
+								const session = sessions[sessionId];
+
+								if (
+									session &&
+									session.refreshDate &&
+									Date.now() - session.refreshDate < 60 * 60 * 24 * 30 * 1000
+								)
+									return next2();
+
+								if (!session) {
+									TasksModule.log("INFO", "TASK_SESSION_CLEAR", "Removing an empty session.");
+									return CacheModule.runJob("HDEL", {
+										table: "sessions",
+										key: sessionId
+									}).finally(() => {
+										next2();
+									});
+								}
+								if (!session.refreshDate) {
+									session.refreshDate = Date.now();
+									return CacheModule.runJob("HSET", {
+										table: "sessions",
+										key: sessionId,
+										value: session
+									}).finally(() => next2());
+								}
+								if (Date.now() - session.refreshDate > 60 * 60 * 24 * 30 * 1000) {
+									return WSModule.runJob("SOCKETS_FROM_SESSION_ID", {
+										sessionId: session.sessionId
+									}).then(response => {
+										if (response.sockets.length > 0) {
+											session.refreshDate = Date.now();
+											CacheModule.runJob("HSET", {
+												table: "sessions",
+												key: sessionId,
+												value: session
+											}).finally(() => {
+												next2();
+											});
+										} else {
+											TasksModule.log(
+												"INFO",
+												"TASK_SESSION_CLEAR",
+												`Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`
+											);
+											CacheModule.runJob("HDEL", {
+												table: "sessions",
+												key: session.sessionId
+											}).finally(() => next2());
+										}
+									});
+								}
+								TasksModule.log("ERROR", "TASK_SESSION_CLEAR", "This should never log.");
+								return next2();
+							},
+							() => next()
+						);
+					}
+				],
+				() => resolve()
+			);
+		});
+	}
+
+	/**
+	 * Periodically warns about the size of any log files
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	logFileSizeCheckTask() {
+		return new Promise((resolve, reject) => {
+			TasksModule.log("INFO", "TASK_LOG_FILE_SIZE_CHECK", `Checking the size for the log files.`);
+			async.each(
+				["all.log", "debugStation.log", "error.log", "info.log", "success.log"],
+				(fileName, next) => {
+					try {
+						const stats = fs.statSync(path.resolve(__dirname, "../../log/", fileName));
+						const mb = stats.size / 1000000;
+						if (mb > 25) return next(true);
+
+						return next();
+					} catch (err) {
+						return next(err);
+					}
+				},
+				async err => {
+					if (err && err !== true) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err });
+						return reject(new Error(err));
+					}
+					if (err === true) {
+						TasksModule.log(
+							"ERROR",
+							"LOGGER_FILE_SIZE_WARNING",
+							"************************************WARNING*************************************"
+						);
+						TasksModule.log(
+							"ERROR",
+							"LOGGER_FILE_SIZE_WARNING",
+							"***************ONE OR MORE LOG FILES APPEAR TO BE MORE THAN 25MB****************"
+						);
+						TasksModule.log(
+							"ERROR",
+							"LOGGER_FILE_SIZE_WARNING",
+							"****MAKE SURE TO REGULARLY CLEAR UP THE LOG FILES, MANUALLY OR AUTOMATICALLY****"
+						);
+						TasksModule.log(
+							"ERROR",
+							"LOGGER_FILE_SIZE_WARNING",
+							"********************************************************************************"
+						);
+					}
+
+					return resolve();
+				}
+			);
+		});
+	}
+
+	/**
+	 * Periodically collect users in stations
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async collectStationUsersTask() {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+		return new Promise(resolve => {
+			TasksModule.log("INFO", "TASK_COLLECT_STATION_USERS_TASK", `Checking for users in stations.`, false);
+
+			const stationsCountUpdated = [];
+			const stationsUpdated = [];
+
+			const oldUsersPerStation = StationsModule.usersPerStation;
+			const usersPerStation = { loggedIn: [], loggedOut: [] };
+
+			const oldUsersPerStationCount = JSON.parse(JSON.stringify(StationsModule.usersPerStationCount));
+			const usersPerStationCount = {};
+
+			async.each(
+				Object.keys(StationsModule.userList),
+				(socketId, next) => {
+					WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }).then(async socket => {
+						const stationId = StationsModule.userList[socketId];
+						const room = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", {
+							room: `station.${stationId}`
+						});
+
+						if (!socket || !room.includes(socketId)) {
+							if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
+							if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(String(stationId));
+
+							delete StationsModule.userList[socketId];
+
+							return next();
+						}
+
+						if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0; // start count for station
+						if (!usersPerStation[stationId]) usersPerStation[stationId] = { loggedIn: [], loggedOut: [] };
+
+						return async.waterfall(
+							[
+								next => {
+									if (!socket.session || !socket.session.sessionId) {
+										return next("No session found.", { ip: socket.ip });
+									}
+
+									return CacheModule.runJob("HGET", {
+										table: "sessions",
+										key: socket.session.sessionId
+									})
+										.then(session => next(null, session))
+										.catch(next);
+								},
+
+								(session, next) => {
+									if (!session) return next("Session not found.");
+									return userModel.findOne({ _id: session.userId }, next);
+								},
+
+								(user, next) => {
+									if (!user) return next("User not found.");
+
+									if (usersPerStation[stationId].loggedIn.some(u => user.username === u.username))
+										return next("User already in the list.");
+
+									usersPerStationCount[stationId] += 1; // increment user count for station
+
+									return next(null, {
+										username: user.username,
+										name: user.name,
+										avatar: user.avatar
+									});
+								}
+							],
+							(err, user) => {
+								if (!err) usersPerStation[stationId].loggedIn.push(user);
+
+								// if user is logged out (an ip can only be counted once)
+								if (
+									err === "No session found." &&
+									!usersPerStation[stationId].loggedOut.some(u => user.ip === u.ip)
+								) {
+									usersPerStationCount[stationId] += 1; // increment user count for station
+									usersPerStation[stationId].loggedOut.push(user);
+								}
+
+								next();
+							}
+						);
+					});
+				},
+				() => {
+					Object.keys(usersPerStationCount).forEach(stationId => {
+						if (
+							oldUsersPerStationCount[stationId] !== usersPerStationCount[stationId] &&
+							stationsCountUpdated.indexOf(stationId) === -1
+						) {
+							this.log("INFO", "UPDATE_STATION_USER_COUNT", `Updating user count of ${stationId}.`);
+
+							CacheModule.runJob("PUB", {
+								channel: "station.updateUserCount",
+								value: { stationId, usersPerStationCount: usersPerStationCount[stationId] }
+							});
+						}
+					});
+
+					Object.keys(usersPerStation).forEach(stationId => {
+						if (
+							!oldUsersPerStation[stationId] ||
+							JSON.stringify(oldUsersPerStation[stationId]) !==
+								JSON.stringify(usersPerStation[stationId]) ||
+							oldUsersPerStationCount[stationId] !== usersPerStationCount[stationId]
+						) {
+							this.log("INFO", "UPDATE_STATION_USER_LIST", `Updating user list of ${stationId}.`);
+
+							CacheModule.runJob("PUB", {
+								channel: "station.updateUsers",
+								value: { stationId, usersPerStation: usersPerStation[stationId] }
+							});
+						}
+					});
+
+					StationsModule.usersPerStationCount = usersPerStationCount;
+					StationsModule.usersPerStation = usersPerStation;
+				}
+			);
+
+			resolve();
+		});
+	}
+}
+
+export default new _TasksModule();

+ 314 - 342
backend/logic/utils.js

@@ -1,377 +1,349 @@
-'use strict';
-
-const moment  = require('moment'),
-	  io      = require('./io'),
-	  config  = require('config'),
-	  request = require('request'),
-	  cache   = require('./cache');
-
-class Timer {
-	constructor(callback, delay, paused) {
-		this.callback = callback;
-		this.timerId = undefined;
-		this.start = undefined;
-		this.paused = paused;
-		this.remaining = moment.duration(delay, "hh:mm:ss").asSeconds() * 1000;
-		this.timeWhenPaused = 0;
-		this.timePaused = Date.now();
-
-		if (!paused) {
-			this.resume();
-		}
-	}
+import crypto from "crypto";
+import CoreClass from "../core";
 
 
-	pause() {
-		clearTimeout(this.timerId);
-		this.remaining -= Date.now() - this.start;
-		this.timePaused = Date.now();
-		this.paused = true;
-	}
+let UtilsModule;
 
 
-	ifNotPaused() {
-		if (!this.paused) {
-			this.resume();
-		}
-	}
+class _UtilsModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("utils");
 
 
-	resume() {
-		this.start = Date.now();
-		clearTimeout(this.timerId);
-		this.timerId = setTimeout(this.callback, this.remaining);
-		this.timeWhenPaused = Date.now() - this.timePaused;
-		this.paused = false;
+		UtilsModule = this;
 	}
 	}
 
 
-	resetTimeWhenPaused() {
-		this.timeWhenPaused = 0;
+	/**
+	 * Initialises the utils module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => resolve());
 	}
 	}
 
 
-	getTimePaused() {
-		if (!this.paused) {
-			return this.timeWhenPaused;
-		} else {
-			return Date.now() - this.timePaused;
-		}
+	/**
+	 * Parses the cookie into a readable object
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.cookieString - the cookie string
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	PARSE_COOKIES(payload) {
+		return new Promise((resolve, reject) => {
+			const cookies = {};
+
+			if (typeof payload.cookieString !== "string") return reject(new Error("Cookie string is not a string"));
+
+			// eslint-disable-next-line array-callback-return
+			payload.cookieString.split("; ").map(cookie => {
+				cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(
+					cookie.indexOf("=") + 1,
+					cookie.length
+				);
+			});
+
+			return resolve(cookies);
+		});
 	}
 	}
-}
 
 
-function convertTime (duration) {
-	let a = duration.match(/\d+/g);
+	// COOKIES_TO_STRING() {//cookies
+	// 	return new Promise((resolve, reject) => {
+	//         let newCookie = [];
+	//         for (let prop in cookie) {
+	//             newCookie.push(prop + "=" + cookie[prop]);
+	//         }
+	//         return newCookie.join("; ");
+	//     });
+	// }
+
+	/**
+	 * Removes a cookie by name
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.cookieString - the cookie string
+	 * @param {string} payload.cookieName - the unique name of the cookie
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	REMOVE_COOKIE(payload) {
+		return new Promise((resolve, reject) => {
+			let cookies;
+
+			try {
+				cookies = UtilsModule.runJob(
+					"PARSE_COOKIES",
+					{
+						cookieString: payload.cookieString
+					},
+					this
+				);
+			} catch (err) {
+				return reject(err);
+			}
 
 
-	if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
-		a = [0, a[0], 0];
-	}
+			delete cookies[payload.cookieName];
 
 
-	if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
-		a = [a[0], 0, a[1]];
+			return resolve();
+		});
 	}
 	}
-	if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
-		a = [a[0], 0, 0];
+
+	/**
+	 * Replaces any html reserved characters in a string with html entities
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.str - the string to replace characters with html entities
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	HTML_ENTITIES(payload) {
+		return new Promise(resolve => {
+			resolve(
+				String(payload.str)
+					.replace(/&/g, "&amp;")
+					.replace(/</g, "&lt;")
+					.replace(/>/g, "&gt;")
+					.replace(/"/g, "&quot;")
+			);
+		});
 	}
 	}
 
 
-	duration = 0;
+	/**
+	 * Generates a random string of a specified length
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {number} payload.length - the length the random string should be
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GENERATE_RANDOM_STRING(payload) {
+		const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
+
+		const promises = [];
+		for (let i = 0; i < payload.length; i += 1) {
+			promises.push(
+				UtilsModule.runJob(
+					"GET_RANDOM_NUMBER",
+					{
+						min: 0,
+						max: chars.length - 1
+					},
+					this
+				)
+			);
+		}
 
 
-	if (a.length == 3) {
-		duration = duration + parseInt(a[0]) * 3600;
-		duration = duration + parseInt(a[1]) * 60;
-		duration = duration + parseInt(a[2]);
-	}
+		const randomNums = await Promise.all(promises);
 
 
-	if (a.length == 2) {
-		duration = duration + parseInt(a[0]) * 60;
-		duration = duration + parseInt(a[1]);
-	}
+		const randomChars = [];
+		for (let i = 0; i < payload.length; i += 1) {
+			randomChars.push(chars[randomNums[i]]);
+		}
 
 
-	if (a.length == 1) {
-		duration = duration + parseInt(a[0]);
+		return new Promise(resolve => resolve(randomChars.join("")));
 	}
 	}
 
 
-	let hours = Math.floor(duration / 3600);
-	let minutes = Math.floor(duration % 3600 / 60);
-	let seconds = Math.floor(duration % 3600 % 60);
-
-	return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
-}
+	/**
+	 * Creates a random number within a range
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {number} payload.min - the minimum number the result should be
+	 * @param {number} payload.max - the maximum number the result should be
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_RANDOM_NUMBER(payload) {
+		// min, max
+		return new Promise(resolve =>
+			resolve(Math.floor(Math.random() * (payload.max - payload.min + 1)) + payload.min)
+		);
+	}
 
 
-let youtubeRequestCallbacks = [];
-let youtubeRequestsPending = 0;
-let youtubeRequestsActive = false;
-
-module.exports = {
-	htmlEntities: str => String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
-	generateRandomString: function(len) {
-		let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
-		let result = [];
-		for (let i = 0; i < len; i++) {
-			result.push(chars[this.getRandomNumber(0, chars.length - 1)]);
-		}
-		return result.join("");
-	},
-	getSocketFromId: function(socketId) {
-		return globals.io.sockets.sockets[socketId];
-	},
-	getRandomNumber: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min,
-	convertTime,
-	Timer,
-	guid: () => [1,1,0,1,0,1,0,1,0,1,1,1].map(b => b ? Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) : '-').join(''),
-	cookies: {
-		parseCookies: cookieString => {
-			let cookies = {};
-			if (cookieString) cookieString.split("; ").map((cookie) => {
-				(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
-			});
-			return cookies;
-		},
-		toString: cookies => {
-			let newCookie = [];
-			for (let prop in cookie) {
-				newCookie.push(prop + "=" + cookie[prop]);
+	/**
+	 * Converts ISO8601 time format (YouTube API) to HH:MM:SS
+	 *
+	 * @param  {object} payload - object contaiing the payload
+	 * @param {string} payload.duration - string in the format of ISO8601
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CONVERT_TIME(payload) {
+		// duration
+		return new Promise(resolve => {
+			let { duration } = payload;
+			let a = duration.match(/\d+/g);
+
+			if (duration.indexOf("M") >= 0 && duration.indexOf("H") === -1 && duration.indexOf("S") === -1) {
+				a = [0, a[0], 0];
 			}
 			}
-			return newCookie.join("; ");
-		},
-		removeCookie: (cookieString, cookieName) => {
-			var cookies = this.parseCookies(cookieString);
-			delete cookies[cookieName];
-			return this.toString(cookies);
-		}
-	},
-	socketFromSession: function(socketId) {
-		let ns = io.io.of("/");
-		if (ns) {
-			return ns.connected[socketId];
-		}
-	},
-	socketsFromUser: function(userId, cb) {
-		let ns = io.io.of("/");
-		let sockets = [];
-		if (ns) {
-			let total = Object.keys(ns.connected).length;
-			let done = 0;
-			for (let id in ns.connected) {
-				let session = ns.connected[id].session;
-				cache.hget('sessions', session.sessionId, (err, session) => {
-					if (!err && session && session.userId === userId) {
-						sockets.push(ns.connected[id]);
-					}
-					checkComplete();
-				});
+
+			if (duration.indexOf("H") >= 0 && duration.indexOf("M") === -1) {
+				a = [a[0], 0, a[1]];
 			}
 			}
-			function checkComplete() {
-				done++;
-				if (done === total) {
-					cb(sockets);
-				}
+			if (duration.indexOf("H") >= 0 && duration.indexOf("M") === -1 && duration.indexOf("S") === -1) {
+				a = [a[0], 0, 0];
 			}
 			}
-		}
-	},
-	socketLeaveRooms: function(socketid) {
-		let socket = this.socketFromSession(socketid);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			socket.leave(room);
-		}
-	},
-	socketJoinRoom: function(socketId, room) {
-		let socket = this.socketFromSession(socketId);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			socket.leave(room);
-		}
-		socket.join(room);
-	},
-	socketJoinSongRoom: function(socketId, room) {
-		let socket = this.socketFromSession(socketId);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			if (room.indexOf('song.') !== -1) socket.leave(rooms);
-		}
-		socket.join(room);
-	},
-	socketsJoinSongRoom: function(sockets, room) {
-		for (let id in sockets) {
-			let socket = sockets[id];
-			let rooms = socket.rooms;
-			for (let room in rooms) {
-				if (room.indexOf('song.') !== -1) socket.leave(room);
+
+			duration = 0;
+
+			if (a.length === 3) {
+				duration += parseInt(a[0]) * 3600;
+				duration += parseInt(a[1]) * 60;
+				duration += parseInt(a[2]);
 			}
 			}
-			socket.join(room);
-		}
-	},
-	socketsLeaveSongRooms: function(sockets) {
-		for (let id in sockets) {
-			let socket = sockets[id];
-			let rooms = socket.rooms;
-			for (let room in rooms) {
-				if (room.indexOf('song.') !== -1) socket.leave(room);
+
+			if (a.length === 2) {
+				duration += parseInt(a[0]) * 60;
+				duration += parseInt(a[1]);
 			}
 			}
-		}
-	},
-	emitToRoom: function(room) {
-		let sockets = io.io.sockets.sockets;
-		for (let id in sockets) {
-			let socket = sockets[id];
-			if (socket.rooms[room]) {
-				let args = [];
-				for (let i = 1; i < Object.keys(arguments).length; i++) {
-					args.push(arguments[i]);
-				}
-				socket.emit.apply(socket, args);
+
+			if (a.length === 1) {
+				duration += parseInt(a[0]);
 			}
 			}
-		}
-	},
-	getRoomSockets: function(room) {
-		let sockets = io.io.sockets.sockets;
-		let roomSockets = [];
-		for (let id in sockets) {
-			let socket = sockets[id];
-			if (socket.rooms[room]) roomSockets.push(socket);
-		}
-		return roomSockets;
-	},
-	getSongFromYouTube: (songId, cb) => {
-
-		youtubeRequestCallbacks.push({cb: (test) => {
-			youtubeRequestsActive = true;
-			const youtubeParams = [
-				'part=snippet,contentDetails,statistics,status',
-				`id=${encodeURIComponent(songId)}`,
-				`key=${config.get('apis.youtube.key')}`
-			].join('&');
-
-			request(`https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`, (err, res, body) => {
-
-				youtubeRequestCallbacks.splice(0, 1);
-				if (youtubeRequestCallbacks.length > 0) {
-					youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
-				} else youtubeRequestsActive = false;
-
-				if (err) {
-					console.error(err);
-					return null;
-				}
-
-				body = JSON.parse(body);
-
-				//TODO Clean up duration converter
-				let dur = body.items[0].contentDetails.duration;
-				dur = dur.replace('PT', '');
-				let duration = 0;
-				dur = dur.replace(/([\d]*)H/, (v, v2) => {
-					v2 = Number(v2);
-					duration = (v2 * 60 * 60);
-					return '';
-				});
-				dur = dur.replace(/([\d]*)M/, (v, v2) => {
-					v2 = Number(v2);
-					duration += (v2 * 60);
-					return '';
-				});
-				dur = dur.replace(/([\d]*)S/, (v, v2) => {
-					v2 = Number(v2);
-					duration += v2;
-					return '';
-				});
-
-				let song = {
-					_id: body.items[0].id,
-					title: body.items[0].snippet.title,
-					duration
-				};
-				cb(song);
-			});
-		}, songId});
 
 
-		if (!youtubeRequestsActive) {
-			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
-		}
-	},
-	getPlaylistFromYouTube: (url, cb) => {
-		
-		let name = 'list'.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
-		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
-		let playlistId = regex.exec(url)[1];
-
-		function getPage(pageToken, songs) {
-			let nextPageToken = (pageToken) ? `pageToken=${pageToken}` : '';
-			const youtubeParams = [
-				'part=contentDetails',
-				`playlistId=${encodeURIComponent(playlistId)}`,
-				`maxResults=5`,
-				`key=${config.get('apis.youtube.key')}`,
-				nextPageToken
-			].join('&');
-
-			request(`https://www.googleapis.com/youtube/v3/playlistItems?${youtubeParams}`, (err, res, body) => {
-				if (err) {
-					console.error(err);
-					return next('Failed to find playlist from YouTube');
-				}
-
-				body = JSON.parse(body);
-				songs = songs.concat(body.items);
-				if (body.nextPageToken) getPage(body.nextPageToken, songs);
-				else {
-					console.log(songs);
-					cb(songs);
-				}
-			});
-		}
-		getPage(null, []);
-	},
-	getSongFromSpotify: (song, cb) => {
-		const spotifyParams = [
-			`q=${encodeURIComponent(song.title)}`,
-			`type=track`
-		].join('&');
-
-		request(`https://api.spotify.com/v1/search?${spotifyParams}`, (err, res, body) => {
-
-			if (err) console.error(err);
-
-			body = JSON.parse(body);
-
-			durationArtistLoop:
-			for (let i in body) {
-				let items = body[i].items;
-				for (let j in items) {
-					let item = items[j];
-					let hasArtist = false;
-					for (let k = 0; k < item.artists.length; k++) {
-						let artist = item.artists[k];
-						if (song.title.indexOf(artist.name) !== -1) hasArtist = true;
-					}
-					if (hasArtist && song.title.indexOf(item.name) !== -1) {
-						song.duration = item.duration_ms / 1000;
-						song.artists = item.artists.map(artist => {
-							return artist.name;
-						});
-						song.title = item.name;
-						song.explicit = item.explicit;
-						song.thumbnail = item.album.images[1].url;
-						break durationArtistLoop;
-					}
-				}
+			const hours = Math.floor(duration / 3600);
+			const minutes = Math.floor((duration % 3600) / 60);
+			const seconds = Math.floor((duration % 3600) % 60);
+
+			resolve(
+				(hours < 10 ? `0${hours}:` : `${hours}:`) +
+					(minutes < 10 ? `0${minutes}:` : `${minutes}:`) +
+					(seconds < 10 ? `0${seconds}` : seconds)
+			);
+		});
+	}
+
+	/**
+	 * Creates a random identifier for e.g. sessionId
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GUID() {
+		return new Promise(resolve => {
+			resolve(
+				[1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1]
+					.map(b =>
+						b
+							? Math.floor((1 + Math.random()) * 0x10000)
+									.toString(16)
+									.substring(1)
+							: "-"
+					)
+					.join("")
+			);
+		});
+	}
+
+	/**
+	 * Shuffles an array
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.array - an array of songs that should be shuffled
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SHUFFLE(payload) {
+		// array
+		return new Promise(resolve => {
+			const { array } = payload;
+
+			// sort the positions array
+			let currentIndex = array.length;
+			let temporaryValue;
+			let randomIndex;
+
+			// While there remain elements to shuffle...
+			while (currentIndex !== 0) {
+				// Pick a remaining element...
+				randomIndex = Math.floor(Math.random() * currentIndex);
+				currentIndex -= 1;
+
+				// And swap it with the current element.
+				temporaryValue = array[currentIndex];
+				array[currentIndex] = array[randomIndex];
+				array[randomIndex] = temporaryValue;
 			}
 			}
 
 
-			cb(song);
+			resolve({ array });
 		});
 		});
-	},
-	shuffle: (array) => {
-		let currentIndex = array.length, temporaryValue, randomIndex;
+	}
+
+	/**
+	 * Shuffles an array of songs by their position property
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.array - an array of songs that should be shuffled
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SHUFFLE_SONG_POSITIONS(payload) {
+		// array
+		return new Promise(resolve => {
+			const { array } = payload;
+
+			// get array of positions
+			const positions = [];
+			array.forEach(song => positions.push(song.position));
+
+			// sort the positions array
+			let currentIndex = positions.length;
+			let temporaryValue;
+			let randomIndex;
+
+			// While there remain elements to shuffle...
+			while (currentIndex !== 0) {
+				// Pick a remaining element...
+				randomIndex = Math.floor(Math.random() * currentIndex);
+				currentIndex -= 1;
+
+				// And swap it with the current element.
+				temporaryValue = positions[currentIndex];
+				positions[currentIndex] = positions[randomIndex];
+				positions[randomIndex] = temporaryValue;
+			}
 
 
-		// While there remain elements to shuffle...
-		while (0 !== currentIndex) {
+			// assign new positions
+			array.forEach((song, index) => {
+				song.position = positions[index];
+			});
 
 
-			// Pick a remaining element...
-			randomIndex = Math.floor(Math.random() * currentIndex);
-			currentIndex -= 1;
+			resolve({ array });
+		});
+	}
 
 
-			// And swap it with the current element.
-			temporaryValue = array[currentIndex];
-			array[currentIndex] = array[randomIndex];
-			array[randomIndex] = temporaryValue;
-		}
+	/**
+	 * Creates an error
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.error - object that contains the error
+	 * @param {string} payload.message - possible error message
+	 * @param {object} payload.errors - possible object that contains multiple errors
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ERROR(payload) {
+		return new Promise(resolve => {
+			let error = "An error occurred.";
+			if (typeof payload.error === "string") error = payload.error;
+			else if (payload.error.message) {
+				if (payload.error.message !== "Validation failed") error = payload.error.message;
+				else error = payload.error.errors[Object.keys(payload.error.errors)].message;
+			}
+			resolve(error);
+		});
+	}
+
+	/**
+	 * Creates the gravatar url for a specified email address
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.email - the email address
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	CREATE_GRAVATAR(payload) {
+		return new Promise(resolve => {
+			const hash = crypto.createHash("md5").update(payload.email).digest("hex");
+
+			resolve(`https://www.gravatar.com/avatar/${hash}`);
+		});
+	}
 
 
-		return array;
+	/**
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	DEBUG() {
+		return new Promise(resolve => resolve());
 	}
 	}
-};
+}
+
+export default new _UtilsModule();

+ 771 - 0
backend/logic/ws.js

@@ -0,0 +1,771 @@
+/**
+ * @file
+ */
+
+import config from "config";
+import async from "async";
+import { WebSocketServer } from "ws";
+import { EventEmitter } from "events";
+
+import CoreClass from "../core";
+
+let WSModule;
+let AppModule;
+let CacheModule;
+let UtilsModule;
+let DBModule;
+let PunishmentsModule;
+
+class _WSModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("ws");
+
+		WSModule = this;
+	}
+
+	/**
+	 * Initialises the ws module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		this.setStage(1);
+
+		AppModule = this.moduleManager.modules.app;
+		CacheModule = this.moduleManager.modules.cache;
+		UtilsModule = this.moduleManager.modules.utils;
+		DBModule = this.moduleManager.modules.db;
+		PunishmentsModule = this.moduleManager.modules.punishments;
+
+		this.actions = (await import("./actions")).default;
+
+		this.userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
+
+		this.setStage(2);
+
+		this.SIDname = config.get("cookie.SIDname");
+
+		// TODO: Check every 30s/, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
+		const server = await AppModule.runJob("SERVER");
+
+		this._io = new WebSocketServer({ server, path: "/ws" });
+
+		this.rooms = {};
+
+		return new Promise(resolve => {
+			this.setStage(3);
+
+			this._io.on("connection", async (socket, req) => {
+				socket.dispatch = (...args) => socket.send(JSON.stringify(args));
+
+				socket.actions = new EventEmitter();
+				socket.actions.setMaxListeners(0);
+				socket.listen = (target, cb) => socket.actions.addListener(target, args => cb(args));
+
+				WSModule.runJob("HANDLE_WS_USE", { socket, req }).then(socket =>
+					WSModule.runJob("HANDLE_WS_CONNECTION", { socket })
+				);
+
+				socket.isAlive = true;
+				socket.on("pong", function heartbeat() {
+					this.isAlive = true;
+				});
+			});
+
+			const keepAliveInterval = setInterval(() => {
+				this._io.clients.forEach(socket => {
+					if (socket.isAlive === false) return socket.terminate();
+
+					socket.isAlive = false;
+					return socket.ping(() => {});
+				});
+			}, 45000);
+
+			this._io.on("close", () => clearInterval(keepAliveInterval));
+
+			this.setStage(4);
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Returns the websockets variable
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	WS() {
+		return new Promise(resolve => resolve(WSModule._io));
+	}
+
+	/**
+	 * Obtains socket object for a specified socket id
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.socketId - the id of the socket
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_FROM_SOCKET_ID(payload) {
+		return new Promise(resolve => {
+			const { clients } = WSModule._io;
+
+			if (clients)
+				// eslint-disable-next-line consistent-return
+				clients.forEach(socket => {
+					if (socket.session.socketId === payload.socketId) return resolve(socket);
+				});
+
+			// socket doesn't exist
+			return resolve();
+		});
+	}
+
+	/**
+	 * Gets all sockets for a specified session id
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.sessionId - user session id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKETS_FROM_SESSION_ID(payload) {
+		return new Promise(resolve => {
+			const { clients } = WSModule._io;
+			const sockets = [];
+
+			if (clients) {
+				return async.each(
+					Object.keys(clients),
+					(id, next) => {
+						const { session } = clients[id];
+						if (session.sessionId === payload.sessionId) sockets.push(session.sessionId);
+						next();
+					},
+					() => resolve(sockets)
+				);
+			}
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Returns any sockets for a specific user
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the user id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKETS_FROM_USER(payload) {
+		return new Promise((resolve, reject) => {
+			const sockets = [];
+
+			return async.eachLimit(
+				WSModule._io.clients,
+				1,
+				(socket, next) => {
+					const { sessionId } = socket.session;
+
+					if (sessionId) {
+						return CacheModule.runJob("HGET", { table: "sessions", key: sessionId }, this)
+							.then(session => {
+								if (session && session.userId === payload.userId) sockets.push(socket);
+								next();
+							})
+							.catch(err => next(err));
+					}
+
+					return next();
+				},
+				err => {
+					if (err) return reject(err);
+					return resolve(sockets);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns any sockets from a specific ip address
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.ip - the ip address in question
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKETS_FROM_IP(payload) {
+		return new Promise(resolve => {
+			const { clients } = WSModule._io;
+
+			const sockets = [];
+
+			return async.each(
+				Object.keys(clients),
+				(id, next) => {
+					const { session } = clients[id];
+
+					CacheModule.runJob("HGET", { table: "sessions", key: session.sessionId }, this)
+						.then(session => {
+							if (session && clients[id].ip === payload.ip) sockets.push(clients[id]);
+							next();
+						})
+						.catch(() => next());
+				},
+				() => resolve(sockets)
+			);
+		});
+	}
+
+	/**
+	 * Returns any sockets from a specific user without using redis/cache
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.userId - the id of the user in question
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKETS_FROM_USER_WITHOUT_CACHE(payload) {
+		return new Promise(resolve => {
+			const { clients } = WSModule._io;
+			const sockets = [];
+
+			if (clients) {
+				return async.each(
+					Object.keys(clients),
+					(id, next) => {
+						const { session } = clients[id];
+						if (session.userId === payload.userId) sockets.push(clients[id]);
+						next();
+					},
+					() => resolve(sockets)
+				);
+			}
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Allows a socket to leave any rooms they are connected to
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket which should leave all their rooms
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_LEAVE_ROOMS(payload) {
+		return new Promise(resolve => {
+			// filter out rooms that the user is in
+			Object.keys(WSModule.rooms).forEach(room => {
+				WSModule.rooms[room] = WSModule.rooms[room].filter(participant => participant !== payload.socketId);
+			});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Allows a socket to leave a specific room they are connected to
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket which should leave a room
+	 * @param {string} payload.room - the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_LEAVE_ROOM(payload) {
+		return new Promise(resolve => {
+			// filter out rooms that the user is in
+			if (WSModule.rooms[payload.room])
+				WSModule.rooms[payload.room] = WSModule.rooms[payload.room].filter(
+					participant => participant !== payload.socketId
+				);
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Allows a socket to join a specified room (this will remove them from any rooms they are currently in)
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket which should join the room
+	 * @param {string} payload.room - the name of the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_JOIN_ROOM(payload) {
+		const { room, socketId } = payload;
+		return new Promise(resolve => {
+			// create room if it doesn't exist, and add socketId to array
+			if (WSModule.rooms[room]) {
+				if (WSModule.rooms[room].indexOf(socketId) === -1) WSModule.rooms[room].push(socketId);
+			} else WSModule.rooms[room] = [socketId];
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Emits arguments to any sockets that are in a specified a room
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.room - the name of the room to emit arguments
+	 * @param {object} payload.args - any arguments to be emitted to the sockets in the specific room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async EMIT_TO_ROOM(payload) {
+		return new Promise(resolve => {
+			// if the room exists
+			if (WSModule.rooms[payload.room] && WSModule.rooms[payload.room].length > 0)
+				return WSModule.rooms[payload.room].forEach(async socketId => {
+					// get every socketId (and thus every socket) in the room, and dispatch to each
+					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
+					if (socket) socket.dispatch(...payload.args);
+					return resolve();
+				});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Emits arguments to any sockets that are in specified rooms
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.rooms - array of strings with the name of each room e.g. ["station-page", "song.1234"]
+	 * @param {object} payload.args - any arguments to be emitted to the sockets in the specific room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async EMIT_TO_ROOMS(payload) {
+		return new Promise(resolve =>
+			async.each(
+				payload.rooms,
+				(room, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", { room, args: payload.args });
+					return next();
+				},
+				() => resolve()
+			)
+		);
+	}
+
+	/**
+	 * Allows a socket to join a 'song' room
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket which should join the room
+	 * @param {string} payload.room - the name of the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async SOCKET_JOIN_SONG_ROOM(payload) {
+		const { room, socketId } = payload;
+
+		// leave any other song rooms the user is in
+		await WSModule.runJob("SOCKETS_LEAVE_SONG_ROOMS", { sockets: [socketId] }, this);
+
+		return new Promise(resolve => {
+			// join the room
+			if (WSModule.rooms[room]) WSModule.rooms[room].push(socketId);
+			else WSModule.rooms[room] = [socketId];
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Allows multiple sockets to join a 'song' room
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.sockets - array of socketIds
+	 * @param {object} payload.room - the name of the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SOCKETS_JOIN_SONG_ROOM(payload) {
+		return new Promise(resolve => {
+			Promise.allSettled(
+				payload.sockets.map(async socketId => {
+					await WSModule.runJob("SOCKET_JOIN_SONG_ROOM", { socketId, room: payload.room }, this);
+				})
+			).then(() => resolve());
+		});
+	}
+
+	/**
+	 * Allows multiple sockets to leave any 'song' rooms they are in
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.sockets - array of socketIds
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SOCKETS_LEAVE_SONG_ROOMS(payload) {
+		return new Promise(resolve =>
+			Promise.allSettled(
+				payload.sockets.map(async socketId => {
+					const rooms = await WSModule.runJob("GET_ROOMS_FOR_SOCKET", { socketId }, this);
+
+					rooms.forEach(room => {
+						if (room.indexOf("song.") !== -1)
+							WSModule.rooms[room] = WSModule.rooms[room].filter(participant => participant !== socketId);
+					});
+				})
+			).then(() => resolve())
+		);
+	}
+
+	/**
+	 * Gets any sockets connected to a room
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.room - the name of the room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_SOCKETS_FOR_ROOM(payload) {
+		return new Promise(resolve => {
+			if (WSModule.rooms[payload.room]) return resolve(WSModule.rooms[payload.room]);
+			return resolve([]);
+		});
+	}
+
+	/**
+	 * Gets any rooms a socket is connected to
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.socketId - the id of the socket to check the rooms for
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_ROOMS_FOR_SOCKET(payload) {
+		return new Promise(resolve => {
+			const rooms = [];
+
+			Object.keys(WSModule.rooms).forEach(room => {
+				if (WSModule.rooms[room].includes(payload.socketId)) rooms.push(room);
+			});
+
+			return resolve(rooms);
+		});
+	}
+
+	/**
+	 * Handles use of websockets
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async HANDLE_WS_USE(payload) {
+		return new Promise(resolve => {
+			const { socket, req } = payload;
+			let SID = "";
+
+			socket.ip = req.headers["x-forwarded-for"] || "0..0.0";
+
+			return async.waterfall(
+				[
+					next => {
+						if (!req.headers.cookie) return next("No cookie exists yet.");
+						return UtilsModule.runJob("PARSE_COOKIES", { cookieString: req.headers.cookie }, this).then(
+							res => {
+								SID = res[WSModule.SIDname];
+								next(null);
+							}
+						);
+					},
+
+					next => {
+						if (!SID) return next("No SID.");
+						return next();
+					},
+
+					// see if session exists for cookie
+					next => {
+						CacheModule.runJob("HGET", { table: "sessions", key: SID }, this)
+							.then(session => next(null, session))
+							.catch(next);
+					},
+
+					(session, next) => {
+						if (!session) return next("No session found.");
+
+						session.refreshDate = Date.now();
+
+						socket.session = session;
+
+						return CacheModule.runJob(
+							"HSET",
+							{
+								table: "sessions",
+								key: SID,
+								value: session
+							},
+							this
+						).then(session => next(null, session));
+					},
+
+					(res, next) => {
+						// check if a session's user / IP is banned
+						PunishmentsModule.runJob("GET_PUNISHMENTS", {}, this)
+							.then(punishments => {
+								const isLoggedIn = !!(socket.session && socket.session.refreshDate);
+								const userId = isLoggedIn ? socket.session.userId : null;
+
+								const banishment = {
+									banned: false,
+									ban: 0
+								};
+
+								punishments.forEach(punishment => {
+									if (punishment.expiresAt > banishment.ban) banishment.ban = punishment;
+									if (punishment.type === "banUserId" && isLoggedIn && punishment.value === userId)
+										banishment.banned = true;
+									if (punishment.type === "banUserIp" && punishment.value === socket.ip)
+										banishment.banned = true;
+								});
+
+								socket.banishment = banishment;
+
+								next();
+							})
+							.catch(() => next());
+					}
+				],
+				() => {
+					if (!socket.session) socket.session = { socketId: req.headers["sec-websocket-key"] };
+					else socket.session.socketId = req.headers["sec-websocket-key"];
+
+					resolve(socket);
+				}
+			);
+		});
+	}
+
+	/**
+	 * Handles a websocket connection
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.socket - socket itself
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async HANDLE_WS_CONNECTION(payload) {
+		return new Promise(resolve => {
+			const { socket } = payload;
+
+			let sessionInfo = "";
+			if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
+
+			// if session is banned
+			if (socket.banishment && socket.banishment.banned) {
+				WSModule.log(
+					"INFO",
+					"IO_BANNED_CONNECTION",
+					`A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`
+				);
+
+				socket.dispatch("keep.event:banned", { data: { ban: socket.banishment.ban } });
+
+				return socket.close(); // close socket connection
+			}
+
+			WSModule.log("INFO", "IO_CONNECTION", `User connected. IP: ${socket.ip}.${sessionInfo}`);
+
+			// catch when the socket has been disconnected
+			socket.on("close", async () => {
+				if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
+				WSModule.log("INFO", "IO_DISCONNECTION", `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
+
+				// leave all rooms when a socket connection is closed (to prevent rooms object building up)
+				await WSModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: socket.session.socketId });
+			});
+
+			// catch errors on the socket
+			socket.onerror = error => {
+				console.error("SOCKET ERROR: ", error);
+			};
+
+			if (socket.session.sessionId) {
+				CacheModule.runJob("HGET", {
+					table: "sessions",
+					key: socket.session.sessionId
+				})
+					.then(session => {
+						if (session && session.userId) {
+							WSModule.userModel.findOne({ _id: session.userId }, (err, user) => {
+								if (err || !user) return socket.dispatch("ready", { data: { loggedIn: false } });
+
+								let role = "";
+								let username = "";
+								let userId = "";
+								let email = "";
+
+								if (user) {
+									role = user.role;
+									username = user.username;
+									email = user.email.address;
+									userId = session.userId;
+								}
+
+								return socket.dispatch("ready", {
+									data: { loggedIn: true, role, username, userId, email }
+								});
+							});
+						} else socket.dispatch("ready", { data: { loggedIn: false } });
+					})
+					.catch(() => socket.dispatch("ready", { data: { loggedIn: false } }));
+			} else socket.dispatch("ready", { data: { loggedIn: false } });
+
+			socket.onmessage = message => {
+				const data = JSON.parse(message.data);
+
+				if (data.length === 0) return socket.dispatch("ERROR", "Not enough arguments specified.");
+				if (typeof data[0] !== "string") return socket.dispatch("ERROR", "First argument must be a string.");
+
+				const namespaceAction = data[0];
+				if (
+					!namespaceAction ||
+					namespaceAction.indexOf(".") === -1 ||
+					namespaceAction.indexOf(".") !== namespaceAction.lastIndexOf(".")
+				)
+					return socket.dispatch("ERROR", "Invalid first argument");
+
+				const namespace = data[0].split(".")[0];
+				const action = data[0].split(".")[1];
+
+				if (!namespace) return socket.dispatch("ERROR", "Invalid namespace.");
+				if (!action) return socket.dispatch("ERROR", "Invalid action.");
+				if (!WSModule.actions[namespace]) return socket.dispatch("ERROR", "Namespace not found.");
+				if (!WSModule.actions[namespace][action]) return socket.dispatch("ERROR", "Action not found.");
+
+				if (data[data.length - 1].CB_REF) {
+					const { CB_REF } = data[data.length - 1];
+					data.pop();
+
+					return socket.actions.emit(data.shift(0), [...data, res => socket.dispatch("CB_REF", CB_REF, res)]);
+				}
+
+				return socket.actions.emit(data.shift(0), data);
+			};
+
+			// have the socket listen for each action
+			Object.keys(WSModule.actions).forEach(namespace => {
+				Object.keys(WSModule.actions[namespace]).forEach(action => {
+					// the full name of the action
+					const name = `${namespace}.${action}`;
+
+					// listen for this action to be called
+					socket.listen(name, async args =>
+						WSModule.runJob("RUN_ACTION", { socket, namespace, action, args })
+					);
+				});
+			});
+
+			return resolve();
+		});
+	}
+
+	/**
+	 * Runs an action
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async RUN_ACTION(payload) {
+		return new Promise((resolve, reject) => {
+			const { socket, namespace, action, args } = payload;
+
+			// the full name of the action
+			const name = `${namespace}.${action}`;
+
+			let cb = args[args.length - 1];
+
+			if (typeof cb !== "function")
+				cb = () => {
+					WSModule.log("INFO", "IO_MODULE", `There was no callback provided for ${name}.`);
+				};
+			else args.pop();
+
+			WSModule.log("INFO", "IO_ACTION", `A user executed an action. Action: ${namespace}.${action}.`);
+
+			// load the session from the cache
+			new Promise(resolve => {
+				if (socket.session.sessionId)
+					CacheModule.runJob("HGET", {
+						table: "sessions",
+						key: socket.session.sessionId
+					})
+						.then(session => {
+							// make sure the sockets sessionId isn't set if there is no session
+							if (socket.session.sessionId && session === null) delete socket.session.sessionId;
+							resolve();
+						})
+						.catch(() => {
+							if (typeof cb === "function")
+								cb({
+									status: "error",
+									message: "An error occurred while obtaining your session"
+								});
+							reject(new Error("An error occurred while obtaining the session"));
+						});
+				else resolve();
+			})
+				.then(() => {
+					// call the job that calls the action, passing it the session, and the arguments the websocket passed us
+
+					WSModule.runJob("RUN_ACTION2", { session: socket.session, namespace, action, args }, this)
+						.then(response => {
+							cb(response);
+							resolve();
+						})
+						.catch(err => {
+							if (typeof cb === "function")
+								cb({
+									status: "error",
+									message: "An error occurred while executing the specified action."
+								});
+
+							reject(err);
+
+							WSModule.log(
+								"ERROR",
+								"IO_ACTION_ERROR",
+								`Some type of exception occurred in the action ${namespace}.${action}. Error message: ${err.message}`
+							);
+						});
+				})
+				.catch(reject);
+		});
+	}
+
+	/**
+	 * Runs an action
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async RUN_ACTION2(payload) {
+		return new Promise((resolve, reject) => {
+			const { session, namespace, action, args } = payload;
+
+			try {
+				// call the the action, passing it the session, and the arguments the websocket passed us
+				WSModule.actions[namespace][action].apply(
+					this,
+					[session].concat(args).concat([
+						result => {
+							WSModule.log(
+								"INFO",
+								"RUN_ACTION2",
+								`Response to action. Action: ${namespace}.${action}. Response status: ${result.status}`
+							);
+
+							resolve(result);
+						}
+					])
+				);
+			} catch (err) {
+				reject(err);
+
+				WSModule.log(
+					"ERROR",
+					"IO_ACTION_ERROR",
+					`Some type of exception occurred in the action ${namespace}.${action}. Error message: ${err.message}`
+				);
+			}
+		});
+	}
+}
+
+export default new _WSModule();

+ 430 - 0
backend/logic/youtube.js

@@ -0,0 +1,430 @@
+import async from "async";
+import config from "config";
+
+import * as rax from "retry-axios";
+import axios from "axios";
+
+import CoreClass from "../core";
+
+class RateLimitter {
+	/**
+	 * Constructor
+	 *
+	 * @param {number} timeBetween - The time between each allowed YouTube request
+	 */
+	constructor(timeBetween) {
+		this.dateStarted = Date.now();
+		this.timeBetween = timeBetween;
+	}
+
+	/**
+	 * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
+	 *
+	 * @returns {Promise} - promise that gets resolved when the rate limit allows it
+	 */
+	continue() {
+		return new Promise(resolve => {
+			if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
+			else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
+		});
+	}
+
+	/**
+	 * Restart the rate limit timer
+	 */
+	restart() {
+		this.dateStarted = Date.now();
+	}
+}
+
+let YouTubeModule;
+
+class _YouTubeModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("youtube", {
+			concurrency: 1,
+			priorities: {
+				GET_PLAYLIST: 11
+			}
+		});
+
+		YouTubeModule = this;
+	}
+
+	/**
+	 * Initialises the activities module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	initialize() {
+		return new Promise(resolve => {
+			this.rateLimiter = new RateLimitter(config.get("apis.youtube.rateLimit"));
+			this.requestTimeout = config.get("apis.youtube.requestTimeout");
+
+			this.axios = axios.create();
+			this.axios.defaults.raxConfig = {
+				instance: this.axios,
+				retry: config.get("apis.youtube.retryAmount"),
+				noResponseRetries: config.get("apis.youtube.retryAmount")
+			};
+			rax.attach(this.axios);
+
+			resolve();
+		});
+	}
+
+	/**
+	 * Fetches a list of songs from Youtube's API
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the query we'll pass to youtubes api
+	 * @param {string} payload.pageToken - (optional) if this exists, will search search youtube for a specific page reference
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEARCH(payload) {
+		const params = {
+			part: "snippet",
+			q: payload.query,
+			key: config.get("apis.youtube.key"),
+			type: "video",
+			maxResults: 10
+		};
+
+		if (payload.pageToken) params.pageToken = payload.pageToken;
+
+		return new Promise((resolve, reject) =>
+			YouTubeModule.rateLimiter.continue().then(() => {
+				YouTubeModule.rateLimiter.restart();
+				YouTubeModule.axios
+					.get("https://www.googleapis.com/youtube/v3/search", {
+						params,
+						raxConfig: {
+							onRetryAttempt: err => {
+								const cfg = rax.getConfig(err);
+								YouTubeModule.log(
+									"ERROR",
+									"SEARCH",
+									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+								);
+							}
+						}
+					})
+					.then(res => {
+						if (res.data.err) {
+							YouTubeModule.log("ERROR", "SEARCH", `${res.data.error.message}`);
+							return reject(new Error("An error has occured. Please try again later."));
+						}
+
+						return resolve(res.data);
+					})
+					.catch(err => {
+						YouTubeModule.log("ERROR", "SEARCH", `${err.message}`);
+						return reject(new Error("An error has occured. Please try again later."));
+					});
+			})
+		);
+	}
+
+	/**
+	 * Gets the details of a song using the YouTube API
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.youtubeId - the YouTube API id of the song
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const params = {
+				part: "snippet,contentDetails,statistics,status",
+				id: payload.youtubeId,
+				key: config.get("apis.youtube.key")
+			};
+
+			if (payload.pageToken) params.pageToken = payload.pageToken;
+
+			YouTubeModule.rateLimiter.continue().then(() => {
+				YouTubeModule.rateLimiter.restart();
+				YouTubeModule.axios
+					.get("https://www.googleapis.com/youtube/v3/videos", {
+						params,
+						timeout: YouTubeModule.requestTimeout,
+						raxConfig: {
+							onRetryAttempt: err => {
+								const cfg = rax.getConfig(err);
+								YouTubeModule.log(
+									"ERROR",
+									"GET_SONG",
+									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+								);
+							}
+						}
+					})
+					.then(res => {
+						if (res.data.error) {
+							YouTubeModule.log("ERROR", "GET_SONG", `${res.data.error.message}`);
+							return reject(new Error("An error has occured. Please try again later."));
+						}
+
+						if (res.data.items[0] === undefined)
+							return reject(
+								new Error("The specified video does not exist or cannot be publicly accessed.")
+							);
+
+						// TODO Clean up duration converter
+						let dur = res.data.items[0].contentDetails.duration;
+
+						dur = dur.replace("PT", "");
+
+						let duration = 0;
+
+						dur = dur.replace(/([\d]*)H/, (v, v2) => {
+							v2 = Number(v2);
+							duration = v2 * 60 * 60;
+							return "";
+						});
+
+						dur = dur.replace(/([\d]*)M/, (v, v2) => {
+							v2 = Number(v2);
+							duration += v2 * 60;
+							return "";
+						});
+
+						// eslint-disable-next-line no-unused-vars
+						dur = dur.replace(/([\d]*)S/, (v, v2) => {
+							v2 = Number(v2);
+							duration += v2;
+							return "";
+						});
+
+						const song = {
+							youtubeId: res.data.items[0].id,
+							title: res.data.items[0].snippet.title,
+							thumbnail: res.data.items[0].snippet.thumbnails.default.url,
+							duration
+						};
+
+						return resolve({ song });
+					})
+					.catch(err => {
+						YouTubeModule.log("ERROR", "GET_SONG", `${err.message}`);
+						return reject(new Error("An error has occured. Please try again later."));
+					});
+			});
+		});
+	}
+
+	/**
+	 * Returns an array of songs taken from a YouTube playlist
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {boolean} payload.musicOnly - whether to return music videos or all videos in the playlist
+	 * @param {string} payload.url - the url of the YouTube playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST(payload) {
+		return new Promise((resolve, reject) => {
+			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
+			const splitQuery = regex.exec(payload.url);
+
+			if (!splitQuery) {
+				YouTubeModule.log("ERROR", "GET_PLAYLIST", "Invalid YouTube playlist URL query.");
+				return reject(new Error("Invalid playlist URL."));
+			}
+			const playlistId = splitQuery[1];
+
+			return async.waterfall(
+				[
+					next => {
+						let songs = [];
+						let nextPageToken = "";
+
+						async.whilst(
+							next => {
+								YouTubeModule.log(
+									"INFO",
+									`Getting playlist progress for job (${this.toString()}): ${
+										songs.length
+									} songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
+								);
+								next(null, nextPageToken !== undefined);
+							},
+							next => {
+								// Add 250ms delay between each job request
+								setTimeout(() => {
+									YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
+										.then(response => {
+											songs = songs.concat(response.songs);
+											nextPageToken = response.nextPageToken;
+											next();
+										})
+										.catch(err => next(err));
+								}, 250);
+							},
+							err => next(err, songs)
+						);
+					},
+
+					(songs, next) =>
+						next(
+							null,
+							songs.map(song => song.contentDetails.videoId)
+						),
+
+					(songs, next) => {
+						if (!payload.musicOnly) return next(true, { songs });
+						return YouTubeModule.runJob("FILTER_MUSIC_VIDEOS", { videoIds: songs.slice() }, this)
+							.then(filteredSongs => next(null, { filteredSongs, songs }))
+							.catch(next);
+					}
+				],
+				(err, response) => {
+					if (err && err !== true) {
+						YouTubeModule.log("ERROR", "GET_PLAYLIST", "Some error has occurred.", err.message);
+						reject(new Error(err.message));
+					} else {
+						resolve({ songs: response.filteredSongs ? response.filteredSongs.youtubeIds : response.songs });
+					}
+				}
+			);
+		});
+	}
+
+	/**
+	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST.
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {boolean} payload.playlistId - the playlist id to get videos from
+	 * @param {boolean} payload.nextPageToken - the nextPageToken to use
+	 * @param {string} payload.url - the url of the YouTube playlist
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLIST_PAGE(payload) {
+		return new Promise((resolve, reject) => {
+			const params = {
+				part: "contentDetails",
+				playlistId: payload.playlistId,
+				key: config.get("apis.youtube.key"),
+				maxResults: 50
+			};
+
+			if (payload.nextPageToken) params.pageToken = payload.nextPageToken;
+
+			YouTubeModule.rateLimiter.continue().then(() => {
+				YouTubeModule.rateLimiter.restart();
+				YouTubeModule.axios
+					.get("https://www.googleapis.com/youtube/v3/playlistItems", {
+						params,
+						timeout: YouTubeModule.requestTimeout,
+						raxConfig: {
+							onRetryAttempt: err => {
+								const cfg = rax.getConfig(err);
+								YouTubeModule.log(
+									"ERROR",
+									"GET_PLAYLIST_PAGE",
+									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+								);
+							}
+						}
+					})
+					.then(res => {
+						if (res.data.err) {
+							YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${res.data.error.message}`);
+							return reject(new Error("An error has occured. Please try again later."));
+						}
+
+						const songs = res.data.items;
+
+						if (res.data.nextPageToken) return resolve({ nextPageToken: res.data.nextPageToken, songs });
+
+						return resolve({ songs });
+					})
+					.catch(err => {
+						YouTubeModule.log("ERROR", "GET_PLAYLIST_PAGE", `${err.message}`);
+						if (err.message === "Request failed with status code 404") {
+							return reject(new Error("Playlist not found. Is the playlist public/unlisted?"));
+						}
+						return reject(new Error("An error has occured. Please try again later."));
+					});
+			});
+		});
+	}
+
+	/**
+	 * Filters a list of YouTube videos so that they only contains videos with music. Is used internally by GET_PLAYLIST
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.videoIds - an array of YouTube videoIds to filter through
+	 * @param {Array} payload.page - the current page/set of video's to get, starting at 0. If left null, 0 is assumed. Will recurse.
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	FILTER_MUSIC_VIDEOS(payload) {
+		return new Promise((resolve, reject) => {
+			const page = payload.page ? payload.page : 0;
+
+			const videosPerPage = 50;
+
+			const localVideoIds = payload.videoIds.splice(page * 50, videosPerPage);
+
+			if (localVideoIds.length === 0) {
+				return resolve({ videoIds: [] });
+			}
+
+			const params = {
+				part: "topicDetails",
+				id: localVideoIds.join(","),
+				key: config.get("apis.youtube.key"),
+				maxResults: videosPerPage
+			};
+
+			return YouTubeModule.rateLimiter.continue().then(() => {
+				YouTubeModule.rateLimiter.restart();
+				YouTubeModule.axios
+					.get("https://www.googleapis.com/youtube/v3/videos", {
+						params,
+						timeout: YouTubeModule.requestTimeout,
+						raxConfig: {
+							onRetryAttempt: err => {
+								const cfg = rax.getConfig(err);
+								YouTubeModule.log(
+									"ERROR",
+									"FILTER_MUSIC_VIDEOS",
+									`Attempt #${cfg.currentRetryAttempt}. Error: ${err.message}`
+								);
+							}
+						}
+					})
+					.then(res => {
+						if (res.data.err) {
+							YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${res.data.error.message}`);
+							return reject(new Error("An error has occured. Please try again later."));
+						}
+
+						const youtubeIds = [];
+
+						res.data.items.forEach(item => {
+							const youtubeId = item.id;
+
+							if (!item.topicDetails) return;
+							if (item.topicDetails.topicCategories.indexOf("https://en.wikipedia.org/wiki/Music") !== -1)
+								youtubeIds.push(youtubeId);
+						});
+
+						return YouTubeModule.runJob(
+							"FILTER_MUSIC_VIDEOS",
+							{ videoIds: payload.videoIds, page: page + 1 },
+							this
+						)
+							.then(result => resolve({ youtubeIds: youtubeIds.concat(result.youtubeIds) }))
+							.catch(err => reject(err));
+					})
+					.catch(err => {
+						YouTubeModule.log("ERROR", "FILTER_MUSIC_VIDEOS", `${err.message}`);
+						return reject(new Error("Failed to find playlist from YouTube"));
+					});
+			});
+		});
+	}
+}
+
+export default new _YouTubeModule();

+ 6773 - 0
backend/package-lock.json

@@ -0,0 +1,6773 @@
+{
+  "name": "musare-backend",
+  "version": "3.0.0",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "musare-backend",
+      "version": "3.0.0",
+      "license": "GPL-3.0",
+      "dependencies": {
+        "async": "^3.2.1",
+        "axios": "^0.22.0",
+        "bcrypt": "^5.0.1",
+        "bluebird": "^3.7.2",
+        "body-parser": "^1.19.0",
+        "config": "^3.3.6",
+        "cookie-parser": "^1.4.5",
+        "cors": "^2.8.5",
+        "express": "^4.17.1",
+        "moment": "^2.29.1",
+        "mongoose": "^6.0.10",
+        "nodemailer": "^6.7.0",
+        "oauth": "^0.9.15",
+        "redis": "^3.1.2",
+        "retry-axios": "^2.6.0",
+        "sha256": "^0.2.0",
+        "underscore": "^1.13.1",
+        "ws": "^8.2.3"
+      },
+      "devDependencies": {
+        "eslint": "^7.32.0",
+        "eslint-config-airbnb-base": "^14.2.1",
+        "eslint-config-prettier": "^8.3.0",
+        "eslint-plugin-import": "^2.24.2",
+        "eslint-plugin-jsdoc": "^36.1.1",
+        "eslint-plugin-prettier": "^4.0.0",
+        "prettier": "2.4.1",
+        "trace-unhandled": "^2.0.1"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.12.11",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
+      "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/highlight": "^7.10.4"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.14.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz",
+      "integrity": "sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A==",
+      "dev": true
+    },
+    "node_modules/@babel/highlight": {
+      "version": "7.14.0",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.0.tgz",
+      "integrity": "sha512-YSCOwxvTYEIMSGaBQb5kDDsCopDdiUGsqpatp3fOlI4+2HQSkTmEVWnVuySdAC5EWCqSWWTv0ib63RjR7dTBdg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.14.0",
+        "chalk": "^2.0.0",
+        "js-tokens": "^4.0.0"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+      "dev": true
+    },
+    "node_modules/@babel/highlight/node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@es-joy/jsdoccomment": {
+      "version": "0.10.8",
+      "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.10.8.tgz",
+      "integrity": "sha512-3P1JiGL4xaR9PoTKUHa2N/LKwa2/eUdRqGwijMWWgBqbFEqJUVpmaOi2TcjcemrsRMgFLBzQCK4ToPhrSVDiFQ==",
+      "dev": true,
+      "dependencies": {
+        "comment-parser": "1.2.4",
+        "esquery": "^1.4.0",
+        "jsdoc-type-pratt-parser": "1.1.1"
+      },
+      "engines": {
+        "node": "^12 || ^14 || ^16"
+      }
+    },
+    "node_modules/@es-joy/jsdoccomment/node_modules/jsdoc-type-pratt-parser": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-1.1.1.tgz",
+      "integrity": "sha512-uelRmpghNwPBuZScwgBG/OzodaFk5RbO5xaivBdsAY70icWfShwZ7PCMO0x1zSkOa8T1FzHThmrdoyg/0AwV5g==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz",
+      "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.1.1",
+        "espree": "^7.3.0",
+        "globals": "^13.9.0",
+        "ignore": "^4.0.6",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^3.13.1",
+        "minimatch": "^3.0.4",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/debug": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/@humanwhocodes/config-array": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz",
+      "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==",
+      "dev": true,
+      "dependencies": {
+        "@humanwhocodes/object-schema": "^1.2.0",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.4"
+      },
+      "engines": {
+        "node": ">=10.10.0"
+      }
+    },
+    "node_modules/@humanwhocodes/config-array/node_modules/debug": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@humanwhocodes/config-array/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/@humanwhocodes/object-schema": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz",
+      "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==",
+      "dev": true
+    },
+    "node_modules/@mapbox/node-pre-gyp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.4.tgz",
+      "integrity": "sha512-M669Qo4nRT7iDmQEjQYC7RU8Z6dpz9UmSbkJ1OFEja3uevCdLKh7IZZki7L1TZj02kRyl82snXFY8QqkyfowrQ==",
+      "dependencies": {
+        "detect-libc": "^1.0.3",
+        "https-proxy-agent": "^5.0.0",
+        "make-dir": "^3.1.0",
+        "node-fetch": "^2.6.1",
+        "nopt": "^5.0.0",
+        "npmlog": "^4.1.2",
+        "rimraf": "^3.0.2",
+        "semver": "^7.3.4",
+        "tar": "^6.1.0"
+      },
+      "bin": {
+        "node-pre-gyp": "bin/node-pre-gyp"
+      }
+    },
+    "node_modules/@types/json5": {
+      "version": "0.0.29",
+      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+      "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
+      "dev": true
+    },
+    "node_modules/@types/node": {
+      "version": "16.10.3",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.3.tgz",
+      "integrity": "sha512-ho3Ruq+fFnBrZhUYI46n/bV2GjwzSkwuT4dTf0GkuNFmnb8nq4ny2z9JEVemFi6bdEJanHLlYfy9c6FN9B9McQ=="
+    },
+    "node_modules/@types/webidl-conversions": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz",
+      "integrity": "sha512-XAahCdThVuCFDQLT7R7Pk/vqeObFNL3YqRyFZg+AqAP/W1/w3xHaIxuW7WszQqTbIBOPRcItYJIou3i/mppu3Q=="
+    },
+    "node_modules/@types/whatwg-url": {
+      "version": "8.2.1",
+      "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.1.tgz",
+      "integrity": "sha512-2YubE1sjj5ifxievI5Ge1sckb9k/Er66HyR2c+3+I6VDUUg1TLPdYYTEbQ+DjRkS4nTxMJhgWfSfMRD2sl2EYQ==",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/webidl-conversions": "*"
+      }
+    },
+    "node_modules/abbrev": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+      "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
+    },
+    "node_modules/accepts": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
+      "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
+      "dependencies": {
+        "mime-types": "~2.1.24",
+        "negotiator": "0.6.2"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/acorn": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/agent-base/node_modules/debug": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+      "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/agent-base/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/aproba": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+      "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
+    },
+    "node_modules/are-we-there-yet": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz",
+      "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
+      "dependencies": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^2.0.6"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "dev": true,
+      "dependencies": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
+    },
+    "node_modules/array-includes": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz",
+      "integrity": "sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.18.0-next.2",
+        "get-intrinsic": "^1.1.1",
+        "is-string": "^1.0.5"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.flat": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz",
+      "integrity": "sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.18.0-next.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/astral-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/async": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.1.tgz",
+      "integrity": "sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg=="
+    },
+    "node_modules/axios": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.22.0.tgz",
+      "integrity": "sha512-Z0U3uhqQeg1oNcihswf4ZD57O3NrR1+ZXhxaROaWpDmsDTx7T2HNBV2ulBtie2hwJptu8UvgnJoK+BIqdzh/1w==",
+      "dependencies": {
+        "follow-redirects": "^1.14.4"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/bcrypt": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.1.tgz",
+      "integrity": "sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "@mapbox/node-pre-gyp": "^1.0.0",
+        "node-addon-api": "^3.1.0"
+      },
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/bluebird": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
+    },
+    "node_modules/body-parser": {
+      "version": "1.19.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
+      "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
+      "dependencies": {
+        "bytes": "3.1.0",
+        "content-type": "~1.0.4",
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "http-errors": "1.7.2",
+        "iconv-lite": "0.4.24",
+        "on-finished": "~2.3.0",
+        "qs": "6.7.0",
+        "raw-body": "2.4.0",
+        "type-is": "~1.6.17"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/bson": {
+      "version": "4.5.3",
+      "resolved": "https://registry.npmjs.org/bson/-/bson-4.5.3.tgz",
+      "integrity": "sha512-qVX7LX79Mtj7B3NPLzCfBiCP6RAsjiV8N63DjlaVVpZW+PFoDTxQ4SeDbSpcqgE6mXksM5CAwZnXxxxn/XwC0g==",
+      "dependencies": {
+        "buffer": "^5.6.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+      "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
+      "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chownr": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+      "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/code-point-at": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+      "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/comment-parser": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.2.4.tgz",
+      "integrity": "sha512-pm0b+qv+CkWNriSTMsfnjChF9kH0kxz55y44Wo5le9qLxMj5xDQAaEd9ZN1ovSuk9CsrncWaFwgpOMg7ClJwkw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 12.0.0"
+      }
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+    },
+    "node_modules/config": {
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/config/-/config-3.3.6.tgz",
+      "integrity": "sha512-Hj5916C5HFawjYJat1epbyY2PlAgLpBtDUlr0MxGLgo3p5+7kylyvnRY18PqJHgnNWXcdd0eWDemT7eYWuFgwg==",
+      "dependencies": {
+        "json5": "^2.1.1"
+      },
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/confusing-browser-globals": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz",
+      "integrity": "sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==",
+      "dev": true
+    },
+    "node_modules/console-control-strings": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
+      "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
+      "dependencies": {
+        "safe-buffer": "5.1.2"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+      "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/convert-hex": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/convert-hex/-/convert-hex-0.1.0.tgz",
+      "integrity": "sha1-CMBFaJIsJ3drii6BqV05M2LqC2U="
+    },
+    "node_modules/convert-string": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/convert-string/-/convert-string-0.1.0.tgz",
+      "integrity": "sha1-ec5BqbsNA7z3LNxqjzxW+7xkQQo="
+    },
+    "node_modules/cookie": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
+      "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-parser": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz",
+      "integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==",
+      "dependencies": {
+        "cookie": "0.4.0",
+        "cookie-signature": "1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+    },
+    "node_modules/cors": {
+      "version": "2.8.5",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+      "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
+      "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",
+      "dev": true
+    },
+    "node_modules/define-properties": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+      "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+      "dev": true,
+      "dependencies": {
+        "object-keys": "^1.0.12"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
+    },
+    "node_modules/denque": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz",
+      "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==",
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
+      "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
+    },
+    "node_modules/detect-libc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+      "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=",
+      "bin": {
+        "detect-libc": "bin/detect-libc.js"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "node_modules/encodeurl": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+      "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/enquirer": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+      "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-colors": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/error-ex": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+      "dev": true,
+      "dependencies": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
+    "node_modules/es-abstract": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
+      "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "es-to-primitive": "^1.2.1",
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.1.1",
+        "get-symbol-description": "^1.0.0",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.2",
+        "internal-slot": "^1.0.3",
+        "is-callable": "^1.2.4",
+        "is-negative-zero": "^2.0.1",
+        "is-regex": "^1.1.4",
+        "is-shared-array-buffer": "^1.0.1",
+        "is-string": "^1.0.7",
+        "is-weakref": "^1.0.1",
+        "object-inspect": "^1.11.0",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.2",
+        "string.prototype.trimend": "^1.0.4",
+        "string.prototype.trimstart": "^1.0.4",
+        "unbox-primitive": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/es-to-primitive": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+      "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+      "dev": true,
+      "dependencies": {
+        "is-callable": "^1.1.4",
+        "is-date-object": "^1.0.1",
+        "is-symbol": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "7.32.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz",
+      "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "7.12.11",
+        "@eslint/eslintrc": "^0.4.3",
+        "@humanwhocodes/config-array": "^0.5.0",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.0.1",
+        "doctrine": "^3.0.0",
+        "enquirer": "^2.3.5",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^5.1.1",
+        "eslint-utils": "^2.1.0",
+        "eslint-visitor-keys": "^2.0.0",
+        "espree": "^7.3.1",
+        "esquery": "^1.4.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "functional-red-black-tree": "^1.0.1",
+        "glob-parent": "^5.1.2",
+        "globals": "^13.6.0",
+        "ignore": "^4.0.6",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "js-yaml": "^3.13.1",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.0.4",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "progress": "^2.0.0",
+        "regexpp": "^3.1.0",
+        "semver": "^7.2.1",
+        "strip-ansi": "^6.0.0",
+        "strip-json-comments": "^3.1.0",
+        "table": "^6.0.9",
+        "text-table": "^0.2.0",
+        "v8-compile-cache": "^2.0.3"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-config-airbnb-base": {
+      "version": "14.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz",
+      "integrity": "sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA==",
+      "dev": true,
+      "dependencies": {
+        "confusing-browser-globals": "^1.0.10",
+        "object.assign": "^4.1.2",
+        "object.entries": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 6"
+      },
+      "peerDependencies": {
+        "eslint": "^5.16.0 || ^6.8.0 || ^7.2.0",
+        "eslint-plugin-import": "^2.22.1"
+      }
+    },
+    "node_modules/eslint-config-prettier": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz",
+      "integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==",
+      "dev": true,
+      "bin": {
+        "eslint-config-prettier": "bin/cli.js"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.0.0"
+      }
+    },
+    "node_modules/eslint-import-resolver-node": {
+      "version": "0.3.6",
+      "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
+      "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^3.2.7",
+        "resolve": "^1.20.0"
+      }
+    },
+    "node_modules/eslint-import-resolver-node/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/eslint-import-resolver-node/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true
+    },
+    "node_modules/eslint-module-utils": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.2.tgz",
+      "integrity": "sha512-QG8pcgThYOuqxupd06oYTZoNOGaUdTY1PqK+oS6ElF6vs4pBdk/aYxFVQQXzcrAqp9m7cl7lb2ubazX+g16k2Q==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^3.2.7",
+        "pkg-dir": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true
+    },
+    "node_modules/eslint-plugin-import": {
+      "version": "2.24.2",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.24.2.tgz",
+      "integrity": "sha512-hNVtyhiEtZmpsabL4neEj+6M5DCLgpYyG9nzJY8lZQeQXEn5UPW1DpUdsMHMXsq98dbNm7nt1w9ZMSVpfJdi8Q==",
+      "dev": true,
+      "dependencies": {
+        "array-includes": "^3.1.3",
+        "array.prototype.flat": "^1.2.4",
+        "debug": "^2.6.9",
+        "doctrine": "^2.1.0",
+        "eslint-import-resolver-node": "^0.3.6",
+        "eslint-module-utils": "^2.6.2",
+        "find-up": "^2.0.0",
+        "has": "^1.0.3",
+        "is-core-module": "^2.6.0",
+        "minimatch": "^3.0.4",
+        "object.values": "^1.1.4",
+        "pkg-up": "^2.0.0",
+        "read-pkg-up": "^3.0.0",
+        "resolve": "^1.20.0",
+        "tsconfig-paths": "^3.11.0"
+      },
+      "engines": {
+        "node": ">=4"
+      },
+      "peerDependencies": {
+        "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0"
+      }
+    },
+    "node_modules/eslint-plugin-import/node_modules/doctrine": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+      "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/eslint-plugin-jsdoc": {
+      "version": "36.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-36.1.1.tgz",
+      "integrity": "sha512-nuLDvH1EJaKx0PCa9oeQIxH6pACIhZd1gkalTUxZbaxxwokjs7TplqY0Q8Ew3CoZaf5aowm0g/Z3JGHCatt+gQ==",
+      "dev": true,
+      "dependencies": {
+        "@es-joy/jsdoccomment": "0.10.8",
+        "comment-parser": "1.2.4",
+        "debug": "^4.3.2",
+        "esquery": "^1.4.0",
+        "jsdoc-type-pratt-parser": "^1.1.1",
+        "lodash": "^4.17.21",
+        "regextras": "^0.8.0",
+        "semver": "^7.3.5",
+        "spdx-expression-parse": "^3.0.1"
+      },
+      "engines": {
+        "node": "^12 || ^14 || ^16"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-jsdoc/node_modules/debug": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-jsdoc/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/eslint-plugin-prettier": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz",
+      "integrity": "sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==",
+      "dev": true,
+      "dependencies": {
+        "prettier-linter-helpers": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.28.0",
+        "prettier": ">=2.0.0"
+      },
+      "peerDependenciesMeta": {
+        "eslint-config-prettier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/eslint-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz",
+      "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      }
+    },
+    "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+      "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+      "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/eslint/node_modules/debug": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+      "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint/node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "node_modules/espree": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz",
+      "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^7.4.0",
+        "acorn-jsx": "^5.3.1",
+        "eslint-visitor-keys": "^1.3.0"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/espree/node_modules/eslint-visitor-keys": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+      "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true,
+      "bin": {
+        "esparse": "bin/esparse.js",
+        "esvalidate": "bin/esvalidate.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+      "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esquery/node_modules/estraverse": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+      "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esrecurse/node_modules/estraverse": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+      "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.17.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
+      "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
+      "dependencies": {
+        "accepts": "~1.3.7",
+        "array-flatten": "1.1.1",
+        "body-parser": "1.19.0",
+        "content-disposition": "0.5.3",
+        "content-type": "~1.0.4",
+        "cookie": "0.4.0",
+        "cookie-signature": "1.0.6",
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.1.2",
+        "fresh": "0.5.2",
+        "merge-descriptors": "1.0.1",
+        "methods": "~1.1.2",
+        "on-finished": "~2.3.0",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "0.1.7",
+        "proxy-addr": "~2.0.5",
+        "qs": "6.7.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.1.2",
+        "send": "0.17.1",
+        "serve-static": "1.14.1",
+        "setprototypeof": "1.1.1",
+        "statuses": "~1.5.0",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "node_modules/fast-diff": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
+      "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
+      "dev": true
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+      "dev": true
+    },
+    "node_modules/file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^3.0.4"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+      "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.3.0",
+        "parseurl": "~1.3.3",
+        "statuses": "~1.5.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
+      "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz",
+      "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==",
+      "dev": true
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.14.4",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
+      "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/foreground-child": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+      "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^7.0.0",
+        "signal-exit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
+      "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fs-minipass": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+      "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+      "dependencies": {
+        "minipass": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "node_modules/functional-red-black-tree": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+      "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+      "dev": true
+    },
+    "node_modules/gauge": {
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+      "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
+      "dependencies": {
+        "aproba": "^1.0.3",
+        "console-control-strings": "^1.0.0",
+        "has-unicode": "^2.0.0",
+        "object-assign": "^4.1.0",
+        "signal-exit": "^3.0.0",
+        "string-width": "^1.0.1",
+        "strip-ansi": "^3.0.1",
+        "wide-align": "^1.1.0"
+      }
+    },
+    "node_modules/gauge/node_modules/ansi-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+      "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/gauge/node_modules/strip-ansi": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+      "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+      "dependencies": {
+        "ansi-regex": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+      "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-symbol-description": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+      "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "get-intrinsic": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob": {
+      "version": "7.1.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+      "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/globals": {
+      "version": "13.11.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.11.0.tgz",
+      "integrity": "sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
+      "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==",
+      "dev": true
+    },
+    "node_modules/has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/has-bigints": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+      "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+      "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+      "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+      "dev": true,
+      "dependencies": {
+        "has-symbols": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
+    },
+    "node_modules/haxec": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/haxec/-/haxec-2.0.1.tgz",
+      "integrity": "sha512-2DaSqGZIzgVkZ4YFHbk9Su0Q6gm7YbzNX9njOHK/D/XklOdvgTemsPmjcyExlLdkl7lRlNIW0Wxo6niVfpWedw==",
+      "dev": true,
+      "dependencies": {
+        "foreground-child": "^2.0.0",
+        "spawn-wrap": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/hosted-git-info": {
+      "version": "2.8.9",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+      "dev": true
+    },
+    "node_modules/http-errors": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
+      "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
+      "dependencies": {
+        "depd": "~1.1.2",
+        "inherits": "2.0.3",
+        "setprototypeof": "1.1.1",
+        "statuses": ">= 1.5.0 < 2",
+        "toidentifier": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
+      "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/https-proxy-agent/node_modules/debug": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+      "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/https-proxy-agent/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/ignore": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+      "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+      "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+    },
+    "node_modules/internal-slot": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+      "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+      "dev": true,
+      "dependencies": {
+        "get-intrinsic": "^1.1.0",
+        "has": "^1.0.3",
+        "side-channel": "^1.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=",
+      "dev": true
+    },
+    "node_modules/is-bigint": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.2.tgz",
+      "integrity": "sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-boolean-object": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.0.tgz",
+      "integrity": "sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-callable": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+      "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.7.0.tgz",
+      "integrity": "sha512-ByY+tjCciCr+9nLryBYcSD50EOGWt95c7tIsKTG1J2ixKKXPvF7Ej3AVd+UfDydAJom3biBGDBALaO79ktwgEQ==",
+      "dev": true,
+      "dependencies": {
+        "has": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-date-object": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.3.tgz",
+      "integrity": "sha512-tDpEUInNcy2Yw3lNSepK3Wdw1RnXLcIVienz6Ou631Acl15cJyRWK4dgA1vCmOEgIbtOV0W7MHg+AR2Gdg1NXQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+      "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+      "dependencies": {
+        "number-is-nan": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-negative-zero": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz",
+      "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-number-object": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz",
+      "integrity": "sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-regex": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+      "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-shared-array-buffer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
+      "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-string": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+      "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+      "dev": true,
+      "dependencies": {
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-symbol": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
+      "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
+      "dev": true,
+      "dependencies": {
+        "has-symbols": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-weakref": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.1.tgz",
+      "integrity": "sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-windows": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+      "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+      "dev": true
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true
+    },
+    "node_modules/js-yaml": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+      "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^1.0.7",
+        "esprima": "^4.0.0"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/jsdoc-type-pratt-parser": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-1.2.0.tgz",
+      "integrity": "sha512-4STjeF14jp4bqha44nKMY1OUI6d2/g6uclHWUCZ7B4DoLzaB5bmpTkQrpqU+vSVzMD0LsKAOskcnI3I3VfIpmg==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/json-parse-better-errors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+      "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+      "dev": true
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+      "dev": true
+    },
+    "node_modules/json5": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
+      "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
+      "dependencies": {
+        "minimist": "^1.2.5"
+      },
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/kareem": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.2.tgz",
+      "integrity": "sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ=="
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/load-json-file": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
+      "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=",
+      "dev": true,
+      "dependencies": {
+        "graceful-fs": "^4.1.2",
+        "parse-json": "^4.0.0",
+        "pify": "^3.0.0",
+        "strip-bom": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
+      "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^2.0.0",
+        "path-exists": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "node_modules/lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=",
+      "dev": true
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "node_modules/lodash.truncate": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+      "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+      "dev": true
+    },
+    "node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+      "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+      "dependencies": {
+        "semver": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/make-dir/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/memory-pager": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+      "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+      "optional": true
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+      "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.47.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.47.0.tgz",
+      "integrity": "sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.30",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.30.tgz",
+      "integrity": "sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==",
+      "dependencies": {
+        "mime-db": "1.47.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+    },
+    "node_modules/minipass": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz",
+      "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==",
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/minizlib": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+      "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+      "dependencies": {
+        "minipass": "^3.0.0",
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+      "bin": {
+        "mkdirp": "bin/cmd.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/moment": {
+      "version": "2.29.1",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
+      "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/mongodb": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.1.2.tgz",
+      "integrity": "sha512-pHCKDoOy1h6mVurziJmXmTMPatYWOx8pbnyFgSgshja9Y36Q+caHUzTDY6rrIy9HCSrjnbXmx3pCtvNZHmR8xg==",
+      "dependencies": {
+        "bson": "^4.5.2",
+        "denque": "^2.0.1",
+        "mongodb-connection-string-url": "^2.0.0",
+        "saslprep": "^1.0.3"
+      },
+      "engines": {
+        "node": ">=12.9.0"
+      },
+      "optionalDependencies": {
+        "saslprep": "^1.0.3"
+      }
+    },
+    "node_modules/mongodb-connection-string-url": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.1.0.tgz",
+      "integrity": "sha512-Qf9Zw7KGiRljWvMrrUFDdVqo46KIEiDuCzvEN97rh/PcKzk2bd6n9KuzEwBwW9xo5glwx69y1mI6s+jFUD/aIQ==",
+      "dependencies": {
+        "@types/whatwg-url": "^8.2.1",
+        "whatwg-url": "^9.1.0"
+      }
+    },
+    "node_modules/mongodb/node_modules/denque": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz",
+      "integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ==",
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/mongoose": {
+      "version": "6.0.10",
+      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.0.10.tgz",
+      "integrity": "sha512-p/wiEDUXoQuyb/xQx8QW/YGN92ZsojJ5E/DDgMCUU0WOGxc5uhcWoZ7ijLu6Ssjq8UkwVSv+jzkYp4Wbr+NqBg==",
+      "dependencies": {
+        "bson": "^4.2.2",
+        "kareem": "2.3.2",
+        "mongodb": "4.1.2",
+        "mpath": "0.8.4",
+        "mquery": "4.0.0",
+        "ms": "2.1.2",
+        "regexp-clone": "1.0.0",
+        "sift": "13.5.2",
+        "sliced": "1.0.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mongoose"
+      }
+    },
+    "node_modules/mongoose/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "node_modules/mpath": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.8.4.tgz",
+      "integrity": "sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g==",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/mquery": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.0.tgz",
+      "integrity": "sha512-nGjm89lHja+T/b8cybAby6H0YgA4qYC/lx6UlwvHGqvTq8bDaNeCwl1sY8uRELrNbVWJzIihxVd+vphGGn1vBw==",
+      "dependencies": {
+        "debug": "4.x",
+        "regexp-clone": "^1.0.0",
+        "sliced": "1.0.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/mquery/node_modules/debug": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/mquery/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+      "dev": true
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
+      "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-addon-api": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz",
+      "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw=="
+    },
+    "node_modules/node-fetch": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
+      "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      }
+    },
+    "node_modules/nodemailer": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.0.tgz",
+      "integrity": "sha512-AtiTVUFHLiiDnMQ43zi0YgkzHOEWUkhDgPlBXrsDzJiJvB29Alo4OKxHQ0ugF3gRqRQIneCLtZU3yiUo7pItZw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/nopt": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+      "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+      "dependencies": {
+        "abbrev": "1"
+      },
+      "bin": {
+        "nopt": "bin/nopt.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/normalize-package-data": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+      "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+      "dev": true,
+      "dependencies": {
+        "hosted-git-info": "^2.1.4",
+        "resolve": "^1.10.0",
+        "semver": "2 || 3 || 4 || 5",
+        "validate-npm-package-license": "^3.0.1"
+      }
+    },
+    "node_modules/normalize-package-data/node_modules/semver": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
+    "node_modules/npmlog": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+      "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+      "dependencies": {
+        "are-we-there-yet": "~1.1.2",
+        "console-control-strings": "~1.1.0",
+        "gauge": "~2.7.3",
+        "set-blocking": "~2.0.0"
+      }
+    },
+    "node_modules/number-is-nan": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+      "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/oauth": {
+      "version": "0.9.15",
+      "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
+      "integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE="
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz",
+      "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.assign": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+      "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "has-symbols": "^1.0.1",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object.entries": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.3.tgz",
+      "integrity": "sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.18.0-next.1",
+        "has": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.values": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+      "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+      "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+      "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+      "dev": true,
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.3"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
+      "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
+      "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
+      "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parse-json": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
+      "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
+      "dev": true,
+      "dependencies": {
+        "error-ex": "^1.3.1",
+        "json-parse-better-errors": "^1.0.1"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+      "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+      "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
+    },
+    "node_modules/path-type": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
+      "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==",
+      "dev": true,
+      "dependencies": {
+        "pify": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pify": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+      "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pkg-dir": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz",
+      "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=",
+      "dev": true,
+      "dependencies": {
+        "find-up": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pkg-up": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-2.0.0.tgz",
+      "integrity": "sha1-yBmscoBZpGHKscOImivjxJoATX8=",
+      "dev": true,
+      "dependencies": {
+        "find-up": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
+      "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
+      "dev": true,
+      "bin": {
+        "prettier": "bin-prettier.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/prettier-linter-helpers": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+      "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+      "dev": true,
+      "dependencies": {
+        "fast-diff": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+    },
+    "node_modules/progress": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
+      "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==",
+      "dependencies": {
+        "forwarded": "~0.1.2",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
+      "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
+      "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
+      "dependencies": {
+        "bytes": "3.1.0",
+        "http-errors": "1.7.2",
+        "iconv-lite": "0.4.24",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/read-pkg": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
+      "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=",
+      "dev": true,
+      "dependencies": {
+        "load-json-file": "^4.0.0",
+        "normalize-package-data": "^2.3.2",
+        "path-type": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/read-pkg-up": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz",
+      "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=",
+      "dev": true,
+      "dependencies": {
+        "find-up": "^2.0.0",
+        "read-pkg": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "node_modules/redis": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
+      "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
+      "dependencies": {
+        "denque": "^1.5.0",
+        "redis-commands": "^1.7.0",
+        "redis-errors": "^1.2.0",
+        "redis-parser": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/node-redis"
+      }
+    },
+    "node_modules/redis-commands": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
+      "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
+    },
+    "node_modules/redis-errors": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+      "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/redis-parser": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+      "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
+      "dependencies": {
+        "redis-errors": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/regexp-clone": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz",
+      "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw=="
+    },
+    "node_modules/regexpp": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz",
+      "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      }
+    },
+    "node_modules/regextras": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/regextras/-/regextras-0.8.0.tgz",
+      "integrity": "sha512-k519uI04Z3SaY0fLX843MRXnDeG2+vHOFsyhiPZvNLe7r8rD2YNRjq4BQLZZ0oAr2NrtvZlICsXysGNFPGa3CQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.1.14"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+      "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.2.0",
+        "path-parse": "^1.0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/retry-axios": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/retry-axios/-/retry-axios-2.6.0.tgz",
+      "integrity": "sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==",
+      "engines": {
+        "node": ">=10.7.0"
+      },
+      "peerDependencies": {
+        "axios": "*"
+      }
+    },
+    "node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dependencies": {
+        "glob": "^7.1.3"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+    },
+    "node_modules/saslprep": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
+      "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
+      "optional": true,
+      "dependencies": {
+        "sparse-bitfield": "^3.0.3"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.3.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+      "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "0.17.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
+      "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "destroy": "~1.0.4",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "0.5.2",
+        "http-errors": "~1.7.2",
+        "mime": "1.6.0",
+        "ms": "2.1.1",
+        "on-finished": "~2.3.0",
+        "range-parser": "~1.2.1",
+        "statuses": "~1.5.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+      "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
+    },
+    "node_modules/serve-static": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
+      "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
+      "dependencies": {
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "0.17.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
+      "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
+    },
+    "node_modules/sha256": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/sha256/-/sha256-0.2.0.tgz",
+      "integrity": "sha1-c6C0GNqrcDW/+G6EkeNjQS/CqwU=",
+      "dependencies": {
+        "convert-hex": "~0.1.0",
+        "convert-string": "~0.1.0"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "get-intrinsic": "^1.0.2",
+        "object-inspect": "^1.9.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/sift": {
+      "version": "13.5.2",
+      "resolved": "https://registry.npmjs.org/sift/-/sift-13.5.2.tgz",
+      "integrity": "sha512-+gxdEOMA2J+AI+fVsCqeNn7Tgx3M9ZN9jdi95939l1IJ8cZsqS8sqpJyOkic2SJk+1+98Uwryt/gL6XDaV+UZA=="
+    },
+    "node_modules/signal-exit": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
+      "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA=="
+    },
+    "node_modules/slice-ansi": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+      }
+    },
+    "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/sliced": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz",
+      "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E="
+    },
+    "node_modules/sparse-bitfield": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+      "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=",
+      "optional": true,
+      "dependencies": {
+        "memory-pager": "^1.0.2"
+      }
+    },
+    "node_modules/spawn-wrap": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz",
+      "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==",
+      "dev": true,
+      "dependencies": {
+        "foreground-child": "^2.0.0",
+        "is-windows": "^1.0.2",
+        "make-dir": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "signal-exit": "^3.0.2",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/spdx-correct": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+      "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+      "dev": true,
+      "dependencies": {
+        "spdx-expression-parse": "^3.0.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "node_modules/spdx-exceptions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+      "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+      "dev": true
+    },
+    "node_modules/spdx-expression-parse": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+      "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+      "dev": true,
+      "dependencies": {
+        "spdx-exceptions": "^2.1.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "node_modules/spdx-license-ids": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz",
+      "integrity": "sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==",
+      "dev": true
+    },
+    "node_modules/sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+      "dev": true
+    },
+    "node_modules/statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+      "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+      "dependencies": {
+        "code-point-at": "^1.0.0",
+        "is-fullwidth-code-point": "^1.0.0",
+        "strip-ansi": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string-width/node_modules/ansi-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+      "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string-width/node_modules/strip-ansi": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+      "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+      "dependencies": {
+        "ansi-regex": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string.prototype.trimend": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+      "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimstart": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+      "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+      "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/table": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.7.0.tgz",
+      "integrity": "sha512-SAM+5p6V99gYiiy2gT5ArdzgM1dLDed0nkrWmG6Fry/bUS/m9x83BwpJUOf1Qj/x2qJd+thL6IkIx7qPGRxqBw==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^8.0.1",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.truncate": "^4.4.2",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/table/node_modules/ajv": {
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.2.0.tgz",
+      "integrity": "sha512-WSNGFuyWd//XO8n/m/EaOlNLtO0yL8EXT/74LqT4khdhpZjP7lkj/kT5uwRmGitKEVp/Oj7ZUHeGfPtgHhQ5CA==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/table/node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/table/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true
+    },
+    "node_modules/table/node_modules/string-width": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
+      "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==",
+      "dev": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/tar": {
+      "version": "6.1.11",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
+      "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
+      "dependencies": {
+        "chownr": "^2.0.0",
+        "fs-minipass": "^2.0.0",
+        "minipass": "^3.0.0",
+        "minizlib": "^2.1.1",
+        "mkdirp": "^1.0.3",
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+      "dev": true
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
+      "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz",
+      "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==",
+      "dependencies": {
+        "punycode": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/trace-unhandled": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/trace-unhandled/-/trace-unhandled-2.0.1.tgz",
+      "integrity": "sha512-wOZbhBiNyuZTs0b/ADZFTiTDVVDsvKQj/RkVJTKefH6u9CowGDSR+H/3miaGUrYCCuzS0nVmIzpbIIm6lRF8gg==",
+      "dev": true,
+      "dependencies": {
+        "haxec": "^2.0.1"
+      },
+      "bin": {
+        "trace-unhandled": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/tsconfig-paths": {
+      "version": "3.11.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.11.0.tgz",
+      "integrity": "sha512-7ecdYDnIdmv639mmDwslG6KQg1Z9STTz1j7Gcz0xa+nshh/gKDAHcPxRbWOsA3SPp0tXP2leTcY9Kw+NAkfZzA==",
+      "dev": true,
+      "dependencies": {
+        "@types/json5": "^0.0.29",
+        "json5": "^1.0.1",
+        "minimist": "^1.2.0",
+        "strip-bom": "^3.0.0"
+      }
+    },
+    "node_modules/tsconfig-paths/node_modules/json5": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+      "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+      "dev": true,
+      "dependencies": {
+        "minimist": "^1.2.0"
+      },
+      "bin": {
+        "json5": "lib/cli.js"
+      }
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/unbox-primitive": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+      "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "has-bigints": "^1.0.1",
+        "has-symbols": "^1.0.2",
+        "which-boxed-primitive": "^1.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/underscore": {
+      "version": "1.13.1",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
+      "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/v8-compile-cache": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+      "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+      "dev": true
+    },
+    "node_modules/validate-npm-package-license": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+      "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+      "dev": true,
+      "dependencies": {
+        "spdx-correct": "^3.0.0",
+        "spdx-expression-parse": "^3.0.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
+      "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==",
+      "engines": {
+        "node": ">=10.4"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-9.1.0.tgz",
+      "integrity": "sha512-CQ0UcrPHyomtlOCot1TL77WyMIm/bCwrJ2D6AOKGwEczU9EpyoqAokfqrf/MioU9kHcMsmJZcg1egXix2KYEsA==",
+      "dependencies": {
+        "tr46": "^2.1.0",
+        "webidl-conversions": "^6.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/which-boxed-primitive": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+      "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+      "dev": true,
+      "dependencies": {
+        "is-bigint": "^1.0.1",
+        "is-boolean-object": "^1.1.0",
+        "is-number-object": "^1.0.4",
+        "is-string": "^1.0.5",
+        "is-symbol": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/wide-align": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
+      "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
+      "dependencies": {
+        "string-width": "^1.0.2 || 2"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    },
+    "node_modules/ws": {
+      "version": "8.2.3",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
+      "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": "^5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+    }
+  },
+  "dependencies": {
+    "@babel/code-frame": {
+      "version": "7.12.11",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
+      "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
+      "dev": true,
+      "requires": {
+        "@babel/highlight": "^7.10.4"
+      }
+    },
+    "@babel/helper-validator-identifier": {
+      "version": "7.14.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz",
+      "integrity": "sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A==",
+      "dev": true
+    },
+    "@babel/highlight": {
+      "version": "7.14.0",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.0.tgz",
+      "integrity": "sha512-YSCOwxvTYEIMSGaBQb5kDDsCopDdiUGsqpatp3fOlI4+2HQSkTmEVWnVuySdAC5EWCqSWWTv0ib63RjR7dTBdg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.14.0",
+        "chalk": "^2.0.0",
+        "js-tokens": "^4.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+          "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^1.9.0"
+          }
+        },
+        "chalk": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^3.2.1",
+            "escape-string-regexp": "^1.0.5",
+            "supports-color": "^5.3.0"
+          }
+        },
+        "color-convert": {
+          "version": "1.9.3",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+          "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+          "dev": true,
+          "requires": {
+            "color-name": "1.1.3"
+          }
+        },
+        "color-name": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+          "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+          "dev": true
+        },
+        "has-flag": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+          "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
+    "@es-joy/jsdoccomment": {
+      "version": "0.10.8",
+      "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.10.8.tgz",
+      "integrity": "sha512-3P1JiGL4xaR9PoTKUHa2N/LKwa2/eUdRqGwijMWWgBqbFEqJUVpmaOi2TcjcemrsRMgFLBzQCK4ToPhrSVDiFQ==",
+      "dev": true,
+      "requires": {
+        "comment-parser": "1.2.4",
+        "esquery": "^1.4.0",
+        "jsdoc-type-pratt-parser": "1.1.1"
+      },
+      "dependencies": {
+        "jsdoc-type-pratt-parser": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-1.1.1.tgz",
+          "integrity": "sha512-uelRmpghNwPBuZScwgBG/OzodaFk5RbO5xaivBdsAY70icWfShwZ7PCMO0x1zSkOa8T1FzHThmrdoyg/0AwV5g==",
+          "dev": true
+        }
+      }
+    },
+    "@eslint/eslintrc": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz",
+      "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.4",
+        "debug": "^4.1.1",
+        "espree": "^7.3.0",
+        "globals": "^13.9.0",
+        "ignore": "^4.0.6",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^3.13.1",
+        "minimatch": "^3.0.4",
+        "strip-json-comments": "^3.1.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.2",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+          "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+          "dev": true,
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+          "dev": true
+        }
+      }
+    },
+    "@humanwhocodes/config-array": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz",
+      "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==",
+      "dev": true,
+      "requires": {
+        "@humanwhocodes/object-schema": "^1.2.0",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.4"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.2",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+          "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+          "dev": true,
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+          "dev": true
+        }
+      }
+    },
+    "@humanwhocodes/object-schema": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz",
+      "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==",
+      "dev": true
+    },
+    "@mapbox/node-pre-gyp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.4.tgz",
+      "integrity": "sha512-M669Qo4nRT7iDmQEjQYC7RU8Z6dpz9UmSbkJ1OFEja3uevCdLKh7IZZki7L1TZj02kRyl82snXFY8QqkyfowrQ==",
+      "requires": {
+        "detect-libc": "^1.0.3",
+        "https-proxy-agent": "^5.0.0",
+        "make-dir": "^3.1.0",
+        "node-fetch": "^2.6.1",
+        "nopt": "^5.0.0",
+        "npmlog": "^4.1.2",
+        "rimraf": "^3.0.2",
+        "semver": "^7.3.4",
+        "tar": "^6.1.0"
+      }
+    },
+    "@types/json5": {
+      "version": "0.0.29",
+      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+      "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
+      "dev": true
+    },
+    "@types/node": {
+      "version": "16.10.3",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.3.tgz",
+      "integrity": "sha512-ho3Ruq+fFnBrZhUYI46n/bV2GjwzSkwuT4dTf0GkuNFmnb8nq4ny2z9JEVemFi6bdEJanHLlYfy9c6FN9B9McQ=="
+    },
+    "@types/webidl-conversions": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz",
+      "integrity": "sha512-XAahCdThVuCFDQLT7R7Pk/vqeObFNL3YqRyFZg+AqAP/W1/w3xHaIxuW7WszQqTbIBOPRcItYJIou3i/mppu3Q=="
+    },
+    "@types/whatwg-url": {
+      "version": "8.2.1",
+      "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.1.tgz",
+      "integrity": "sha512-2YubE1sjj5ifxievI5Ge1sckb9k/Er66HyR2c+3+I6VDUUg1TLPdYYTEbQ+DjRkS4nTxMJhgWfSfMRD2sl2EYQ==",
+      "requires": {
+        "@types/node": "*",
+        "@types/webidl-conversions": "*"
+      }
+    },
+    "abbrev": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+      "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="
+    },
+    "accepts": {
+      "version": "1.3.7",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
+      "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
+      "requires": {
+        "mime-types": "~2.1.24",
+        "negotiator": "0.6.2"
+      }
+    },
+    "acorn": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+      "dev": true
+    },
+    "acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "requires": {}
+    },
+    "agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "requires": {
+        "debug": "4"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        }
+      }
+    },
+    "ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "requires": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true
+    },
+    "ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true
+    },
+    "ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "requires": {
+        "color-convert": "^2.0.1"
+      }
+    },
+    "aproba": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+      "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
+    },
+    "are-we-there-yet": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz",
+      "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==",
+      "requires": {
+        "delegates": "^1.0.0",
+        "readable-stream": "^2.0.6"
+      }
+    },
+    "argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "dev": true,
+      "requires": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
+    "array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
+    },
+    "array-includes": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz",
+      "integrity": "sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.18.0-next.2",
+        "get-intrinsic": "^1.1.1",
+        "is-string": "^1.0.5"
+      }
+    },
+    "array.prototype.flat": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz",
+      "integrity": "sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.18.0-next.1"
+      }
+    },
+    "astral-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+      "dev": true
+    },
+    "async": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.1.tgz",
+      "integrity": "sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg=="
+    },
+    "axios": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.22.0.tgz",
+      "integrity": "sha512-Z0U3uhqQeg1oNcihswf4ZD57O3NrR1+ZXhxaROaWpDmsDTx7T2HNBV2ulBtie2hwJptu8UvgnJoK+BIqdzh/1w==",
+      "requires": {
+        "follow-redirects": "^1.14.4"
+      }
+    },
+    "balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+    },
+    "base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
+    },
+    "bcrypt": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.1.tgz",
+      "integrity": "sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw==",
+      "requires": {
+        "@mapbox/node-pre-gyp": "^1.0.0",
+        "node-addon-api": "^3.1.0"
+      }
+    },
+    "bluebird": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
+    },
+    "body-parser": {
+      "version": "1.19.0",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
+      "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
+      "requires": {
+        "bytes": "3.1.0",
+        "content-type": "~1.0.4",
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "http-errors": "1.7.2",
+        "iconv-lite": "0.4.24",
+        "on-finished": "~2.3.0",
+        "qs": "6.7.0",
+        "raw-body": "2.4.0",
+        "type-is": "~1.6.17"
+      }
+    },
+    "brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "requires": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "bson": {
+      "version": "4.5.3",
+      "resolved": "https://registry.npmjs.org/bson/-/bson-4.5.3.tgz",
+      "integrity": "sha512-qVX7LX79Mtj7B3NPLzCfBiCP6RAsjiV8N63DjlaVVpZW+PFoDTxQ4SeDbSpcqgE6mXksM5CAwZnXxxxn/XwC0g==",
+      "requires": {
+        "buffer": "^5.6.0"
+      }
+    },
+    "buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "requires": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "bytes": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+      "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
+    },
+    "call-bind": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.0.2"
+      }
+    },
+    "callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true
+    },
+    "chalk": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz",
+      "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      }
+    },
+    "chownr": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+      "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="
+    },
+    "code-point-at": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+      "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
+    },
+    "color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "requires": {
+        "color-name": "~1.1.4"
+      }
+    },
+    "color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "comment-parser": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.2.4.tgz",
+      "integrity": "sha512-pm0b+qv+CkWNriSTMsfnjChF9kH0kxz55y44Wo5le9qLxMj5xDQAaEd9ZN1ovSuk9CsrncWaFwgpOMg7ClJwkw==",
+      "dev": true
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+    },
+    "config": {
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/config/-/config-3.3.6.tgz",
+      "integrity": "sha512-Hj5916C5HFawjYJat1epbyY2PlAgLpBtDUlr0MxGLgo3p5+7kylyvnRY18PqJHgnNWXcdd0eWDemT7eYWuFgwg==",
+      "requires": {
+        "json5": "^2.1.1"
+      }
+    },
+    "confusing-browser-globals": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz",
+      "integrity": "sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==",
+      "dev": true
+    },
+    "console-control-strings": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+      "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
+    },
+    "content-disposition": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
+      "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
+      "requires": {
+        "safe-buffer": "5.1.2"
+      }
+    },
+    "content-type": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz",
+      "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA=="
+    },
+    "convert-hex": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/convert-hex/-/convert-hex-0.1.0.tgz",
+      "integrity": "sha1-CMBFaJIsJ3drii6BqV05M2LqC2U="
+    },
+    "convert-string": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/convert-string/-/convert-string-0.1.0.tgz",
+      "integrity": "sha1-ec5BqbsNA7z3LNxqjzxW+7xkQQo="
+    },
+    "cookie": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
+      "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
+    },
+    "cookie-parser": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz",
+      "integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==",
+      "requires": {
+        "cookie": "0.4.0",
+        "cookie-signature": "1.0.6"
+      }
+    },
+    "cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
+    },
+    "core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+    },
+    "cors": {
+      "version": "2.8.5",
+      "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+      "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+      "requires": {
+        "object-assign": "^4",
+        "vary": "^1"
+      }
+    },
+    "cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "requires": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      }
+    },
+    "debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "requires": {
+        "ms": "2.0.0"
+      }
+    },
+    "deep-is": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
+      "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",
+      "dev": true
+    },
+    "define-properties": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+      "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+      "dev": true,
+      "requires": {
+        "object-keys": "^1.0.12"
+      }
+    },
+    "delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
+    },
+    "denque": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz",
+      "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ=="
+    },
+    "depd": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
+      "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak="
+    },
+    "destroy": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
+      "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
+    },
+    "detect-libc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+      "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
+    },
+    "doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "requires": {
+        "esutils": "^2.0.2"
+      }
+    },
+    "ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
+    },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "encodeurl": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+      "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
+    },
+    "enquirer": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+      "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+      "dev": true,
+      "requires": {
+        "ansi-colors": "^4.1.1"
+      }
+    },
+    "error-ex": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+      "dev": true,
+      "requires": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
+    "es-abstract": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
+      "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "es-to-primitive": "^1.2.1",
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.1.1",
+        "get-symbol-description": "^1.0.0",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.2",
+        "internal-slot": "^1.0.3",
+        "is-callable": "^1.2.4",
+        "is-negative-zero": "^2.0.1",
+        "is-regex": "^1.1.4",
+        "is-shared-array-buffer": "^1.0.1",
+        "is-string": "^1.0.7",
+        "is-weakref": "^1.0.1",
+        "object-inspect": "^1.11.0",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.2",
+        "string.prototype.trimend": "^1.0.4",
+        "string.prototype.trimstart": "^1.0.4",
+        "unbox-primitive": "^1.0.1"
+      }
+    },
+    "es-to-primitive": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+      "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+      "dev": true,
+      "requires": {
+        "is-callable": "^1.1.4",
+        "is-date-object": "^1.0.1",
+        "is-symbol": "^1.0.2"
+      }
+    },
+    "escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
+    },
+    "escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "dev": true
+    },
+    "eslint": {
+      "version": "7.32.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz",
+      "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "7.12.11",
+        "@eslint/eslintrc": "^0.4.3",
+        "@humanwhocodes/config-array": "^0.5.0",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.0.1",
+        "doctrine": "^3.0.0",
+        "enquirer": "^2.3.5",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^5.1.1",
+        "eslint-utils": "^2.1.0",
+        "eslint-visitor-keys": "^2.0.0",
+        "espree": "^7.3.1",
+        "esquery": "^1.4.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "functional-red-black-tree": "^1.0.1",
+        "glob-parent": "^5.1.2",
+        "globals": "^13.6.0",
+        "ignore": "^4.0.6",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "js-yaml": "^3.13.1",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.0.4",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "progress": "^2.0.0",
+        "regexpp": "^3.1.0",
+        "semver": "^7.2.1",
+        "strip-ansi": "^6.0.0",
+        "strip-json-comments": "^3.1.0",
+        "table": "^6.0.9",
+        "text-table": "^0.2.0",
+        "v8-compile-cache": "^2.0.3"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+          "dev": true,
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "escape-string-regexp": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+          "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+          "dev": true
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-config-airbnb-base": {
+      "version": "14.2.1",
+      "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz",
+      "integrity": "sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA==",
+      "dev": true,
+      "requires": {
+        "confusing-browser-globals": "^1.0.10",
+        "object.assign": "^4.1.2",
+        "object.entries": "^1.1.2"
+      }
+    },
+    "eslint-config-prettier": {
+      "version": "8.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz",
+      "integrity": "sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==",
+      "dev": true,
+      "requires": {}
+    },
+    "eslint-import-resolver-node": {
+      "version": "0.3.6",
+      "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
+      "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==",
+      "dev": true,
+      "requires": {
+        "debug": "^3.2.7",
+        "resolve": "^1.20.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "3.2.7",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+          "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+          "dev": true,
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "ms": {
+          "version": "2.1.3",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-module-utils": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.2.tgz",
+      "integrity": "sha512-QG8pcgThYOuqxupd06oYTZoNOGaUdTY1PqK+oS6ElF6vs4pBdk/aYxFVQQXzcrAqp9m7cl7lb2ubazX+g16k2Q==",
+      "dev": true,
+      "requires": {
+        "debug": "^3.2.7",
+        "pkg-dir": "^2.0.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "3.2.7",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+          "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+          "dev": true,
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "ms": {
+          "version": "2.1.3",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-import": {
+      "version": "2.24.2",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.24.2.tgz",
+      "integrity": "sha512-hNVtyhiEtZmpsabL4neEj+6M5DCLgpYyG9nzJY8lZQeQXEn5UPW1DpUdsMHMXsq98dbNm7nt1w9ZMSVpfJdi8Q==",
+      "dev": true,
+      "requires": {
+        "array-includes": "^3.1.3",
+        "array.prototype.flat": "^1.2.4",
+        "debug": "^2.6.9",
+        "doctrine": "^2.1.0",
+        "eslint-import-resolver-node": "^0.3.6",
+        "eslint-module-utils": "^2.6.2",
+        "find-up": "^2.0.0",
+        "has": "^1.0.3",
+        "is-core-module": "^2.6.0",
+        "minimatch": "^3.0.4",
+        "object.values": "^1.1.4",
+        "pkg-up": "^2.0.0",
+        "read-pkg-up": "^3.0.0",
+        "resolve": "^1.20.0",
+        "tsconfig-paths": "^3.11.0"
+      },
+      "dependencies": {
+        "doctrine": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+          "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+          "dev": true,
+          "requires": {
+            "esutils": "^2.0.2"
+          }
+        }
+      }
+    },
+    "eslint-plugin-jsdoc": {
+      "version": "36.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-36.1.1.tgz",
+      "integrity": "sha512-nuLDvH1EJaKx0PCa9oeQIxH6pACIhZd1gkalTUxZbaxxwokjs7TplqY0Q8Ew3CoZaf5aowm0g/Z3JGHCatt+gQ==",
+      "dev": true,
+      "requires": {
+        "@es-joy/jsdoccomment": "0.10.8",
+        "comment-parser": "1.2.4",
+        "debug": "^4.3.2",
+        "esquery": "^1.4.0",
+        "jsdoc-type-pratt-parser": "^1.1.1",
+        "lodash": "^4.17.21",
+        "regextras": "^0.8.0",
+        "semver": "^7.3.5",
+        "spdx-expression-parse": "^3.0.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.2",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+          "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+          "dev": true,
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-prettier": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz",
+      "integrity": "sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==",
+      "dev": true,
+      "requires": {
+        "prettier-linter-helpers": "^1.0.0"
+      }
+    },
+    "eslint-scope": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "dev": true,
+      "requires": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^4.1.1"
+      }
+    },
+    "eslint-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz",
+      "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==",
+      "dev": true,
+      "requires": {
+        "eslint-visitor-keys": "^1.1.0"
+      },
+      "dependencies": {
+        "eslint-visitor-keys": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+          "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-visitor-keys": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+      "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+      "dev": true
+    },
+    "espree": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz",
+      "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==",
+      "dev": true,
+      "requires": {
+        "acorn": "^7.4.0",
+        "acorn-jsx": "^5.3.1",
+        "eslint-visitor-keys": "^1.3.0"
+      },
+      "dependencies": {
+        "eslint-visitor-keys": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+          "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+          "dev": true
+        }
+      }
+    },
+    "esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true
+    },
+    "esquery": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+      "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.1.0"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+          "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+          "dev": true
+        }
+      }
+    },
+    "esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.2.0"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+          "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+          "dev": true
+        }
+      }
+    },
+    "estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "dev": true
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true
+    },
+    "etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
+    },
+    "express": {
+      "version": "4.17.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
+      "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
+      "requires": {
+        "accepts": "~1.3.7",
+        "array-flatten": "1.1.1",
+        "body-parser": "1.19.0",
+        "content-disposition": "0.5.3",
+        "content-type": "~1.0.4",
+        "cookie": "0.4.0",
+        "cookie-signature": "1.0.6",
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.1.2",
+        "fresh": "0.5.2",
+        "merge-descriptors": "1.0.1",
+        "methods": "~1.1.2",
+        "on-finished": "~2.3.0",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "0.1.7",
+        "proxy-addr": "~2.0.5",
+        "qs": "6.7.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.1.2",
+        "send": "0.17.1",
+        "serve-static": "1.14.1",
+        "setprototypeof": "1.1.1",
+        "statuses": "~1.5.0",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      }
+    },
+    "fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "fast-diff": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
+      "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
+      "dev": true
+    },
+    "fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+      "dev": true
+    },
+    "file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "requires": {
+        "flat-cache": "^3.0.4"
+      }
+    },
+    "finalhandler": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+      "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
+      "requires": {
+        "debug": "2.6.9",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.3.0",
+        "parseurl": "~1.3.3",
+        "statuses": "~1.5.0",
+        "unpipe": "~1.0.0"
+      }
+    },
+    "find-up": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
+      "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
+      "dev": true,
+      "requires": {
+        "locate-path": "^2.0.0"
+      }
+    },
+    "flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "requires": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      }
+    },
+    "flatted": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz",
+      "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==",
+      "dev": true
+    },
+    "follow-redirects": {
+      "version": "1.14.4",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
+      "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
+    },
+    "foreground-child": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+      "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
+      "dev": true,
+      "requires": {
+        "cross-spawn": "^7.0.0",
+        "signal-exit": "^3.0.2"
+      }
+    },
+    "forwarded": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz",
+      "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ="
+    },
+    "fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
+    },
+    "fs-minipass": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+      "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+      "requires": {
+        "minipass": "^3.0.0"
+      }
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+    },
+    "function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+      "dev": true
+    },
+    "functional-red-black-tree": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+      "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+      "dev": true
+    },
+    "gauge": {
+      "version": "2.7.4",
+      "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
+      "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
+      "requires": {
+        "aproba": "^1.0.3",
+        "console-control-strings": "^1.0.0",
+        "has-unicode": "^2.0.0",
+        "object-assign": "^4.1.0",
+        "signal-exit": "^3.0.0",
+        "string-width": "^1.0.1",
+        "strip-ansi": "^3.0.1",
+        "wide-align": "^1.1.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "requires": {
+            "ansi-regex": "^2.0.0"
+          }
+        }
+      }
+    },
+    "get-intrinsic": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+      "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.1"
+      }
+    },
+    "get-symbol-description": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+      "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "get-intrinsic": "^1.1.1"
+      }
+    },
+    "glob": {
+      "version": "7.1.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+      "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
+    "glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "requires": {
+        "is-glob": "^4.0.1"
+      }
+    },
+    "globals": {
+      "version": "13.11.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.11.0.tgz",
+      "integrity": "sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==",
+      "dev": true,
+      "requires": {
+        "type-fest": "^0.20.2"
+      }
+    },
+    "graceful-fs": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
+      "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==",
+      "dev": true
+    },
+    "has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1"
+      }
+    },
+    "has-bigints": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+      "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+      "dev": true
+    },
+    "has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true
+    },
+    "has-symbols": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+      "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+      "dev": true
+    },
+    "has-tostringtag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+      "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+      "dev": true,
+      "requires": {
+        "has-symbols": "^1.0.2"
+      }
+    },
+    "has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
+    },
+    "haxec": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/haxec/-/haxec-2.0.1.tgz",
+      "integrity": "sha512-2DaSqGZIzgVkZ4YFHbk9Su0Q6gm7YbzNX9njOHK/D/XklOdvgTemsPmjcyExlLdkl7lRlNIW0Wxo6niVfpWedw==",
+      "dev": true,
+      "requires": {
+        "foreground-child": "^2.0.0",
+        "spawn-wrap": "^2.0.0"
+      }
+    },
+    "hosted-git-info": {
+      "version": "2.8.9",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+      "dev": true
+    },
+    "http-errors": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
+      "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
+      "requires": {
+        "depd": "~1.1.2",
+        "inherits": "2.0.3",
+        "setprototypeof": "1.1.1",
+        "statuses": ">= 1.5.0 < 2",
+        "toidentifier": "1.0.0"
+      }
+    },
+    "https-proxy-agent": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
+      "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
+      "requires": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        }
+      }
+    },
+    "iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "requires": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      }
+    },
+    "ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
+    },
+    "ignore": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+      "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+      "dev": true
+    },
+    "import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "requires": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      }
+    },
+    "imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+      "dev": true
+    },
+    "inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "requires": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+      "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+    },
+    "internal-slot": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+      "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+      "dev": true,
+      "requires": {
+        "get-intrinsic": "^1.1.0",
+        "has": "^1.0.3",
+        "side-channel": "^1.0.4"
+      }
+    },
+    "ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
+    },
+    "is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=",
+      "dev": true
+    },
+    "is-bigint": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.2.tgz",
+      "integrity": "sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA==",
+      "dev": true
+    },
+    "is-boolean-object": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.0.tgz",
+      "integrity": "sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0"
+      }
+    },
+    "is-callable": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+      "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+      "dev": true
+    },
+    "is-core-module": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.7.0.tgz",
+      "integrity": "sha512-ByY+tjCciCr+9nLryBYcSD50EOGWt95c7tIsKTG1J2ixKKXPvF7Ej3AVd+UfDydAJom3biBGDBALaO79ktwgEQ==",
+      "dev": true,
+      "requires": {
+        "has": "^1.0.3"
+      }
+    },
+    "is-date-object": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.3.tgz",
+      "integrity": "sha512-tDpEUInNcy2Yw3lNSepK3Wdw1RnXLcIVienz6Ou631Acl15cJyRWK4dgA1vCmOEgIbtOV0W7MHg+AR2Gdg1NXQ==",
+      "dev": true
+    },
+    "is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true
+    },
+    "is-fullwidth-code-point": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+      "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
+      "requires": {
+        "number-is-nan": "^1.0.0"
+      }
+    },
+    "is-glob": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "dev": true,
+      "requires": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "is-negative-zero": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz",
+      "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==",
+      "dev": true
+    },
+    "is-number-object": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz",
+      "integrity": "sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==",
+      "dev": true
+    },
+    "is-regex": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+      "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      }
+    },
+    "is-shared-array-buffer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
+      "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+      "dev": true
+    },
+    "is-string": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+      "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+      "dev": true,
+      "requires": {
+        "has-tostringtag": "^1.0.0"
+      }
+    },
+    "is-symbol": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
+      "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
+      "dev": true,
+      "requires": {
+        "has-symbols": "^1.0.1"
+      }
+    },
+    "is-weakref": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.1.tgz",
+      "integrity": "sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0"
+      }
+    },
+    "is-windows": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+      "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+      "dev": true
+    },
+    "isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+    },
+    "isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+      "dev": true
+    },
+    "js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true
+    },
+    "js-yaml": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+      "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+      "dev": true,
+      "requires": {
+        "argparse": "^1.0.7",
+        "esprima": "^4.0.0"
+      }
+    },
+    "jsdoc-type-pratt-parser": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-1.2.0.tgz",
+      "integrity": "sha512-4STjeF14jp4bqha44nKMY1OUI6d2/g6uclHWUCZ7B4DoLzaB5bmpTkQrpqU+vSVzMD0LsKAOskcnI3I3VfIpmg==",
+      "dev": true
+    },
+    "json-parse-better-errors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+      "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+      "dev": true
+    },
+    "json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+      "dev": true
+    },
+    "json5": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
+      "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
+      "requires": {
+        "minimist": "^1.2.5"
+      }
+    },
+    "kareem": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.2.tgz",
+      "integrity": "sha512-STHz9P7X2L4Kwn72fA4rGyqyXdmrMSdxqHx9IXon/FXluXieaFA6KJ2upcHAHxQPQ0LeM/OjLrhFxifHewOALQ=="
+    },
+    "levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      }
+    },
+    "load-json-file": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
+      "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.1.2",
+        "parse-json": "^4.0.0",
+        "pify": "^3.0.0",
+        "strip-bom": "^3.0.0"
+      }
+    },
+    "locate-path": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
+      "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
+      "dev": true,
+      "requires": {
+        "p-locate": "^2.0.0",
+        "path-exists": "^3.0.0"
+      }
+    },
+    "lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=",
+      "dev": true
+    },
+    "lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "lodash.truncate": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+      "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+      "dev": true
+    },
+    "lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "requires": {
+        "yallist": "^4.0.0"
+      }
+    },
+    "make-dir": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+      "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+      "requires": {
+        "semver": "^6.0.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
+        }
+      }
+    },
+    "media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
+    },
+    "memory-pager": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
+      "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
+      "optional": true
+    },
+    "merge-descriptors": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
+      "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
+    },
+    "methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
+    },
+    "mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
+    },
+    "mime-db": {
+      "version": "1.47.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.47.0.tgz",
+      "integrity": "sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw=="
+    },
+    "mime-types": {
+      "version": "2.1.30",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.30.tgz",
+      "integrity": "sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==",
+      "requires": {
+        "mime-db": "1.47.0"
+      }
+    },
+    "minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+    },
+    "minipass": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.1.3.tgz",
+      "integrity": "sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==",
+      "requires": {
+        "yallist": "^4.0.0"
+      }
+    },
+    "minizlib": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+      "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+      "requires": {
+        "minipass": "^3.0.0",
+        "yallist": "^4.0.0"
+      }
+    },
+    "mkdirp": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+      "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
+    },
+    "moment": {
+      "version": "2.29.1",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
+      "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
+    },
+    "mongodb": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.1.2.tgz",
+      "integrity": "sha512-pHCKDoOy1h6mVurziJmXmTMPatYWOx8pbnyFgSgshja9Y36Q+caHUzTDY6rrIy9HCSrjnbXmx3pCtvNZHmR8xg==",
+      "requires": {
+        "bson": "^4.5.2",
+        "denque": "^2.0.1",
+        "mongodb-connection-string-url": "^2.0.0",
+        "saslprep": "^1.0.3"
+      },
+      "dependencies": {
+        "denque": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/denque/-/denque-2.0.1.tgz",
+          "integrity": "sha512-tfiWc6BQLXNLpNiR5iGd0Ocu3P3VpxfzFiqubLgMfhfOw9WyvgJBd46CClNn9k3qfbjvT//0cf7AlYRX/OslMQ=="
+        }
+      }
+    },
+    "mongodb-connection-string-url": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.1.0.tgz",
+      "integrity": "sha512-Qf9Zw7KGiRljWvMrrUFDdVqo46KIEiDuCzvEN97rh/PcKzk2bd6n9KuzEwBwW9xo5glwx69y1mI6s+jFUD/aIQ==",
+      "requires": {
+        "@types/whatwg-url": "^8.2.1",
+        "whatwg-url": "^9.1.0"
+      }
+    },
+    "mongoose": {
+      "version": "6.0.10",
+      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-6.0.10.tgz",
+      "integrity": "sha512-p/wiEDUXoQuyb/xQx8QW/YGN92ZsojJ5E/DDgMCUU0WOGxc5uhcWoZ7ijLu6Ssjq8UkwVSv+jzkYp4Wbr+NqBg==",
+      "requires": {
+        "bson": "^4.2.2",
+        "kareem": "2.3.2",
+        "mongodb": "4.1.2",
+        "mpath": "0.8.4",
+        "mquery": "4.0.0",
+        "ms": "2.1.2",
+        "regexp-clone": "1.0.0",
+        "sift": "13.5.2",
+        "sliced": "1.0.1"
+      },
+      "dependencies": {
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        }
+      }
+    },
+    "mpath": {
+      "version": "0.8.4",
+      "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.8.4.tgz",
+      "integrity": "sha512-DTxNZomBcTWlrMW76jy1wvV37X/cNNxPW1y2Jzd4DZkAaC5ZGsm8bfGfNOthcDuRJujXLqiuS6o3Tpy0JEoh7g=="
+    },
+    "mquery": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/mquery/-/mquery-4.0.0.tgz",
+      "integrity": "sha512-nGjm89lHja+T/b8cybAby6H0YgA4qYC/lx6UlwvHGqvTq8bDaNeCwl1sY8uRELrNbVWJzIihxVd+vphGGn1vBw==",
+      "requires": {
+        "debug": "4.x",
+        "regexp-clone": "^1.0.0",
+        "sliced": "1.0.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.3.2",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+          "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+          "requires": {
+            "ms": "2.1.2"
+          }
+        },
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        }
+      }
+    },
+    "ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+    },
+    "natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+      "dev": true
+    },
+    "negotiator": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
+      "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
+    },
+    "node-addon-api": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz",
+      "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw=="
+    },
+    "node-fetch": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
+      "integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
+    },
+    "nodemailer": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.0.tgz",
+      "integrity": "sha512-AtiTVUFHLiiDnMQ43zi0YgkzHOEWUkhDgPlBXrsDzJiJvB29Alo4OKxHQ0ugF3gRqRQIneCLtZU3yiUo7pItZw=="
+    },
+    "nopt": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+      "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+      "requires": {
+        "abbrev": "1"
+      }
+    },
+    "normalize-package-data": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+      "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+      "dev": true,
+      "requires": {
+        "hosted-git-info": "^2.1.4",
+        "resolve": "^1.10.0",
+        "semver": "2 || 3 || 4 || 5",
+        "validate-npm-package-license": "^3.0.1"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+          "dev": true
+        }
+      }
+    },
+    "npmlog": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
+      "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
+      "requires": {
+        "are-we-there-yet": "~1.1.2",
+        "console-control-strings": "~1.1.0",
+        "gauge": "~2.7.3",
+        "set-blocking": "~2.0.0"
+      }
+    },
+    "number-is-nan": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+      "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
+    },
+    "oauth": {
+      "version": "0.9.15",
+      "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
+      "integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE="
+    },
+    "object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+    },
+    "object-inspect": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz",
+      "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==",
+      "dev": true
+    },
+    "object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "dev": true
+    },
+    "object.assign": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+      "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "has-symbols": "^1.0.1",
+        "object-keys": "^1.1.1"
+      }
+    },
+    "object.entries": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.3.tgz",
+      "integrity": "sha512-ym7h7OZebNS96hn5IJeyUmaWhaSM4SVtAPPfNLQEI2MYWCO2egsITb9nab2+i/Pwibx+R0mtn+ltKJXRSeTMGg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.18.0-next.1",
+        "has": "^1.0.3"
+      }
+    },
+    "object.values": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+      "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      }
+    },
+    "on-finished": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
+      "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
+      "requires": {
+        "ee-first": "1.1.1"
+      }
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "optionator": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+      "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+      "dev": true,
+      "requires": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.3"
+      }
+    },
+    "p-limit": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
+      "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
+      "dev": true,
+      "requires": {
+        "p-try": "^1.0.0"
+      }
+    },
+    "p-locate": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
+      "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
+      "dev": true,
+      "requires": {
+        "p-limit": "^1.1.0"
+      }
+    },
+    "p-try": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
+      "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
+      "dev": true
+    },
+    "parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "requires": {
+        "callsites": "^3.0.0"
+      }
+    },
+    "parse-json": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
+      "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
+      "dev": true,
+      "requires": {
+        "error-ex": "^1.3.1",
+        "json-parse-better-errors": "^1.0.1"
+      }
+    },
+    "parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
+    },
+    "path-exists": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+      "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+      "dev": true
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+    },
+    "path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true
+    },
+    "path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "path-to-regexp": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
+      "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
+    },
+    "path-type": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
+      "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==",
+      "dev": true,
+      "requires": {
+        "pify": "^3.0.0"
+      }
+    },
+    "pify": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+      "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
+      "dev": true
+    },
+    "pkg-dir": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz",
+      "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=",
+      "dev": true,
+      "requires": {
+        "find-up": "^2.1.0"
+      }
+    },
+    "pkg-up": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-2.0.0.tgz",
+      "integrity": "sha1-yBmscoBZpGHKscOImivjxJoATX8=",
+      "dev": true,
+      "requires": {
+        "find-up": "^2.1.0"
+      }
+    },
+    "prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true
+    },
+    "prettier": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz",
+      "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==",
+      "dev": true
+    },
+    "prettier-linter-helpers": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+      "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+      "dev": true,
+      "requires": {
+        "fast-diff": "^1.1.2"
+      }
+    },
+    "process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
+    },
+    "progress": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+      "dev": true
+    },
+    "proxy-addr": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
+      "integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==",
+      "requires": {
+        "forwarded": "~0.1.2",
+        "ipaddr.js": "1.9.1"
+      }
+    },
+    "punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
+    },
+    "qs": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
+      "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
+    },
+    "range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
+    },
+    "raw-body": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
+      "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
+      "requires": {
+        "bytes": "3.1.0",
+        "http-errors": "1.7.2",
+        "iconv-lite": "0.4.24",
+        "unpipe": "1.0.0"
+      }
+    },
+    "read-pkg": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
+      "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=",
+      "dev": true,
+      "requires": {
+        "load-json-file": "^4.0.0",
+        "normalize-package-data": "^2.3.2",
+        "path-type": "^3.0.0"
+      }
+    },
+    "read-pkg-up": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-3.0.0.tgz",
+      "integrity": "sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=",
+      "dev": true,
+      "requires": {
+        "find-up": "^2.0.0",
+        "read-pkg": "^3.0.0"
+      }
+    },
+    "readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "requires": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
+    "redis": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
+      "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
+      "requires": {
+        "denque": "^1.5.0",
+        "redis-commands": "^1.7.0",
+        "redis-errors": "^1.2.0",
+        "redis-parser": "^3.0.0"
+      }
+    },
+    "redis-commands": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
+      "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
+    },
+    "redis-errors": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+      "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60="
+    },
+    "redis-parser": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+      "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
+      "requires": {
+        "redis-errors": "^1.0.0"
+      }
+    },
+    "regexp-clone": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz",
+      "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw=="
+    },
+    "regexpp": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz",
+      "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==",
+      "dev": true
+    },
+    "regextras": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/regextras/-/regextras-0.8.0.tgz",
+      "integrity": "sha512-k519uI04Z3SaY0fLX843MRXnDeG2+vHOFsyhiPZvNLe7r8rD2YNRjq4BQLZZ0oAr2NrtvZlICsXysGNFPGa3CQ==",
+      "dev": true
+    },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true
+    },
+    "resolve": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+      "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+      "dev": true,
+      "requires": {
+        "is-core-module": "^2.2.0",
+        "path-parse": "^1.0.6"
+      }
+    },
+    "resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true
+    },
+    "retry-axios": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/retry-axios/-/retry-axios-2.6.0.tgz",
+      "integrity": "sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==",
+      "requires": {}
+    },
+    "rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+    },
+    "saslprep": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/saslprep/-/saslprep-1.0.3.tgz",
+      "integrity": "sha512-/MY/PEMbk2SuY5sScONwhUDsV2p77Znkb/q3nSVstq/yQzYJOH/Azh29p9oJLsl3LnQwSvZDKagDGBsBwSooag==",
+      "optional": true,
+      "requires": {
+        "sparse-bitfield": "^3.0.3"
+      }
+    },
+    "semver": {
+      "version": "7.3.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+      "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+      "requires": {
+        "lru-cache": "^6.0.0"
+      }
+    },
+    "send": {
+      "version": "0.17.1",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
+      "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
+      "requires": {
+        "debug": "2.6.9",
+        "depd": "~1.1.2",
+        "destroy": "~1.0.4",
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "0.5.2",
+        "http-errors": "~1.7.2",
+        "mime": "1.6.0",
+        "ms": "2.1.1",
+        "on-finished": "~2.3.0",
+        "range-parser": "~1.2.1",
+        "statuses": "~1.5.0"
+      },
+      "dependencies": {
+        "ms": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
+          "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
+        }
+      }
+    },
+    "serve-static": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
+      "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
+      "requires": {
+        "encodeurl": "~1.0.2",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "0.17.1"
+      }
+    },
+    "set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
+    },
+    "setprototypeof": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
+      "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
+    },
+    "sha256": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/sha256/-/sha256-0.2.0.tgz",
+      "integrity": "sha1-c6C0GNqrcDW/+G6EkeNjQS/CqwU=",
+      "requires": {
+        "convert-hex": "~0.1.0",
+        "convert-string": "~0.1.0"
+      }
+    },
+    "shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "requires": {
+        "shebang-regex": "^3.0.0"
+      }
+    },
+    "shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true
+    },
+    "side-channel": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0",
+        "get-intrinsic": "^1.0.2",
+        "object-inspect": "^1.9.0"
+      }
+    },
+    "sift": {
+      "version": "13.5.2",
+      "resolved": "https://registry.npmjs.org/sift/-/sift-13.5.2.tgz",
+      "integrity": "sha512-+gxdEOMA2J+AI+fVsCqeNn7Tgx3M9ZN9jdi95939l1IJ8cZsqS8sqpJyOkic2SJk+1+98Uwryt/gL6XDaV+UZA=="
+    },
+    "signal-exit": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
+      "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA=="
+    },
+    "slice-ansi": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      },
+      "dependencies": {
+        "is-fullwidth-code-point": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+          "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+          "dev": true
+        }
+      }
+    },
+    "sliced": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz",
+      "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E="
+    },
+    "sparse-bitfield": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
+      "integrity": "sha1-/0rm5oZWBWuks+eSqzM004JzyhE=",
+      "optional": true,
+      "requires": {
+        "memory-pager": "^1.0.2"
+      }
+    },
+    "spawn-wrap": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz",
+      "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==",
+      "dev": true,
+      "requires": {
+        "foreground-child": "^2.0.0",
+        "is-windows": "^1.0.2",
+        "make-dir": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "signal-exit": "^3.0.2",
+        "which": "^2.0.1"
+      }
+    },
+    "spdx-correct": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+      "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+      "dev": true,
+      "requires": {
+        "spdx-expression-parse": "^3.0.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "spdx-exceptions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+      "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+      "dev": true
+    },
+    "spdx-expression-parse": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+      "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+      "dev": true,
+      "requires": {
+        "spdx-exceptions": "^2.1.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "spdx-license-ids": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz",
+      "integrity": "sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==",
+      "dev": true
+    },
+    "sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+      "dev": true
+    },
+    "statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
+    },
+    "string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "requires": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
+    "string-width": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
+      "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
+      "requires": {
+        "code-point-at": "^1.0.0",
+        "is-fullwidth-code-point": "^1.0.0",
+        "strip-ansi": "^3.0.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+          "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+        },
+        "strip-ansi": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+          "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+          "requires": {
+            "ansi-regex": "^2.0.0"
+          }
+        }
+      }
+    },
+    "string.prototype.trimend": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+      "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      }
+    },
+    "string.prototype.trimstart": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+      "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      }
+    },
+    "strip-ansi": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+      "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^5.0.0"
+      }
+    },
+    "strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+      "dev": true
+    },
+    "strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true
+    },
+    "supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "requires": {
+        "has-flag": "^4.0.0"
+      }
+    },
+    "table": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.7.0.tgz",
+      "integrity": "sha512-SAM+5p6V99gYiiy2gT5ArdzgM1dLDed0nkrWmG6Fry/bUS/m9x83BwpJUOf1Qj/x2qJd+thL6IkIx7qPGRxqBw==",
+      "dev": true,
+      "requires": {
+        "ajv": "^8.0.1",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.truncate": "^4.4.2",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "8.2.0",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.2.0.tgz",
+          "integrity": "sha512-WSNGFuyWd//XO8n/m/EaOlNLtO0yL8EXT/74LqT4khdhpZjP7lkj/kT5uwRmGitKEVp/Oj7ZUHeGfPtgHhQ5CA==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "is-fullwidth-code-point": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+          "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+          "dev": true
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true
+        },
+        "string-width": {
+          "version": "4.2.2",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
+          "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==",
+          "dev": true,
+          "requires": {
+            "emoji-regex": "^8.0.0",
+            "is-fullwidth-code-point": "^3.0.0",
+            "strip-ansi": "^6.0.0"
+          }
+        }
+      }
+    },
+    "tar": {
+      "version": "6.1.11",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz",
+      "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==",
+      "requires": {
+        "chownr": "^2.0.0",
+        "fs-minipass": "^2.0.0",
+        "minipass": "^3.0.0",
+        "minizlib": "^2.1.1",
+        "mkdirp": "^1.0.3",
+        "yallist": "^4.0.0"
+      }
+    },
+    "text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+      "dev": true
+    },
+    "toidentifier": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
+      "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
+    },
+    "tr46": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz",
+      "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==",
+      "requires": {
+        "punycode": "^2.1.1"
+      }
+    },
+    "trace-unhandled": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/trace-unhandled/-/trace-unhandled-2.0.1.tgz",
+      "integrity": "sha512-wOZbhBiNyuZTs0b/ADZFTiTDVVDsvKQj/RkVJTKefH6u9CowGDSR+H/3miaGUrYCCuzS0nVmIzpbIIm6lRF8gg==",
+      "dev": true,
+      "requires": {
+        "haxec": "^2.0.1"
+      }
+    },
+    "tsconfig-paths": {
+      "version": "3.11.0",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.11.0.tgz",
+      "integrity": "sha512-7ecdYDnIdmv639mmDwslG6KQg1Z9STTz1j7Gcz0xa+nshh/gKDAHcPxRbWOsA3SPp0tXP2leTcY9Kw+NAkfZzA==",
+      "dev": true,
+      "requires": {
+        "@types/json5": "^0.0.29",
+        "json5": "^1.0.1",
+        "minimist": "^1.2.0",
+        "strip-bom": "^3.0.0"
+      },
+      "dependencies": {
+        "json5": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+          "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+          "dev": true,
+          "requires": {
+            "minimist": "^1.2.0"
+          }
+        }
+      }
+    },
+    "type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1"
+      }
+    },
+    "type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true
+    },
+    "type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "requires": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      }
+    },
+    "unbox-primitive": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+      "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "has-bigints": "^1.0.1",
+        "has-symbols": "^1.0.2",
+        "which-boxed-primitive": "^1.0.2"
+      }
+    },
+    "underscore": {
+      "version": "1.13.1",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz",
+      "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g=="
+    },
+    "unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
+    },
+    "uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+    },
+    "utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM="
+    },
+    "v8-compile-cache": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+      "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+      "dev": true
+    },
+    "validate-npm-package-license": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+      "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+      "dev": true,
+      "requires": {
+        "spdx-correct": "^3.0.0",
+        "spdx-expression-parse": "^3.0.0"
+      }
+    },
+    "vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
+    },
+    "webidl-conversions": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
+      "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w=="
+    },
+    "whatwg-url": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-9.1.0.tgz",
+      "integrity": "sha512-CQ0UcrPHyomtlOCot1TL77WyMIm/bCwrJ2D6AOKGwEczU9EpyoqAokfqrf/MioU9kHcMsmJZcg1egXix2KYEsA==",
+      "requires": {
+        "tr46": "^2.1.0",
+        "webidl-conversions": "^6.1.0"
+      }
+    },
+    "which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "requires": {
+        "isexe": "^2.0.0"
+      }
+    },
+    "which-boxed-primitive": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+      "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+      "dev": true,
+      "requires": {
+        "is-bigint": "^1.0.1",
+        "is-boolean-object": "^1.1.0",
+        "is-number-object": "^1.0.4",
+        "is-string": "^1.0.5",
+        "is-symbol": "^1.0.3"
+      }
+    },
+    "wide-align": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
+      "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
+      "requires": {
+        "string-width": "^1.0.2 || 2"
+      }
+    },
+    "word-wrap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "dev": true
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    },
+    "ws": {
+      "version": "8.2.3",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
+      "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==",
+      "requires": {}
+    },
+    "yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
+    }
+  }
+}

+ 38 - 28
backend/package.json

@@ -1,37 +1,47 @@
 {
 {
   "name": "musare-backend",
   "name": "musare-backend",
-  "version": "0.0.1",
-  "description": "A modern, open-source, collaborative music app https://musare.com",
-  "main": "app.js",
+  "private": true,
+  "version": "3.0.0",
+  "type": "module",
+  "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
+  "main": "index.js",
   "author": "Musare Team",
   "author": "Musare Team",
-  "repository": "https://github.com/Musare/MusareNode",
+  "license": "GPL-3.0",
+  "repository": "https://github.com/Musare/Musare",
   "scripts": {
   "scripts": {
-    "development": "nodemon -L /opt/app",
-    "production": "node /opt/app"
+    "dev": "nodemon --es-module-specifier-resolution=node",
+    "docker:dev": "nodemon --es-module-specifier-resolution=node -L /opt/app",
+    "docker:prod": "node --es-module-specifier-resolution=node /opt/app",
+    "lint": "npx eslint logic"
   },
   },
   "dependencies": {
   "dependencies": {
-    "async": "2.0.1",
-    "bcrypt": "^0.8.7",
-    "bluebird": "^3.4.6",
-    "body-parser": "^1.15.2",
-    "config": "^1.21.0",
-    "connect-mongo": "^1.3.2",
-    "cookie-parser": "^1.4.3",
-    "cors": "^2.8.1",
-    "express": "^4.14.0",
-    "express-session": "^1.14.0",
-    "mailgun-js": "^0.8.0",
-    "moment": "^2.15.2",
-    "mongoose": "^4.6.0",
-    "oauth": "^0.9.14",
-    "passport": "^0.3.2",
-    "passport-discord": "^0.1.1",
-    "passport-github": "^1.1.0",
-    "passport-local": "^1.0.0",
-    "passport.socketio": "^3.7.0",
-    "redis": "^2.6.3",
-    "request": "^2.74.0",
+    "async": "^3.2.1",
+    "axios": "^0.22.0",
+    "bcrypt": "^5.0.1",
+    "bluebird": "^3.7.2",
+    "body-parser": "^1.19.0",
+    "config": "^3.3.6",
+    "cookie-parser": "^1.4.5",
+    "cors": "^2.8.5",
+    "express": "^4.17.1",
+    "moment": "^2.29.1",
+    "mongoose": "^6.0.10",
+    "nodemailer": "^6.7.0",
+    "oauth": "^0.9.15",
+    "redis": "^3.1.2",
+    "retry-axios": "^2.6.0",
     "sha256": "^0.2.0",
     "sha256": "^0.2.0",
-    "socket.io": "^1.5.0"
+    "underscore": "^1.13.1",
+    "ws": "^8.2.3"
+  },
+  "devDependencies": {
+    "eslint": "^7.32.0",
+    "eslint-config-airbnb-base": "^14.2.1",
+    "eslint-config-prettier": "^8.3.0",
+    "eslint-plugin-import": "^2.24.2",
+    "eslint-plugin-jsdoc": "^36.1.1",
+    "eslint-plugin-prettier": "^4.0.0",
+    "prettier": "2.4.1",
+    "trace-unhandled": "^2.0.1"
   }
   }
 }
 }

+ 0 - 30
docker-compose-production.yml

@@ -1,30 +0,0 @@
-version: '2'
-services:
-  backend:
-    build: ./backend
-    ports:
-    - "8081:8081"
-    volumes:
-    - ./backend:/opt/app
-    links:
-    - mongo
-    - redis
-    environment:
-    - NGINX_PORT=81
-  frontend:
-    build: ./frontend
-    ports:
-    - "81:81"
-    volumes:
-    - ./frontend:/opt/app
-  mongo:
-    image: mongo
-    ports:
-    - "27017:27017"
-  mongoclient:
-    image: mongoclient/mongoclient
-    ports:
-    - "3000:3000"
-  redis:
-    image: redis
-    command: "--notify-keyspace-events Ex"

+ 38 - 16
docker-compose.yml

@@ -1,30 +1,52 @@
-version: '2'
+version: '3'
 services:
 services:
+
   backend:
   backend:
     build: ./backend
     build: ./backend
     ports:
     ports:
-    - "8080:8080"
+      - "${BACKEND_HOST}:${BACKEND_PORT}:8080"
     volumes:
     volumes:
-    - ./backend:/opt/app
+      - ./backend:/opt/app
+      - ./log:/opt/log
     links:
     links:
-    - mongo
-    - redis
-    environment:
-    - NGINX_PORT=80
+      - mongo
+      - redis
+    stdin_open: true
+    tty: true
+
   frontend:
   frontend:
     build: ./frontend
     build: ./frontend
     ports:
     ports:
-    - "80:80"
+      - "${FRONTEND_HOST}:${FRONTEND_PORT}:80"
     volumes:
     volumes:
-    - ./frontend:/opt/app
+      - ./frontend:/opt/app
+      - /opt/app/node_modules/
+    environment:
+      - FRONTEND_MODE=${FRONTEND_MODE}
+    links:
+      - backend
+
   mongo:
   mongo:
-    image: mongo
-    ports:
-    - "27017:27017"
-  mongoclient:
-    image: mongoclient/mongoclient
+    image: mongo:4.0
     ports:
     ports:
-    - "3000:3000"
+      - "${MONGO_HOST}:${MONGO_PORT}:${MONGO_PORT}"
+    environment:
+      - MONGO_INITDB_ROOT_USERNAME=admin
+      - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
+      - MONGO_INITDB_DATABASE=musare
+      - MONGO_PORT=${MONGO_PORT}
+      - MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
+      - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
+      - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
+    volumes:
+      - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
+      - ./.db:/data/db
+
   redis:
   redis:
     image: redis
     image: redis
-    command: "--notify-keyspace-events Ex"
+    ports:
+      - "${REDIS_HOST}:${REDIS_PORT}:6379"
+    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
+    volumes:
+      - .redis:/data
+

+ 58 - 0
fallback.html

@@ -0,0 +1,58 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <title>Musare</title>
+    <link href="https://fonts.googleapis.com/css?family=Lato:100" rel="stylesheet" type="text/css">
+    <link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"
+        integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
+    <style>
+        html,
+        body {
+            height: 100%;
+        }
+
+        body {
+            margin: 0;
+            padding: 0;
+            width: 100%;
+            display: table;
+            font-weight: 100;
+            font-family: 'Lato';
+        }
+
+        .container {
+            text-align: center;
+            display: table-cell;
+            vertical-align: middle;
+        }
+
+        .content {
+            text-align: center;
+            display: inline-block;
+            padding-left: 15px;
+            padding-right: 15px;
+        }
+
+        .title {
+            font-size: 96px;
+        }
+
+        p {
+            font-size: 28px;
+            font-weight: 700;
+        }
+    </style>
+</head>
+
+<body>
+    <div class="container">
+        <div class="content">
+            <img src="https://preview.ibb.co/eAo1y5/logo.png" alt="Logo" style="width: 80%;" />
+            <div class="title">We are offline!</div>
+            <p>Please check back later.</p>
+        </div>
+    </div>
+</body>
+
+</html>

部分文件因为文件数量过多而无法显示