Răsfoiți Sursa

Merge branch 'experimental'

Kristian Vos 3 ani în urmă
părinte
comite
960f59bcc5
100 a modificat fișierele cu 21553 adăugiri și 8663 ștergeri
  1. 3 0
      .gitattributes
  2. 1 1
      .gitignore
  3. 2 2
      .travis.yml
  4. 145 168
      README.md
  5. 15 0
      backend/.devcontainer/devcontainer.json
  6. 13 0
      backend/.snyk
  7. 48 0
      backend/classes/Timer.class.js
  8. 186 85
      backend/core.js
  9. 339 157
      backend/index.js
  10. 99 0
      backend/logic/actions/activities.js
  11. 188 134
      backend/logic/actions/apis.js
  12. 54 36
      backend/logic/actions/hooks/adminRequired.js
  13. 45 30
      backend/logic/actions/hooks/loginRequired.js
  14. 66 40
      backend/logic/actions/hooks/ownerRequired.js
  15. 12 10
      backend/logic/actions/index.js
  16. 226 139
      backend/logic/actions/news.js
  17. 1149 523
      backend/logic/actions/playlists.js
  18. 154 108
      backend/logic/actions/punishments.js
  19. 374 229
      backend/logic/actions/queueSongs.js
  20. 342 233
      backend/logic/actions/reports.js
  21. 1022 469
      backend/logic/actions/songs.js
  22. 2344 1185
      backend/logic/actions/stations.js
  23. 2072 1139
      backend/logic/actions/users.js
  24. 98 0
      backend/logic/actions/utils.js
  25. 80 0
      backend/logic/activities.js
  26. 44 39
      backend/logic/api.js
  27. 539 246
      backend/logic/app.js
  28. 261 206
      backend/logic/cache/index.js
  29. 324 190
      backend/logic/db/index.js
  30. 16 0
      backend/logic/db/schemas/activity.js
  31. 1 1
      backend/logic/db/schemas/news.js
  32. 1 1
      backend/logic/db/schemas/playlist.js
  33. 1 1
      backend/logic/db/schemas/punishment.js
  34. 1 1
      backend/logic/db/schemas/queueSong.js
  35. 1 1
      backend/logic/db/schemas/report.js
  36. 1 1
      backend/logic/db/schemas/song.js
  37. 8 1
      backend/logic/db/schemas/user.js
  38. 99 77
      backend/logic/discord.js
  39. 376 188
      backend/logic/io.js
  40. 0 177
      backend/logic/logger.js
  41. 45 35
      backend/logic/mail/index.js
  42. 17 12
      backend/logic/mail/schemas/passwordRequest.js
  43. 17 12
      backend/logic/mail/schemas/resetPasswordRequest.js
  44. 22 13
      backend/logic/mail/schemas/verifyEmail.js
  45. 243 154
      backend/logic/notifications.js
  46. 280 166
      backend/logic/playlists.js
  47. 313 241
      backend/logic/punishments.js
  48. 258 175
      backend/logic/songs.js
  49. 103 82
      backend/logic/spotify.js
  50. 1167 533
      backend/logic/stations.js
  51. 318 167
      backend/logic/tasks.js
  52. 762 559
      backend/logic/utils.js
  53. 4372 0
      backend/package-lock.json
  54. 11 7
      backend/package.json
  55. 19 15
      docker-compose.yml
  56. 19 0
      frontend/.devcontainer/devcontainer.json
  57. 1 2
      frontend/.prettierignore
  58. 101 4
      frontend/.snyk
  59. 110 16
      frontend/App.vue
  60. 5 7
      frontend/Dockerfile
  61. 7 0
      frontend/api/admin/index.js
  62. 17 0
      frontend/api/admin/reports.js
  63. 8 5
      frontend/api/auth.js
  64. 2 2
      frontend/bootstrap.sh
  65. 240 0
      frontend/components/Admin/NewStatistics.vue
  66. 39 0
      frontend/components/Admin/News.vue
  67. 39 0
      frontend/components/Admin/Punishments.vue
  68. 26 0
      frontend/components/Admin/QueueSongs.vue
  69. 38 9
      frontend/components/Admin/Reports.vue
  70. 26 0
      frontend/components/Admin/Songs.vue
  71. 52 7
      frontend/components/Admin/Stations.vue
  72. 34 0
      frontend/components/Admin/Statistics.vue
  73. 35 1
      frontend/components/Admin/Users.vue
  74. 14 5
      frontend/components/MainFooter.vue
  75. 12 0
      frontend/components/MainHeader.vue
  76. 1 0
      frontend/components/Modals/AddSongToPlaylist.vue
  77. 9 13
      frontend/components/Modals/AddSongToQueue.vue
  78. 12 1
      frontend/components/Modals/CreateCommunityStation.vue
  79. 2 4
      frontend/components/Modals/EditSong.vue
  80. 53 24
      frontend/components/Modals/EditStation.vue
  81. 16 5
      frontend/components/Modals/IssuesModal.vue
  82. 15 1
      frontend/components/Modals/Login.vue
  83. 79 54
      frontend/components/Modals/Playlists/Edit.vue
  84. 131 11
      frontend/components/Modals/Register.vue
  85. 9 10
      frontend/components/Modals/Report.vue
  86. 13 0
      frontend/components/Modals/WhatIsNew.vue
  87. 14 0
      frontend/components/Sidebars/Playlist.vue
  88. 21 9
      frontend/components/Sidebars/SongsList.vue
  89. 14 0
      frontend/components/Sidebars/UsersList.vue
  90. 202 107
      frontend/components/Station/Station.vue
  91. 17 4
      frontend/components/Station/StationHeader.vue
  92. 10 0
      frontend/components/User/ResetPassword.vue
  93. 545 142
      frontend/components/User/Settings.vue
  94. 702 97
      frontend/components/User/Show.vue
  95. 65 50
      frontend/components/pages/About.vue
  96. 36 0
      frontend/components/pages/Admin.vue
  97. 28 3
      frontend/components/pages/Home.vue
  98. 6 0
      frontend/components/pages/News.vue
  99. 30 80
      frontend/components/pages/Team.vue
  100. 1 1
      frontend/components/pages/Terms.vue

+ 3 - 0
.gitattributes

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

+ 1 - 1
.gitignore

@@ -12,6 +12,7 @@ startMongo.cmd
 .db
 .redis
 *.rdb
+
 npm-debug.log
 lerna-debug.log
 
@@ -20,7 +21,6 @@ backend/node_modules/
 backend/config/default.json
 
 # Frontend
-frontend/yarn-error.log
 frontend/bundle-stats.json
 frontend/bundle-report.html
 frontend/node_modules/

+ 2 - 2
.travis.yml

@@ -31,8 +31,8 @@ jobs:
       script:
         - docker-compose build frontend # build frontend
         - docker-compose up -d frontend # start frontend
-        - docker-compose exec frontend /bin/bash -c "cd app && yarn lint" # using eslint to check for formatting/linting issues
-    - stage: backend
+        - 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

+ 145 - 168
README.md

@@ -1,3 +1,4 @@
+# Musare is no longer being maintained
 
 # MusareNode
 
@@ -8,13 +9,9 @@ MusareNode now uses NodeJS, Express, SocketIO and VueJS - among other technologi
 The master branch is available at [musare.com](https://musare.com)
 You can also find the staging branch at [musare.dev](https://musare.dev)
 
-## Contact
-
-Get in touch with us via email at [core@musare.com](mailto:core@musare.com) or join our [Discord Guild](https://discord.gg/Y5NxYGP).
+<br />
 
-You can also find us on [Facebook](https://www.facebook.com/MusareMusic) and [Twitter](https://twitter.com/MusareApp).
-
-### Our Stack
+## Our Stack
 
 - NodeJS
 - MongoDB
@@ -22,34 +19,20 @@ You can also find us on [Facebook](https://www.facebook.com/MusareMusic) and [Tw
 - Nginx (not required)
 - VueJS
 
-### Frontend
+### **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
+### **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.
+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.
 
-## Requirements
-
-Installing with Docker: (not recommended for Windows users)
+<br />
 
-- [Docker](https://www.docker.com/)
+## Getting Started & Configuration
 
-Standard Installation:
-
-- [NodeJS](https://nodejs.org/en/download/)
-  _ nodemon: `yarn global add nodemon`
-  _ [node-gyp](https://github.com/nodejs/node-gyp#installation): `yarn global add node-gyp`
-- [Yarn (Windows)](https://yarnpkg.com/lang/en/docs/install/#windows-stable) [Yarn (Unix)](https://yarnpkg.com/lang/en/docs/install/#debian-stable) ([npm](https://www.npmjs.com/) can also be used)
-- [MongoDB](https://www.mongodb.com/download-center) Currently version 4.0
-- [Redis (Windows)](https://github.com/MSOpenTech/redis/releases/tag/win-3.2.100) [Redis (Unix)](https://redis.io/download)
-
-## Getting Started
-
-Once you've installed the required tools:
 
 1. `git clone https://github.com/Musare/MusareNode.git`
 
@@ -57,205 +40,221 @@ Once you've installed the required tools:
 
 3. `cp backend/config/template.json backend/config/default.json`
 
-   |Property|Description|
-   |--|--|
-   |`mode`|Should be either `development` or `production`. No more explanation needed.|
-   |`secret`|Whatever you want - 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: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.|
-   |`isDocker`|Self-explanatory. Are you using Docker?|
-   |`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
-   |`apis.youtube.key`|Can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started). You need to use the YouTube Data API v3, and create an API key.|
-   |`apis.recaptcha.secret`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
-   |`apis.github`|Can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers). You need to fill in some values to create the OAuth application. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`.|
-   |`apis.discord.token`|Token for the Discord bot.|
-   |`apis.discord.loggingServer`|Server ID of the Discord logging server.|
-   |`apis.discord.loggingChannel`|ID of the channel to be used in the Discord logging server.|
-   |`apis.mailgun`|Can be obtained by setting up a [Mailgun account](http://www.mailgun.com/), or you can disable it.|
-   |`apis.spotify`|Can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it.|
-   |`apis.discogs`|Can be obtained by setting up a [Discogs application](https://www.discogs.com/settings/developers), or you can disable it.|
-   |`redis.url`|Should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.|
-   |`redis.password`|Should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker.|
-   |`mongo.url`|Needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`.|
-   |`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.|
+    | Property | Description |
+    | - | - |
+    | `mode` | Should be either `development` or `production`. No more explanation needed. |
+    | `secret` | Whatever you want - 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: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. |
+    | `isDocker` | Self-explanatory. Are you using Docker? |
+    | `serverPort` | Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker. |
+    | `apis.youtube.key`            | Can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started). You need to use the YouTube Data API v3, and create an API key. |
+    | `apis.recaptcha.secret`       | Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin). |
+    | `apis.github` | Can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers). You need to fill in some values to create the OAuth application. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`. |
+    | `apis.discord.token` | Token for the Discord bot. |
+    | `apis.discord.loggingServer`  | Server ID of the Discord logging server. |
+    | `apis.discord.loggingChannel` | ID of the channel to be used in the Discord logging server. |
+    | `apis.mailgun` | Can be obtained by setting up a [Mailgun account](http://www.mailgun.com/), or you can disable it. |
+    | `apis.spotify` | Can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it. |
+    | `apis.discogs` | Can be obtained by setting up a [Discogs application](https://www.discogs.com/settings/developers), or you can disable it. |
+    | `redis.url` | Should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker. |
+    | `redis.password` | Should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker. |
+    | `mongo.url` | Needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`. |
+    | `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. |
 
 4. `cp frontend/build/config/template.json frontend/build/config/default.json`
 
-   |Property|Description|
-   |--|--|
-   |`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` 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.|
-   |`frontendPort`|Should be the port where the frontend will be accessible from, should always be port `81` for Docker, and is recommended to be port `80` for non-Docker.|
-   |`recaptcha.key`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
-   |`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.|
-   |`siteSettings.logo`|Path to the logo image, by default it is `/assets/wordmark.png`.|
-   |`siteSettings.siteName`|Should be the name of the site.|
-   |`siteSettings.socialLinks`|`github`, `twitter` and `facebook` are set to the official Musare accounts by default, but can be changed.|
+    | Property | Description |
+    | - | - |
+    | `serverDomain` | Should be the url where the backend will be accessible from, usually `http://localhost:8080` 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. |
+    | `frontendPort` | Should be the port where the frontend will be accessible from, should always be port `81` for Docker, and is recommended to be port `80` for non-Docker. |
+    | `recaptcha.key` | Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin). |
+    | `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. |
+    | `siteSettings.logo` | Path to the logo image, by default it is `/assets/wordmark.png`. |
+    | `siteSettings.siteName` | Should be the name of the site. |
+    | `siteSettings.socialLinks` | `github`, `twitter` and `facebook` are set to the official Musare accounts by default, but can be changed. |
 
 5. Simply `cp .env.example .env` to setup your environment variables.
 
 6. To setup [snyk](https://snyk.io/) (which is what we use for our precommit git-hooks), you will need to:
 
-   - Setup an account
-   - Go to [settings](https://app.snyk.io/account)
-   - Copy the API token and set it as your `SNYK_TOKEN` environment variable.
+    - Setup an account
+    - Go to [settings](https://app.snyk.io/account)
+    - Copy the API token and set it as your `SNYK_TOKEN` environment variable.
+    
+    We use snyk to test our dependencies / dev-dependencies for vulnerabilities.
+
+<br />
+
+## Installation
 
-We use snyk to test our dependencies / dev-dependencies for vulnerabilities.
+After initial configuration, there are two different options to use for your local development environment.
 
-### Installing with Docker
+1) [**Docker**](#docker)
+2) [Standard Setup](#standard-setup)
 
-#### Configuration
+We **highly recommend using Docker** - both for stability and speed of setup. We also use Docker on our production servers.
 
-To configure docker configure the `.env` file to match your settings in `backend/config/default.json`.  
-The configurable ports will be how you access the services on your machine, or what ports you will need to specify in your nginx files when using proxy_pass. 
-`COMPOSE_PROJECT_NAME` should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine.
-`FRONTEND_MODE` should be either `dev` or `prod` (self-explanatory).
+<br />
 
-1. Build the backend and frontend Docker images (from the main folder)
+### **Docker**
 
-   `docker-compose build`
+___
 
-2. Set up the MongoDB database
+1. Configure the `.env` file to match your settings in `backend/config/default.json`.  
 
-   1. Set the password for the admin/root user.
+    | Property | Description |
+    | - | - |
+    | Ports | Will be how you access the services on your machine, or what ports you will need to specify in your nginx files when using proxy_pass. |
+    | `COMPOSE_PROJECT_NAME` | Should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine. |
+    | `FRONTEND_MODE` | Should be either `dev` or `prod` (self-explanatory). |
+    | `MONGO_ROOT_PASSWORD` | Password of the root/admin user of MongoDB |
+    | `MONGO_USER_USERNAME` | Password for the "musare" user (what the backend uses) of MongoDB |
 
-      In `.env` set the environment variable of `MONGO_ROOT_PASSWORD`.
 
-   2. Set the password for the musare user (the one the backend will use).
+2. Install [Docker for Desktop](https://www.docker.com/products/docker-desktop)
 
-      In `.env` set the environment variable of `MONGO_USER_USERNAME` and `MONGO_USER_PASSWORD`.
+3. Build the backend and frontend Docker images (from the root folder)
 
-   3. Start the database (in detached mode), which will generate the correct MongoDB users.
+    `docker-compose build`
 
-      `docker-compose up -d mongo`
+4. Start the MongoDB database (in detached mode), which will generate the correct MongoDB users based on the `.env` file.
 
-3. Start redis and the mongo client in the background, as we usually don't need to monitor these for errors
+    `docker-compose up -d mongo`
 
-   `docker-compose up -d mongoclient redis`
+5. If you want to use linting extensions in IDEs, then you must attach the IDE to the docker containers. This is entirely [possible with VS Code](https://code.visualstudio.com/docs/remote/containers).
 
-4. Start the backend and frontend in the foreground, so we can watch for errors during development
+<br />
 
-   `docker-compose up backend frontend`
+### **Standard Setup**
 
-5. 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`
+#### Installation
 
-   - Docker ToolBox: The output of `docker-machine ip default`
+1. Install [Redis](http://redis.io/download) and [MongoDB](https://www.mongodb.com/download-center#community)
+
+2. Install [NodeJS](https://nodejs.org/en/download/)
+
+    1. Install nodemon globally
 
-If you are using linting extensions in IDEs/want to run `yarn lint`, you need to install the following locally (outside of Docker):
+        `npm install -g nodemon`
 
-   ```bash
-   yarn global add eslint
-   yarn add eslint-config-airbnb-base
-   ```
+    2. Install node-gyp globally (first check out <https://github.com/nodejs/node-gyp#installation)>
+
+        `npm install -g node-gyp`.
+
+3. Install webpack globally
 
-### Standard Installation
+    `npm install -g webpack`
 
-Steps 1-4 are things you only have to do once. The steps to start servers follow.
 
-1. In the main folder, create a folder called `.database`
+#### Setting up MongoDB
 
-2. Create a file called `startMongo.cmd` in the main folder with the contents:
+1. In the root directory, create a folder called `.database`
 
-   "C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"
+2. Create a file called `startMongo.cmd` in the root directory with the contents:
 
-   Make sure to adjust your paths accordingly.
+    `"C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"`
 
-3. Set up the MongoDB database
+    Make sure to adjust your paths accordingly.
+
+3. Set up the MongoDB database itself
 
     1. Start the database by executing the script `startMongo.cmd` you just made
 
     2. Connect to Mongo from a command prompt
 
-       `mongo admin`
+        `mongo admin`
 
     3. Create an admin user
 
-       `db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
+        `db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
 
     4. Connect to the Musare database
 
-       `use musare`
+        `use musare`
 
-    5. Create the musare user
+    5. Create the "musare" user
 
-       `db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
+        `db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
 
     6. Exit
 
-       `exit`
+        `exit`
 
     7. Add the authentication
 
-       In `startMongo.cmd` add `--auth` at the end of the first line
+        In `startMongo.cmd` add `--auth` at the end of the first line
 
-4. In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
+#### Setting up Redis
 
-5. Create a file called `startRedis.cmd` in the main folder with the contents:
+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.
 
-   "D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"
+2. Create a file called `startRedis.cmd` in the main folder with the contents:
 
-   And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
+    `"D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"`
 
-### Non-docker start servers
+    And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
 
-#### Automatic
+<br />
 
-1. If you are on Windows you can run `windows-start.cmd` or just double click the `windows-start.cmd` file and all servers will automatically start up.
+## Everyday usage
 
-#### Manual
+<br />
 
-1. Run `startRedis.cmd` and `startMongo.cmd` to start Redis and Mongo.
+### **Docker**
 
-2. In a command prompt with the pwd of frontend, run `yarn run dev`
+___
 
-3. In a command prompt with the pwd of backend, run `nodemon`
+1. Start the MongoDB database in the background.
 
-## Extra
+    `docker-compose up -d mongo`
 
-Below is a list of helpful tips / solutions we've collected while developing MusareNode.
+2. Start redis and the mongo client in the background, as we usually don't need to monitor these for errors.
 
-### Mounting a non-standard directory in Docker Toolbox on Windows
+    `docker-compose up -d mongoclient redis`
 
-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.
+3. Start the backend and frontend in the foreground, so we can watch for errors during development.
 
-1. First lets ensure the machine isn't running
+    `docker-compose up backend frontend`
 
-   `docker-machine stop default`
+4. You should now be able to begin development!
 
-1. Next we'll want to tell the machine about the folder we want to share.
+    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://localhost:8080/`.
 
-   `"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe" sharedfolder add default --name "d/Projects/MusareNode" --hostpath "D:\Projects\MusareNode" --automount`
+<br />
 
-1. Now start the machine back up and ssh into it
+### **Standard Setup**
 
-   `docker-machine start default && docker-machine ssh default`
+___
 
-1. Tell boot2docker to mount our volume at startup, by appending to its startup script
+##### Automatic
 
-   ```bash
-   sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
+1. If you are on Windows you can 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.
 
-   mkdir -p /d/Projects/MusareNode
-   mount -t vboxsf -o uid=1000,gid=50 d/Projects/MusareNode /d/Projects/MusareNode
-   EOF
-   ```
+2. Execute `cd frontend && npm dev` and `cd backend && npm dev` separately.
 
-1. Restart the docker machine so that it uses the new shared folder
+<br />
 
-   `docker-machine restart default`
+## Extra
 
-1. You now should be good to go!
+Below is a list of helpful tips / solutions we've collected while developing MusareNode.
 
 ### Fixing the "couldn't connect to docker daemon" error
 
@@ -265,33 +264,13 @@ 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
-
-   `yarn global add nodemon`
-
-3. Install webpack globally
-
-   `yarn global add webpack`
-
-4. Install node-gyp globally (first check out https://github.com/nodejs/node-gyp#installation)
-
-   `yarn global add node-gyp`.
-
-5. Run `yarn run bootstrap` to install dependencies and dev-dependencies for both the frontend and backend.
-
-6. Either execute `yarn run dev:frontend` and `yarn run dev:backend` separately, or in parallel with `yarn dev`.
-
 ### 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";
-new Toast({ content: "", persistant: true });
+new Toast({ content: "Hi!", persistant: true });
 ```
 
 ### Set user role
@@ -306,12 +285,10 @@ db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
 db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})
 ```
 
-### Adding a package
+<br />
 
-We use lerna to add an additional package to either the frontend or the backend.
+## Contact
 
-For example, this is how we would to add the `webpack-bundle-analyser` package as a dev-dependency to the frontend:
+Get in touch with us via email at [core@musare.com](mailto:core@musare.com) or join our [Discord Guild](https://discord.gg/Y5NxYGP).
 
-```bash
-npx lerna add webpack-bundle-analyser --scope=musare-frontend --dev
-```
+You can also find us on [Facebook](https://www.facebook.com/MusareMusic) and [Twitter](https://twitter.com/MusareApp).

+ 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": []
+}

+ 13 - 0
backend/.snyk

@@ -0,0 +1,13 @@
+# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
+version: v1.14.1
+ignore: {}
+# patches apply the minimum changes required to fix a vulnerability
+patch:
+  SNYK-JS-HTTPSPROXYAGENT-469131:
+    - mailgun-js > proxy-agent > https-proxy-agent:
+        patched: '2019-10-03T21:28:13.725Z'
+    - mailgun-js > proxy-agent > pac-proxy-agent > https-proxy-agent:
+        patched: '2019-10-03T21:28:13.725Z'
+  SNYK-JS-LODASH-567746:
+    - mailgun-js > async > lodash:
+        patched: '2020-04-30T21:29:23.212Z'

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

@@ -0,0 +1,48 @@
+module.exports = class Timer {
+    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();
+        }
+    }
+
+    pause() {
+        clearTimeout(this.timerId);
+        this.remaining -= Date.now() - this.start;
+        this.timePaused = Date.now();
+        this.paused = true;
+    }
+
+    ifNotPaused() {
+        if (!this.paused) {
+            this.resume();
+        }
+    }
+
+    resume() {
+        this.start = Date.now();
+        clearTimeout(this.timerId);
+        this.timerId = setTimeout(this.callback, this.remaining);
+        this.timeWhenPaused = Date.now() - this.timePaused;
+        this.paused = false;
+    }
+
+    resetTimeWhenPaused() {
+        this.timeWhenPaused = 0;
+    }
+
+    getTimePaused() {
+        if (!this.paused) {
+            return this.timeWhenPaused;
+        } else {
+            return Date.now() - this.timePaused;
+        }
+    }
+};

+ 186 - 85
backend/core.js

@@ -1,85 +1,186 @@
-const EventEmitter = require('events');
-
-const bus = new EventEmitter();
-
-bus.setMaxListeners(1000);
-
-module.exports = class {
-	constructor(name, moduleManager) {
-		this.name = name;
-		this.moduleManager = moduleManager;
-		this.lockdown = false;
-		this.dependsOn = [];
-		this.eventHandlers = [];
-		this.state = "NOT_INITIALIZED";
-		this.stage = 0;
-		this.lastTime = 0;
-		this.totalTimeInitialize = 0;
-		this.timeDifferences = [];
-		this.failed = false;
-	}
-
-	_initialize() {
-		this.logger = this.moduleManager.modules["logger"];
-		this.setState("INITIALIZING");
-
-		this.initialize().then(() => {
-			this.setState("INITIALIZED");
-			this.setStage(0);
-			this.moduleManager.printStatus();
-		}).catch(async (err) => {			
-			this.failed = true;
-
-			this.logger.error(err.stack);
-
-			this.moduleManager.aModuleFailed(this);
-		});
-	}
-
-	_onInitialize() {
-		return new Promise(resolve => bus.once(`stateChange:${this.name}:INITIALIZED`, resolve));
-	}
-
-	_isInitialized() {
-		return new Promise(resolve => {
-			if (this.state === "INITIALIZED") resolve();
-		});
-	}
-
-	_isNotLocked() {
-		return new Promise((resolve, reject) => {
-			if (this.state === "LOCKDOWN") reject();
-			else resolve();
-		});
-	}
-
-	setState(state) {
-		this.state = state;
-		bus.emit(`stateChange:${this.name}:${state}`);
-		this.logger.info(`MODULE_STATE`, `${state}: ${this.name}`);
-	}
-
-	setStage(stage) {
-		if (stage !== 1)
-			this.totalTimeInitialize += (Date.now() - this.lastTime);
-		//this.timeDifferences.push(this.stage + ": " + (Date.now() - this.lastTime) + "ms");
-		this.timeDifferences.push(Date.now() - this.lastTime);
-
-		this.lastTime = Date.now();
-		this.stage = stage;
-		this.moduleManager.printStatus();
-	}
-
-	_validateHook() {
-		return Promise.race([this._onInitialize(), this._isInitialized()]).then(
-			() => this._isNotLocked()
-		);
-	}
-
-	_lockdown() {
-		if (this.lockdown) return;
-		this.lockdown = true;
-		this.setState("LOCKDOWN");
-		this.moduleManager.printStatus();
-	}
-}
+const async = require("async");
+
+class DeferredPromise {
+    constructor() {
+        this.promise = new Promise((resolve, reject) => {
+            this.reject = reject;
+            this.resolve = resolve;
+        });
+    }
+}
+
+class MovingAverageCalculator {
+    constructor() {
+        this.count = 0;
+        this._mean = 0;
+    }
+
+    update(newValue) {
+        this.count++;
+        const differential = (newValue - this._mean) / this.count;
+        this._mean += differential;
+    }
+
+    get mean() {
+        this.validate();
+        return this._mean;
+    }
+
+    validate() {
+        if (this.count === 0) throw new Error("Mean is undefined");
+    }
+}
+
+class CoreClass {
+    constructor(name) {
+        this.name = name;
+        this.status = "UNINITIALIZED";
+        // this.log("Core constructor");
+        this.jobQueue = async.priorityQueue(
+            (job, callback) => this._runJob(job, callback),
+            10 // How many jobs can run concurrently
+        );
+        this.jobQueue.pause();
+        this.runningJobs = [];
+        this.priorities = {};
+        this.stage = 0;
+        this.jobStatistics = {};
+
+        this.registerJobs();
+    }
+
+    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();
+    }
+
+    getStatus() {
+        return this.status;
+    }
+
+    setStage(stage) {
+        this.stage = stage;
+    }
+
+    getStage() {
+        return this.stage;
+    }
+
+    _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);
+            });
+    }
+
+    log() {
+        let _arguments = Array.from(arguments);
+        const type = _arguments[0];
+        _arguments.splice(0, 1);
+        const start = `|${this.name.toUpperCase()}|`;
+        const numberOfTabsNeeded = 4 - Math.ceil(start.length / 8);
+        _arguments.unshift(`${start}${Array(numberOfTabsNeeded).join("\t")}`);
+
+        if (type === "INFO") {
+            _arguments[0] = _arguments[0] + "\x1b[36m";
+            _arguments.push("\x1b[0m");
+            console.log.apply(null, _arguments);
+        } else if (type === "ERROR") {
+            _arguments[0] = _arguments[0] + "\x1b[31m";
+            _arguments.push("\x1b[0m");
+            console.error.apply(null, _arguments);
+        }
+    }
+
+    registerJobs() {
+        let props = [];
+        let obj = this;
+        do {
+            props = props.concat(Object.getOwnPropertyNames(obj));
+        } 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(),
+            };
+        });
+    }
+
+    runJob(name, payload, options = {}) {
+        let deferredPromise = new DeferredPromise();
+        const job = { name, payload, onFinish: deferredPromise };
+
+        if (options.bypassQueue) {
+            this._runJob(job, () => {});
+        } else {
+            const priority = this.priorities[name] ? this.priorities[name] : 10;
+            this.jobQueue.push(job, priority);
+        }
+
+        return deferredPromise.promise;
+    }
+
+    setModuleManager(moduleManager) {
+        this.moduleManager = moduleManager;
+    }
+
+    _runJob(job, cb) {
+        this.log("INFO", `Running job ${job.name}`);
+        const startTime = Date.now();
+        this.runningJobs.push(job);
+        const newThis = Object.assign(
+            Object.create(Object.getPrototypeOf(this)),
+            this
+        );
+        newThis.runJob = (...args) => {
+            if (args.length === 2) args.push({});
+            args[2].bypassQueue = true;
+            return this.runJob.apply(this, args);
+        };
+        this[job.name]
+            .apply(newThis, [job.payload])
+            .then((response) => {
+                this.log("INFO", `Ran job ${job.name} successfully`);
+                this.jobStatistics[job.name].successful++;
+                job.onFinish.resolve(response);
+            })
+            .catch((error) => {
+                this.log("INFO", `Running job ${job.name} failed`);
+                this.jobStatistics[job.name].failed++;
+                job.onFinish.reject(error);
+            })
+            .finally(() => {
+                const endTime = Date.now();
+                const executionTime = endTime - startTime;
+                this.jobStatistics[job.name].total++;
+                this.jobStatistics[job.name].averageTiming.update(
+                    executionTime
+                );
+                this.runningJobs.splice(this.runningJobs.indexOf(job), 1);
+                cb();
+            });
+    }
+}
+
+module.exports = CoreClass;

+ 339 - 157
backend/index.js

@@ -1,4 +1,4 @@
-'use strict';
+"use strict";
 
 const util = require("util");
 
@@ -6,168 +6,325 @@ process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
 
 const config = require("config");
 
-process.on('uncaughtException', err => {
-	if (err.code === 'ECONNREFUSED' || err.code === 'UNCERTAIN_STATE') return;
-	console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
+process.on("uncaughtException", (err) => {
+    if (err.code === "ECONNREFUSED" || err.code === "UNCERTAIN_STATE") return;
+    console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
 });
 
+const blacklistedConsoleLogs = [
+    "Running job IO",
+    "Ran job IO successfully",
+    "Running job HGET",
+    "Ran job HGET successfully",
+    "Running job HGETALL",
+    "Ran job HGETALL successfully",
+    "Running job GET_ERROR",
+    "Ran job GET_ERROR successfully",
+    "Running job GET_SCHEMA",
+    "Ran job GET_SCHEMA successfully",
+    "Running job SUB",
+    "Ran job SUB successfully",
+    "Running job GET_MODEL",
+    "Ran job GET_MODEL successfully",
+    "Running job HSET",
+    "Ran job HSET successfully",
+    "Running job CAN_USER_VIEW_STATION",
+    "Ran job CAN_USER_VIEW_STATION successfully",
+];
+
+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);
+};
+
 const fancyConsole = config.get("fancyConsole");
 
+// class ModuleManager {
+// 	constructor() {
+// 		this.modules = {};
+// 		this.modulesInitialized = 0;
+// 		this.totalModules = 0;
+// 		this.modulesLeft = [];
+// 		this.i = 0;
+// 		this.lockdown = false;
+// 		this.fancyConsole = fancyConsole;
+// 	}
+
+// 	addModule(moduleName) {
+// 		console.log("add module", moduleName);
+// 		const moduleClass = new require(`./logic/${moduleName}`);
+// 		this.modules[moduleName] = new moduleClass(moduleName, this);
+// 		this.totalModules++;
+// 		this.modulesLeft.push(moduleName);
+// 	}
+
+// 	initialize() {
+// 		if (!this.modules["logger"]) return console.error("There is no logger module");
+// 		this.logger = this.modules["logger"];
+// 		if (this.fancyConsole) {
+// 			this.replaceConsoleWithLogger();
+// 			this.logger.reservedLines = Object.keys(this.modules).length + 5;
+// 		}
+
+// 		for (let moduleName in this.modules) {
+// 			let module = this.modules[moduleName];
+// 			if (this.lockdown) break;
+
+// 			module._onInitialize().then(() => {
+// 				this.moduleInitialized(moduleName);
+// 			});
+
+// 			let dependenciesInitializedPromises = [];
+
+// 			module.dependsOn.forEach(dependencyName => {
+// 				let dependency = this.modules[dependencyName];
+// 				dependenciesInitializedPromises.push(dependency._onInitialize());
+// 			});
+
+// 			module.lastTime = Date.now();
+
+// 			Promise.all(dependenciesInitializedPromises).then((res, res2) => {
+// 				if (this.lockdown) return;
+// 				this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
+// 				module._initialize();
+// 			});
+// 		}
+// 	}
+
+// 	async printStatus() {
+// 		try { await Promise.race([this.logger._onInitialize(), this.logger._isInitialized()]); } catch { return; }
+// 		if (!this.fancyConsole) return;
+
+// 		let colors = this.logger.colors;
+
+// 		const rows = process.stdout.rows;
+
+// 		process.stdout.cursorTo(0, rows - this.logger.reservedLines);
+// 		process.stdout.clearScreenDown();
+
+// 		process.stdout.cursorTo(0, (rows - this.logger.reservedLines) + 2);
+
+// 		process.stdout.write(`${colors.FgYellow}Modules${colors.FgWhite}:\n`);
+
+// 		for (let moduleName in this.modules) {
+// 			let module = this.modules[moduleName];
+// 			let tabsAmount = Math.max(0, Math.ceil(2 - (moduleName.length / 8)));
+
+// 			let tabs = Array(tabsAmount).fill(`\t`).join("");
+
+// 			let timing = module.timeDifferences.map((timeDifference) => {
+// 				return `${colors.FgMagenta}${timeDifference}${colors.FgCyan}ms${colors.FgWhite}`;
+// 			}).join(", ");
+
+// 			let stateColor;
+// 			if (module.state === "NOT_INITIALIZED") stateColor = colors.FgWhite;
+// 			else if (module.state === "INITIALIZED") stateColor = colors.FgGreen;
+// 			else if (module.state === "LOCKDOWN" && !module.failed) stateColor = colors.FgRed;
+// 			else if (module.state === "LOCKDOWN" && module.failed) stateColor = colors.FgMagenta;
+// 			else stateColor = colors.FgYellow;
+
+// 			process.stdout.write(`${moduleName}${tabs}${stateColor}${module.state}\t${colors.FgYellow}Stage: ${colors.FgRed}${module.stage}${colors.FgWhite}. ${colors.FgYellow}Timing${colors.FgWhite}: [${timing}]${colors.FgWhite}${colors.FgWhite}. ${colors.FgYellow}Total time${colors.FgWhite}: ${colors.FgRed}${module.totalTimeInitialize}${colors.FgCyan}ms${colors.Reset}\n`);
+// 		}
+// 	}
+
+// 	moduleInitialized(moduleName) {
+// 		this.modulesInitialized++;
+// 		this.modulesLeft.splice(this.modulesLeft.indexOf(moduleName), 1);
+
+// 		this.logger.info("MODULE_MANAGER", `Initialized: ${this.modulesInitialized}/${this.totalModules}.`);
+
+// 		if (this.modulesLeft.length === 0) this.allModulesInitialized();
+// 	}
+
+// 	allModulesInitialized() {
+// 		this.logger.success("MODULE_MANAGER", "All modules have started!");
+// 		this.modules["discord"].sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
+// 	}
+
+// 	aModuleFailed(failedModule) {
+// 		this.logger.error("MODULE_MANAGER", `A module has failed, locking down. Module: ${failedModule.name}`);
+// 		this.modules["discord"].sendAdminAlertMessage(`The backend server failed to start due to a failing module: ${failedModule.name}.`, "#AA0000", "Startup", false, []);
+
+// 		this._lockdown();
+// 	}
+
+// 	replaceConsoleWithLogger() {
+// 		this.oldConsole = {
+// 			log: console.log,
+// 			debug: console.debug,
+// 			info: console.info,
+// 			warn: console.warn,
+// 			error: console.error
+// 		};
+// 		console.log = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+// 		console.debug = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+// 		console.info = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+// 		console.warn = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+// 		console.error = (...args) => this.logger.error("CONSOLE", args.map(arg => util.format(arg)));
+// 	}
+
+// 	replaceLoggerWithConsole() {
+// 		console.log = this.oldConsole.log;
+// 		console.debug = this.oldConsole.debug;
+// 		console.info = this.oldConsole.info;
+// 		console.warn = this.oldConsole.warn;
+// 		console.error = this.oldConsole.error;
+// 	}
+
+// 	_lockdown() {
+// 		this.lockdown = true;
+
+// 		for (let moduleName in this.modules) {
+// 			let module = this.modules[moduleName];
+// 			if (module.lockdownImmune) continue;
+// 			module._lockdown();
+// 		}
+// 	}
+// }
+
+// const moduleManager = new ModuleManager();
+
+// module.exports = moduleManager;
+
+// moduleManager.addModule("cache");
+// moduleManager.addModule("db");
+// moduleManager.addModule("mail");
+// moduleManager.addModule("api");
+// moduleManager.addModule("app");
+// moduleManager.addModule("discord");
+// moduleManager.addModule("io");
+// moduleManager.addModule("logger");
+// moduleManager.addModule("notifications");
+// moduleManager.addModule("activities");
+// moduleManager.addModule("playlists");
+// moduleManager.addModule("punishments");
+// moduleManager.addModule("songs");
+// moduleManager.addModule("spotify");
+// moduleManager.addModule("stations");
+// moduleManager.addModule("tasks");
+// moduleManager.addModule("utils");
+
+// moduleManager.initialize();
+
+// process.stdin.on("data", function (data) {
+//     if(data.toString() === "lockdown\r\n"){
+//         console.log("Locking down.");
+//        	moduleManager._lockdown();
+//     }
+// });
+
+// if (fancyConsole) {
+// 	const rows = process.stdout.rows;
+
+// 	for(let i = 0; i < rows; i++) {
+// 		process.stdout.write("\n");
+// 	}
+// }
+
 class ModuleManager {
-	constructor() {
-		this.modules = {};
-		this.modulesInitialized = 0;
-		this.totalModules = 0;
-		this.modulesLeft = [];
-		this.i = 0;
-		this.lockdown = false;
-		this.fancyConsole = fancyConsole;
-	}
-
-	addModule(moduleName) {
-		console.log("add module", moduleName);
-		const moduleClass = new require(`./logic/${moduleName}`);
-		this.modules[moduleName] = new moduleClass(moduleName, this);
-		this.totalModules++;
-		this.modulesLeft.push(moduleName);
-	}
-
-	initialize() {
-		if (!this.modules["logger"]) return console.error("There is no logger module");
-		this.logger = this.modules["logger"];
-		if (this.fancyConsole) {
-			this.replaceConsoleWithLogger();
-			this.logger.reservedLines = Object.keys(this.modules).length + 5;
-		}
-		
-		for (let moduleName in this.modules) {
-			let module = this.modules[moduleName];
-			if (this.lockdown) break;
-
-			module._onInitialize().then(() => {
-				this.moduleInitialized(moduleName);
-			});
-
-			let dependenciesInitializedPromises = [];
-			
-			module.dependsOn.forEach(dependencyName => {
-				let dependency = this.modules[dependencyName];
-				dependenciesInitializedPromises.push(dependency._onInitialize());
-			});
-
-			module.lastTime = Date.now();
-
-			Promise.all(dependenciesInitializedPromises).then((res, res2) => {
-				if (this.lockdown) return;
-				this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
-				module._initialize();
-			});
-		}
-	}
-
-	async printStatus() {
-		try { await Promise.race([this.logger._onInitialize(), this.logger._isInitialized()]); } catch { return; }
-		if (!this.fancyConsole) return;
-		
-		let colors = this.logger.colors;
-
-		const rows = process.stdout.rows;
-
-		process.stdout.cursorTo(0, rows - this.logger.reservedLines);
-		process.stdout.clearScreenDown();
-
-		process.stdout.cursorTo(0, (rows - this.logger.reservedLines) + 2);
-
-		process.stdout.write(`${colors.FgYellow}Modules${colors.FgWhite}:\n`);
-
-		for (let moduleName in this.modules) {
-			let module = this.modules[moduleName];
-			let tabsAmount = Math.max(0, Math.ceil(2 - (moduleName.length / 8)));
-
-			let tabs = Array(tabsAmount).fill(`\t`).join("");
-
-			let timing = module.timeDifferences.map((timeDifference) => {
-				return `${colors.FgMagenta}${timeDifference}${colors.FgCyan}ms${colors.FgWhite}`;
-			}).join(", ");
-
-			let stateColor;
-			if (module.state === "NOT_INITIALIZED") stateColor = colors.FgWhite;
-			else if (module.state === "INITIALIZED") stateColor = colors.FgGreen;
-			else if (module.state === "LOCKDOWN" && !module.failed) stateColor = colors.FgRed;
-			else if (module.state === "LOCKDOWN" && module.failed) stateColor = colors.FgMagenta;
-			else stateColor = colors.FgYellow;
-			
-			process.stdout.write(`${moduleName}${tabs}${stateColor}${module.state}\t${colors.FgYellow}Stage: ${colors.FgRed}${module.stage}${colors.FgWhite}. ${colors.FgYellow}Timing${colors.FgWhite}: [${timing}]${colors.FgWhite}${colors.FgWhite}. ${colors.FgYellow}Total time${colors.FgWhite}: ${colors.FgRed}${module.totalTimeInitialize}${colors.FgCyan}ms${colors.Reset}\n`);
-		}
-	}
-
-	moduleInitialized(moduleName) {
-		this.modulesInitialized++;
-		this.modulesLeft.splice(this.modulesLeft.indexOf(moduleName), 1);
-
-		this.logger.info("MODULE_MANAGER", `Initialized: ${this.modulesInitialized}/${this.totalModules}.`);
-
-		if (this.modulesLeft.length === 0) this.allModulesInitialized();
-	}
-
-	allModulesInitialized() {
-		this.logger.success("MODULE_MANAGER", "All modules have started!");
-		this.modules["discord"].sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
-	}
-
-	aModuleFailed(failedModule) {
-		this.logger.error("MODULE_MANAGER", `A module has failed, locking down. Module: ${failedModule.name}`);
-		this.modules["discord"].sendAdminAlertMessage(`The backend server failed to start due to a failing module: ${failedModule.name}.`, "#AA0000", "Startup", false, []);
-
-		this._lockdown();
-	}
-
-	replaceConsoleWithLogger() {
-		this.oldConsole = {
-			log: console.log,
-			debug: console.debug,
-			info: console.info,
-			warn: console.warn,
-			error: console.error
-		};
-		console.log = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-		console.debug = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-		console.info = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-		console.warn = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
-		console.error = (...args) => this.logger.error("CONSOLE", args.map(arg => util.format(arg)));
-	}
-
-	replaceLoggerWithConsole() {
-		console.log = this.oldConsole.log;
-		console.debug = this.oldConsole.debug;
-		console.info = this.oldConsole.info;
-		console.warn = this.oldConsole.warn;
-		console.error = this.oldConsole.error;
-	}
-
-	_lockdown() {
-		this.lockdown = true;
-		
-		for (let moduleName in this.modules) {
-			let module = this.modules[moduleName];
-			if (module.lockdownImmune) continue;
-			module._lockdown();
-		}
-	}
+    constructor() {
+        this.modules = {};
+        this.modulesNotInitialized = [];
+        this.i = 0;
+        this.lockdown = false;
+        this.fancyConsole = fancyConsole;
+    }
+
+    addModule(moduleName) {
+        console.log("add module", moduleName);
+        const module = require(`./logic/${moduleName}`);
+        this.modules[moduleName] = module;
+        this.modulesNotInitialized.push(module);
+    }
+
+    initialize() {
+        // if (!this.modules["logger"]) return console.error("There is no logger module");
+        // this.logger = this.modules["logger"];
+        // if (this.fancyConsole) {
+        // this.replaceConsoleWithLogger();
+        this.reservedLines = Object.keys(this.modules).length + 5;
+        // }
+
+        for (let moduleName in this.modules) {
+            let module = this.modules[moduleName];
+            module.setModuleManager(this);
+
+            if (this.lockdown) break;
+
+            module._initialize();
+
+            // let dependenciesInitializedPromises = [];
+
+            // module.dependsOn.forEach(dependencyName => {
+            // 	let dependency = this.modules[dependencyName];
+            // 	dependenciesInitializedPromises.push(dependency._onInitialize());
+            // });
+
+            // module.lastTime = Date.now();
+
+            // Promise.all(dependenciesInitializedPromises).then((res, res2) => {
+            // 	if (this.lockdown) return;
+            // 	this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
+            // 	module._initialize();
+            // });
+        }
+    }
+
+    onInitialize(module) {
+        if (this.modulesNotInitialized.indexOf(module) !== -1) {
+            this.modulesNotInitialized.splice(
+                this.modulesNotInitialized.indexOf(module),
+                1
+            );
+
+            console.log(
+                "MODULE_MANAGER",
+                `Initialized: ${Object.keys(this.modules).length -
+                    this.modulesNotInitialized.length}/${
+                    Object.keys(this.modules).length
+                }.`
+            );
+
+            if (this.modulesNotInitialized.length === 0)
+                this.onAllModulesInitialized();
+        }
+    }
+
+    onFail(module) {
+        if (this.modulesNotInitialized.indexOf(module) !== -1) {
+            console.log("A module failed to initialize!");
+        }
+    }
+
+    onAllModulesInitialized() {
+        console.log("All modules initialized!");
+        this.modules["discord"].runJob("SEND_ADMIN_ALERT_MESSAGE", {
+            message: "The backend server started successfully.",
+            color: "#00AA00",
+            type: "Startup",
+            critical: false,
+            extraFields: [],
+        });
+    }
 }
 
 const moduleManager = new ModuleManager();
 
-module.exports = moduleManager;
-
 moduleManager.addModule("cache");
 moduleManager.addModule("db");
 moduleManager.addModule("mail");
+moduleManager.addModule("activities");
 moduleManager.addModule("api");
 moduleManager.addModule("app");
 moduleManager.addModule("discord");
 moduleManager.addModule("io");
-moduleManager.addModule("logger");
 moduleManager.addModule("notifications");
 moduleManager.addModule("playlists");
 moduleManager.addModule("punishments");
@@ -179,18 +336,43 @@ moduleManager.addModule("utils");
 
 moduleManager.initialize();
 
-process.stdin.on("data", function (data) {
-    if(data.toString() === "lockdown\r\n"){
+process.stdin.on("data", function(data) {
+    const command = data.toString().replace(/\r?\n|\r/g, "");
+    if (command === "lockdown") {
         console.log("Locking down.");
-       	moduleManager._lockdown();
+        moduleManager._lockdown();
     }
-});
+    if (command === "status") {
+        console.log("Status:");
+
+        for (let moduleName in moduleManager.modules) {
+            let 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.length()}. Jobs in progress: ${module.jobQueue.running()}. Concurrency: ${
+                    module.jobQueue.concurrency
+                }. Stage: ${module.getStage()}`
+            );
+        }
+        // moduleManager._lockdown();
+    }
+    if (command.startsWith("running")) {
+        const parts = command
+            .split(" ");
 
+        console.log(moduleManager.modules[parts[1]].runningJobs);
+    }
+    if (command.startsWith("stats")) {
+        const parts = command
+            .split(" ");
 
-if (fancyConsole) {
-	const rows = process.stdout.rows;
+        console.log(moduleManager.modules[parts[1]].jobStatistics);
+    }
+    if (command.startsWith("debug")) {
+        moduleManager.modules["utils"].runJob("DEBUG");
+    }
+});
 
-	for(let i = 0; i < rows; i++) {
-		process.stdout.write("\n");
-	}
-}
+module.exports = moduleManager;

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

@@ -0,0 +1,99 @@
+"use strict";
+
+const async = require("async");
+
+const hooks = require("./hooks");
+
+const db = require("../db");
+const utils = require("../utils");
+const activities = require("../activities");
+
+// const logger = moduleManager.modules["logger"];
+
+module.exports = {
+    /**
+     * Gets a set of activities
+     *
+     * @param session
+     * @param {String} userId - the user whose activities we are looking for
+     * @param {Integer} set - the set number to return
+     * @param cb
+     */
+    getSet: async (session, userId, set, cb) => {
+        const activityModel = await db.runJob("GET_MODEL", {
+            modelName: "activity",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    activityModel
+                        .find({ userId, hidden: false })
+                        .skip(15 * (set - 1))
+                        .limit(15)
+                        .sort("createdAt")
+                        .exec(next);
+                },
+            ],
+            async (err, activities) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "ACTIVITIES_GET_SET",
+                        `Failed to get set ${set} from activities. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+
+                console.log(
+                    "SUCCESS",
+                    "ACTIVITIES_GET_SET",
+                    `Set ${set} from activities obtained successfully.`
+                );
+                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: hooks.loginRequired(async (session, activityId, cb) => {
+        const activityModel = await db.runJob("GET_MODEL", {
+            modelName: "activity",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    activityModel.updateOne(
+                        { _id: activityId },
+                        { $set: { hidden: true } },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "ACTIVITIES_HIDE_ACTIVITY",
+                        `Failed to hide activity ${activityId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+
+                console.log(
+                    "SUCCESS",
+                    "ACTIVITIES_HIDE_ACTIVITY",
+                    `Successfully hid activity ${activityId}.`
+                );
+                cb({ status: "success" });
+            }
+        );
+    }),
+};

+ 188 - 134
backend/logic/actions/apis.js

@@ -1,152 +1,206 @@
-'use strict';
+"use strict";
 
 const request = require("request");
 const config = require("config");
 const async = require("async");
 
-const hooks = require('./hooks');
-const moduleManager = require("../../index");
+const hooks = require("./hooks");
+// const moduleManager = require("../../index");
 
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
+const utils = require("../utils");
+// const logger = moduleManager.modules["logger"];
 
 module.exports = {
+    /**
+     * Fetches a list of songs from Youtubes API
+     *
+     * @param session
+     * @param query - the query we'll pass to youtubes api
+     * @param cb
+     * @return {{ status: String, data: Object }}
+     */
+    searchYoutube: (session, query, cb) => {
+        const params = [
+            "part=snippet",
+            `q=${encodeURIComponent(query)}`,
+            `key=${config.get("apis.youtube.key")}`,
+            "type=video",
+            "maxResults=15",
+        ].join("&");
 
-	/**
-	 * Fetches a list of songs from Youtubes API
-	 *
-	 * @param session
-	 * @param query - the query we'll pass to youtubes api
-	 * @param cb
-	 * @return {{ status: String, data: Object }}
-	 */
-	searchYoutube: (session, query, cb) => {
-		const params = [
-			'part=snippet',
-			`q=${encodeURIComponent(query)}`,
-			`key=${config.get('apis.youtube.key')}`,
-			'type=video',
-			'maxResults=15'
-		].join('&');
+        async.waterfall(
+            [
+                (next) => {
+                    request(
+                        `https://www.googleapis.com/youtube/v3/search?${params}`,
+                        next
+                    );
+                },
 
-		async.waterfall([
-			(next) => {
-				request(`https://www.googleapis.com/youtube/v3/search?${params}`, next);
-			},
+                (res, body, next) => {
+                    next(null, JSON.parse(body));
+                },
+            ],
+            async (err, data) => {
+                console.log(data.error);
+                if (err || data.error) {
+                    if (!err) err = data.error.message;
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "APIS_SEARCH_YOUTUBE",
+                        `Searching youtube failed with query "${query}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "APIS_SEARCH_YOUTUBE",
+                    `Searching YouTube successful with query "${query}".`
+                );
+                return cb({ status: "success", data });
+            }
+        );
+    },
 
-			(res, body, next) => {
-				next(null, JSON.parse(body));
-			}
-		], async (err, data) => {
-			console.log(data.error);
-			if (err || data.error) {
-				if (!err) err = data.error.message;
-				err = await utils.getError(err);
-				logger.error("APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.success("APIS_SEARCH_YOUTUBE", `Searching YouTube successful with query "${query}".`);
-			return cb({ status: 'success', data });
-		});
-	},
+    /**
+     * Gets Spotify data
+     *
+     * @param session
+     * @param title - the title of the song
+     * @param artist - an artist for that song
+     * @param cb
+     */
+    getSpotifySongs: hooks.adminRequired((session, title, artist, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    utils
+                        .runJob("GET_SONGS_FROM_SPOTIFY", { title, artist })
+                        .then((songs) => next(null, songs))
+                        .catch(next);
+                },
+            ],
+            (songs) => {
+                console.log(
+                    "SUCCESS",
+                    "APIS_GET_SPOTIFY_SONGS",
+                    `User "${session.userId}" got Spotify songs for title "${title}" successfully.`
+                );
+                cb({ status: "success", songs: songs });
+            }
+        );
+    }),
 
-	/**
-	 * Gets Spotify data
-	 *
-	 * @param session
-	 * @param title - the title of the song
-	 * @param artist - an artist for that song
-	 * @param cb
-	 */
-	getSpotifySongs: hooks.adminRequired((session, title, artist, cb) => {
-		async.waterfall([
-			(next) => {
-				utils.getSongsFromSpotify(title, artist, next);
-			}
-		], (songs) => {
-			logger.success('APIS_GET_SPOTIFY_SONGS', `User "${session.userId}" got Spotify songs for title "${title}" successfully.`);
-			cb({status: 'success', songs: songs});
-		});
-	}),
+    /**
+     * Gets Discogs data
+     *
+     * @param session
+     * @param query - the query
+     * @param cb
+     */
+    searchDiscogs: hooks.adminRequired((session, query, page, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    const params = [
+                        `q=${encodeURIComponent(query)}`,
+                        `per_page=20`,
+                        `page=${page}`,
+                    ].join("&");
 
-	/**
-	 * Gets Discogs data
-	 *
-	 * @param session
-	 * @param query - the query
-	 * @param cb
-	 */
-	searchDiscogs: hooks.adminRequired((session, query, page, cb) => {
-		async.waterfall([
-			(next) => {
-				const params = [
-					`q=${encodeURIComponent(query)}`,
-					`per_page=20`,
-					`page=${page}`
-				].join('&');
-		
-				const options = {
-					url: `https://api.discogs.com/database/search?${params}`,
-					headers: {
-						"User-Agent": "Request",
-						"Authorization": `Discogs key=${config.get("apis.discogs.client")}, secret=${config.get("apis.discogs.secret")}`
-					}
-				};
-		
-				request(options, (err, res, body) => {
-					if (err) next(err);
-					body = JSON.parse(body);
-					next(null, body);
-					if (body.error) next(body.error);
-				});
-			}
-		], async (err, body) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("APIS_SEARCH_DISCOGS", `Searching discogs failed with query "${query}". "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.success('APIS_SEARCH_DISCOGS', `User "${session.userId}" searched Discogs succesfully for query "${query}".`);
-			cb({status: 'success', results: body.results, pages: body.pagination.pages});
-		});
-	}),
+                    const options = {
+                        url: `https://api.discogs.com/database/search?${params}`,
+                        headers: {
+                            "User-Agent": "Request",
+                            Authorization: `Discogs key=${config.get(
+                                "apis.discogs.client"
+                            )}, secret=${config.get("apis.discogs.secret")}`,
+                        },
+                    };
 
-	/**
-	 * Joins a room
-	 *
-	 * @param session
-	 * @param page - the room to join
-	 * @param cb
-	 */
-	joinRoom: (session, page, cb) => {
-		if (page === 'home') {
-			utils.socketJoinRoom(session.socketId, page);
-		}
-		cb({});
-	},
+                    request(options, (err, res, body) => {
+                        if (err) next(err);
+                        body = JSON.parse(body);
+                        next(null, body);
+                        if (body.error) next(body.error);
+                    });
+                },
+            ],
+            async (err, body) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "APIS_SEARCH_DISCOGS",
+                        `Searching discogs failed with query "${query}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "APIS_SEARCH_DISCOGS",
+                    `User "${session.userId}" searched Discogs succesfully for query "${query}".`
+                );
+                cb({
+                    status: "success",
+                    results: body.results,
+                    pages: body.pagination.pages,
+                });
+            }
+        );
+    }),
 
-	/**
-	 * Joins an admin room
-	 *
-	 * @param session
-	 * @param page - the admin room to join
-	 * @param cb
-	 */
-	joinAdminRoom: hooks.adminRequired((session, page, cb) => {
-		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news' || page === 'users' || page === 'statistics' || page === 'punishments') {
-			utils.socketJoinRoom(session.socketId, `admin.${page}`);
-		}
-		cb({});
-	}),
+    /**
+     * Joins a room
+     *
+     * @param session
+     * @param page - the room to join
+     * @param cb
+     */
+    joinRoom: (session, page, cb) => {
+        if (page === "home") {
+            utils.runJob("SOCKET_JOIN_ROOM", {
+                socketId: session.socketId,
+                room: page,
+            });
+        }
+        cb({});
+    },
 
-	/**
-	 * Returns current date
-	 *
-	 * @param session
-	 * @param cb
-	 */
-	ping: (session, cb) => {
-		cb({date: Date.now()});
-	}
+    /**
+     * Joins an admin room
+     *
+     * @param session
+     * @param page - the admin room to join
+     * @param cb
+     */
+    joinAdminRoom: hooks.adminRequired((session, page, cb) => {
+        if (
+            page === "queue" ||
+            page === "songs" ||
+            page === "stations" ||
+            page === "reports" ||
+            page === "news" ||
+            page === "users" ||
+            page === "statistics" ||
+            page === "punishments"
+        ) {
+            utils.runJob("SOCKET_JOIN_ROOM", {
+                socketId: session.socketId,
+                room: `admin.${page}`,
+            });
+        }
+        cb({});
+    }),
 
+    /**
+     * Returns current date
+     *
+     * @param session
+     * @param cb
+     */
+    ping: (session, cb) => {
+        cb({ date: Date.now() });
+    },
 };

+ 54 - 36
backend/logic/actions/hooks/adminRequired.js

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

+ 45 - 30
backend/logic/actions/hooks/loginRequired.js

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

+ 66 - 40
backend/logic/actions/hooks/ownerRequired.js

@@ -1,45 +1,71 @@
-const async = require('async');
+const async = require("async");
 
 const moduleManager = require("../../../index");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const stations = moduleManager.modules["stations"];
+const db = require("../../db");
+const cache = require("../../cache");
+const utils = require("../../utils");
+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];
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
-			(session, next) => {
-				if (!session || !session.userId) return next('Login required.');
-				this.session = session;
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-			(user, next) => {
-				if (!user) return next('Login required.');
-				if (user.role === 'admin') return next(true);
-				stations.getStation(stationId, next);
-			},
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.type === 'community' && station.owner === session.userId) return next(true);
-				next('Invalid permissions.');
-			}
-		], async (err) => {
-			if (err !== true) {
-				err = await utils.getError(err);
-				logger.info("OWNER_REQUIRED", `User failed to pass owner required check for station "${stationId}". "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.info("OWNER_REQUIRED", `User "${session.userId}" passed owner required check for station "${stationId}"`, false);
-			next.apply(null, args);
-		});
-	}
-};
+    return async function(session, stationId) {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        let args = [];
+        for (let prop in arguments) args.push(arguments[prop]);
+        let cb = args[args.length - 1];
+        async.waterfall(
+            [
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then((session) => next(null, session))
+                        .catch(next);
+                },
+                (session, next) => {
+                    if (!session || !session.userId)
+                        return next("Login required.");
+                    this.session = session;
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+                (user, next) => {
+                    if (!user) return next("Login required.");
+                    if (user.role === "admin") return next(true);
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .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);
+                    next("Invalid permissions.");
+                },
+            ],
+            async (err) => {
+                if (err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "INFO",
+                        "OWNER_REQUIRED",
+                        `User failed to pass owner required check for station "${stationId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "INFO",
+                    "OWNER_REQUIRED",
+                    `User "${session.userId}" passed owner required check for station "${stationId}"`,
+                    false
+                );
+                next.apply(null, args);
+            }
+        );
+    };
+};

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

@@ -1,13 +1,15 @@
-'use strict';
+"use strict";
 
 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'),
-	punishments: require('./punishments')
+    apis: require("./apis"),
+    songs: require("./songs"),
+    queueSongs: require("./queueSongs"),
+    stations: require("./stations"),
+    playlists: require("./playlists"),
+    users: require("./users"),
+    activities: require("./activities"),
+    reports: require("./reports"),
+    news: require("./news"),
+    punishments: require("./punishments"),
+    utils: require("./utils"),
 };

+ 226 - 139
backend/logic/actions/news.js

@@ -1,155 +1,242 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 const moduleManager = require("../../index");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
+const db = require("../db");
+const cache = require("../cache");
+const utils = require("../utils");
+// const logger = require("logger");
 
-cache.sub('news.create', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.created', news);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "news.create",
+    cb: (news) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: news.createdBy,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:admin.news.created", news);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('news.remove', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.removed', news);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "news.remove",
+    cb: (news) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: news.createdBy,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:admin.news.removed", news);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('news.update', news => {
-	utils.socketsFromUser(news.createdBy, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:admin.news.updated', news);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "news.update",
+    cb: (news) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: news.createdBy,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:admin.news.updated", news);
+                });
+            },
+        });
+    },
 });
 
 module.exports = {
+    /**
+     * Gets all news items
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    index: async (session, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        async.waterfall(
+            [
+                (next) => {
+                    newsModel
+                        .find({})
+                        .sort({ createdAt: "desc" })
+                        .exec(next);
+                },
+            ],
+            async (err, news) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "NEWS_INDEX",
+                        `Indexing news failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "NEWS_INDEX",
+                    `Indexing news successful.`,
+                    false
+                );
+                return cb({ status: "success", data: news });
+            }
+        );
+    },
 
-	/**
-	 * Gets all news items
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	index: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
-			}
-		], async (err, news) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_INDEX", `Indexing news failed. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.success("NEWS_INDEX", `Indexing news successful.`, false);
-			return cb({ status: 'success', data: news });
-		});
-	},
+    /**
+     * Creates a news item
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Object} data - the object of the news data
+     * @param {Function} cb - gets called with the result
+     */
+    create: hooks.adminRequired(async (session, data, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        async.waterfall(
+            [
+                (next) => {
+                    data.createdBy = session.userId;
+                    data.createdAt = Date.now();
+                    newsModel.create(data, next);
+                },
+            ],
+            async (err, news) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "NEWS_CREATE",
+                        `Creating news failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", { channel: "news.create", value: news });
+                console.log(
+                    "SUCCESS",
+                    "NEWS_CREATE",
+                    `Creating news successful.`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully created News",
+                });
+            }
+        );
+    }),
 
-	/**
-	 * Creates a news item
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} data - the object of the news data
-	 * @param {Function} cb - gets called with the result
-	 */
-	create: hooks.adminRequired((session, data, cb) => {
-		async.waterfall([
-			(next) => {
-				data.createdBy = session.userId;
-				data.createdAt = Date.now();
-				db.models.news.create(data, next);
-			}
-		], async (err, news) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_CREATE", `Creating news failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			}
-			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
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    newest: async (session, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        async.waterfall(
+            [
+                (next) => {
+                    newsModel
+                        .findOne({})
+                        .sort({ createdAt: "desc" })
+                        .exec(next);
+                },
+            ],
+            async (err, news) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "NEWS_NEWEST",
+                        `Getting the latest news failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "NEWS_NEWEST",
+                    `Successfully got the latest news.`,
+                    false
+                );
+                return cb({ status: "success", data: news });
+            }
+        );
+    },
 
-	/**
-	 * Gets the latest news item
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	newest: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
-			}
-		], async (err, news) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			}
-			logger.success("NEWS_NEWEST", `Successfully got the latest news.`, false);
-			return cb({ status: 'success', data: news });
-		});
-	},
+    /**
+     * Removes a news item
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Object} news - the news object
+     * @param {Function} cb - gets called with the result
+     */
+    //TODO Pass in an id, not an object
+    //TODO Fix this
+    remove: hooks.adminRequired(async (session, news, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        newsModel.deleteOne({ _id: news._id }, async (err) => {
+            if (err) {
+                err = await utils.runJob("GET_ERROR", { error: err });
+                console.log(
+                    "ERROR",
+                    "NEWS_REMOVE",
+                    `Removing news "${news._id}" failed for user "${session.userId}". "${err}"`
+                );
+                return cb({ status: "failure", message: err });
+            } else {
+                cache.runJob("PUB", { channel: "news.remove", value: news });
+                console.log(
+                    "SUCCESS",
+                    "NEWS_REMOVE",
+                    `Removing news "${news._id}" successful by user "${session.userId}".`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully removed News",
+                });
+            }
+        });
+    }),
 
-	/**
-	 * Removes a news item
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} news - the news object
-	 * @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) => {
-		db.models.news.deleteOne({ _id: news._id }, async err => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${session.userId}". "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			} else {
-				cache.pub('news.remove', news);
-				logger.success("NEWS_REMOVE", `Removing news "${news._id}" successful by user "${session.userId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully removed News' });
-			}
-		});
-	}),
-
-	/**
-	 * Removes 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 {Function} cb - gets called with the result
-	 */
-	//TODO Fix this
-	update: hooks.adminRequired((session, _id, news, cb) => {
-		db.models.news.updateOne({ _id }, news, { upsert: true }, async err => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${session.userId}". "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			} else {
-				cache.pub('news.update', news);
-				logger.success("NEWS_UPDATE", `Updating news "${_id}" successful for user "${session.userId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully updated News' });
-			}
-		});
-	}),
-
-};
+    /**
+     * Removes 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 {Function} cb - gets called with the result
+     */
+    //TODO Fix this
+    update: hooks.adminRequired(async (session, _id, news, cb) => {
+        const newsModel = await db.runJob("GET_MODEL", { modelName: "news" });
+        newsModel.updateOne({ _id }, news, { upsert: true }, async (err) => {
+            if (err) {
+                err = await utils.runJob("GET_ERROR", { error: err });
+                console.log(
+                    "ERROR",
+                    "NEWS_UPDATE",
+                    `Updating news "${_id}" failed for user "${session.userId}". "${err}"`
+                );
+                return cb({ status: "failure", message: err });
+            } else {
+                cache.runJob("PUB", { channel: "news.update", value: news });
+                console.log(
+                    "SUCCESS",
+                    "NEWS_UPDATE",
+                    `Updating news "${_id}" successful for user "${session.userId}".`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully updated News",
+                });
+            }
+        });
+    }),
+};

+ 1149 - 523
backend/logic/actions/playlists.js

@@ -1,547 +1,1173 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 const moduleManager = require("../../index");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const playlists = moduleManager.modules["playlists"];
-const songs = moduleManager.modules["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);
-				});
-			});
-		}
-	});
+const db = require("../db");
+const cache = require("../cache");
+const utils = require("../utils");
+const playlists = require("../playlists");
+const songs = require("../songs");
+const activities = require("../activities");
+
+cache.runJob("SUB", {
+    channel: "playlist.create",
+    cb: (playlistId) => {
+        playlists.runJob("GET_PLAYLIST", { playlistId }).then((playlist) => {
+            utils
+                .runJob("SOCKETS_FROM_USER", { userId: playlist.createdBy })
+                .then((response) => {
+                    response.sockets.forEach((socket) => {
+                        socket.emit("event:playlist.create", playlist);
+                    });
+                });
+        });
+    },
 });
 
-cache.sub('playlist.delete', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.delete', res.playlistId);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.delete",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:playlist.delete", res.playlistId);
+                });
+            });
+    },
 });
 
-cache.sub('playlist.moveSongToTop', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.moveSongToTop', {playlistId: res.playlistId, songId: res.songId});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.moveSongToTop",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:playlist.moveSongToTop", {
+                        playlistId: res.playlistId,
+                        songId: res.songId,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('playlist.moveSongToBottom', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.moveSongToBottom', {playlistId: res.playlistId, songId: res.songId});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.moveSongToBottom",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:playlist.moveSongToBottom", {
+                        playlistId: res.playlistId,
+                        songId: res.songId,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('playlist.addSong', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.addSong', { playlistId: res.playlistId, song: res.song });
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.addSong",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:playlist.addSong", {
+                        playlistId: res.playlistId,
+                        song: res.song,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('playlist.removeSong', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.removeSong', { playlistId: res.playlistId, songId: res.songId });
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.removeSong",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:playlist.removeSong", {
+                        playlistId: res.playlistId,
+                        songId: res.songId,
+                    });
+                });
+            });
+    },
 });
 
-cache.sub('playlist.updateDisplayName', res => {
-	utils.socketsFromUser(res.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:playlist.updateDisplayName', { playlistId: res.playlistId, displayName: res.displayName });
-		});
-	});
+cache.runJob("SUB", {
+    channel: "playlist.updateDisplayName",
+    cb: (res) => {
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: res.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:playlist.updateDisplayName", {
+                        playlistId: res.playlistId,
+                        displayName: res.displayName,
+                    });
+                });
+            });
+    },
 });
 
 let lib = {
+    /**
+     * Gets the first song from a private playlist
+     *
+     * @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 {Function} cb - gets called with the result
+     */
+    getFirstSong: hooks.loginRequired((session, playlistId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    playlists
+                        .runJob("GET_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+
+                (playlist, next) => {
+                    if (!playlist || playlist.createdBy !== session.userId)
+                        return next("Playlist not found.");
+                    next(null, playlist.songs[0]);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_GET_FIRST_SONG",
+                        `Getting the first song of playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_GET_FIRST_SONG",
+                    `Successfully got the first song of playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    song: song,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Gets all playlists for the user requesting it
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    indexForUser: hooks.loginRequired(async (session, cb) => {
+        const playlistModel = await db.runJob("GET_MODEL", {
+            modelName: "playlist",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    playlistModel.find({ createdBy: session.userId }, next);
+                },
+            ],
+            async (err, playlists) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_INDEX_FOR_USER",
+                        `Indexing playlists for user "${session.userId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_INDEX_FOR_USER",
+                    `Successfully indexed playlists for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    data: playlists,
+                });
+            }
+        );
+    }),
+
+    /**
+     * 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 {Function} cb - gets called with the result
+     */
+    create: hooks.loginRequired(async (session, data, cb) => {
+        const playlistModel = await db.runJob("GET_MODEL", {
+            modelName: "playlist",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    return data
+                        ? next()
+                        : cb({ status: "failure", message: "Invalid data" });
+                },
+
+                (next) => {
+                    const { displayName, songs } = data;
+                    playlistModel.create(
+                        {
+                            displayName,
+                            songs,
+                            createdBy: session.userId,
+                            createdAt: Date.now(),
+                        },
+                        next
+                    );
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_CREATE",
+                        `Creating private playlist failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", {
+                    channel: "playlist.create",
+                    value: playlist._id,
+                });
+                activities.runJob("ADD_ACTIVITY", {
+                    userId: session.userId,
+                    activityType: "created_playlist",
+                    payload: [playlist._id],
+                });
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_CREATE",
+                    `Successfully created private playlist for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    message: "Successfully created playlist",
+                    data: {
+                        _id: playlist._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 {Function} cb - gets called with the result
+     */
+    getPlaylist: hooks.loginRequired((session, playlistId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    playlists
+                        .runJob("GET_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+
+                (playlist, next) => {
+                    if (!playlist || playlist.createdBy !== session.userId)
+                        return next("Playlist not found");
+                    next(null, playlist);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_GET",
+                        `Getting private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_GET",
+                    `Successfully got private playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    data: playlist,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Obtains basic metadata of a playlist in order to format an activity
+     *
+     * @param session
+     * @param playlistId - the playlist id
+     * @param cb
+     */
+    getPlaylistForActivity: (session, playlistId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    playlists
+                        .runJob("GET_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY",
+                        `Failed to obtain metadata of playlist ${playlistId} for activity formatting. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "PLAYLISTS_GET_PLAYLIST_FOR_ACTIVITY",
+                        `Obtained metadata of playlist ${playlistId} for activity formatting successfully.`
+                    );
+                    cb({
+                        status: "success",
+                        data: {
+                            title: playlist.displayName,
+                        },
+                    });
+                }
+            }
+        );
+    },
+
+    //TODO Remove this
+    /**
+     * Updates 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 {Function} cb - gets called with the result
+     */
+    update: hooks.loginRequired(async (session, playlistId, playlist, cb) => {
+        const playlistModel = await db.runJob("GET_MODEL", {
+            modelName: "playlist",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    playlistModel.updateOne(
+                        { _id: playlistId, createdBy: session.userId },
+                        playlist,
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    playlists
+                        .runJob("UPDATE_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_UPDATE",
+                        `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_UPDATE",
+                    `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    data: playlist,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Updates 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 {Function} cb - gets called with the result
+     */
+    shuffle: hooks.loginRequired(async (session, playlistId, cb) => {
+        const playlistModel = await db.runJob("GET_MODEL", {
+            modelName: "playlist",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!playlistId) return next("No playlist id.");
+                    playlistModel.findById(playlistId, next);
+                },
+
+                (playlist, next) => {
+                    utils
+                        .runJob("SHUFFLE", { array: playlist.songs })
+                        .then((result) => next(null, result.array))
+                        .catch(next);
+                },
+
+                (songs, next) => {
+                    playlistModel.updateOne(
+                        { _id: playlistId },
+                        { $set: { songs } },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    playlists
+                        .runJob("UPDATE_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_SHUFFLE",
+                        `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_SHUFFLE",
+                    `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cb({
+                    status: "success",
+                    message: "Successfully shuffled playlist.",
+                    data: playlist,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Adds a song to a private playlist
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Boolean} isSet - is the song part of a set of songs to be added
+     * @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 {Function} cb - gets called with the result
+     */
+    addSongToPlaylist: hooks.loginRequired(
+        async (session, isSet, songId, playlistId, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => {
+                                if (
+                                    !playlist ||
+                                    playlist.createdBy !== session.userId
+                                )
+                                    return next(
+                                        "Something went wrong when trying to get the playlist"
+                                    );
+
+                                async.each(
+                                    playlist.songs,
+                                    (song, next) => {
+                                        if (song.songId === songId)
+                                            return next(
+                                                "That song is already in the playlist"
+                                            );
+                                        next();
+                                    },
+                                    next
+                                );
+                            })
+                            .catch(next);
+                    },
+                    (next) => {
+                        songs
+                            .runJob("GET_SONG", { id: songId })
+                            .then((response) => {
+                                const song = response.song;
+                                next(null, {
+                                    _id: song._id,
+                                    songId: songId,
+                                    title: song.title,
+                                    duration: song.duration,
+                                });
+                            })
+                            .catch(() => {
+                                utils
+                                    .runJob("GET_SONG_FROM_YOUTUBE", { songId })
+                                    .then((response) =>
+                                        next(null, response.song)
+                                    )
+                                    .catch(next);
+                            });
+                    },
+                    (newSong, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            { $push: { songs: newSong } },
+                            { runValidators: true },
+                            (err) => {
+                                if (err) return next(err);
+                                playlists
+                                    .runJob("UPDATE_PLAYLIST", { playlistId })
+                                    .then((playlist) =>
+                                        next(null, playlist, newSong)
+                                    )
+                                    .catch(next);
+                            }
+                        );
+                    },
+                ],
+                async (err, playlist, newSong) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_ADD_SONG",
+                            `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "PLAYLIST_ADD_SONG",
+                            `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${session.userId}".`
+                        );
+                        if (!isSet)
+                            activities.runJob("ADD_ACTIVITY", {
+                                userId: session.userId,
+                                activityType: "added_song_to_playlist",
+                                payload: [{ songId, playlistId }],
+                            });
+
+                        cache.runJob("PUB", {
+                            channel: "playlist.addSong",
+                            value: {
+                                playlistId: playlist._id,
+                                song: newSong,
+                                userId: session.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
+     *
+     * @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 {Boolean} musicOnly - whether to only add music to the playlist
+     * @param {Function} cb - gets called with the result
+     */
+    addSetToPlaylist: hooks.loginRequired(
+        (session, url, playlistId, musicOnly, cb) => {
+            let videosInPlaylistTotal = 0;
+            let songsInPlaylistTotal = 0;
+            let songsSuccess = 0;
+            let songsFail = 0;
+
+            let addedSongs = [];
+
+            async.waterfall(
+                [
+                    (next) => {
+                        utils
+                            .runJob("GET_PLAYLIST_FROM_YOUTUBE", {
+                                url,
+                                musicOnly,
+                            })
+                            .then((response) => {
+                                if (response.filteredSongs) {
+                                    videosInPlaylistTotal =
+                                        response.songs.length;
+                                    songsInPlaylistTotal =
+                                        response.filteredSongs.length;
+                                } else {
+                                    songsInPlaylistTotal = videosInPlaylistTotal =
+                                        response.songs.length;
+                                }
+                                next(null, response.songs);
+                            });
+                    },
+                    (songIds, next) => {
+                        let processed = 0;
+                        function checkDone() {
+                            if (processed === songIds.length) next();
+                        }
+                        for (let s = 0; s < songIds.length; s++) {
+                            lib.addSongToPlaylist(
+                                session,
+                                true,
+                                songIds[s],
+                                playlistId,
+                                (res) => {
+                                    processed++;
+                                    if (res.status === "success") {
+                                        addedSongs.push(songIds[s]);
+                                        songsSuccess++;
+                                    } else songsFail++;
+                                    checkDone();
+                                }
+                            );
+                        }
+                    },
+
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist || playlist.createdBy !== session.userId)
+                            return next("Playlist not found.");
+                        next(null, playlist);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_IMPORT",
+                            `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    } else {
+                        activities.runJob("ADD_ACTIVITY", {
+                            userId: session.userId,
+                            activityType: "added_songs_to_playlist",
+                            payload: addedSongs,
+                        });
+                        console.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: ${songsSuccess}, songs failed: ${songsFail}.`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Playlist has been successfully imported.",
+                            data: playlist.songs,
+                            stats: {
+                                videosInPlaylistTotal,
+                                songsInPlaylistTotal,
+                                songsAddedSuccessfully: songsSuccess,
+                                songsFailedToAdd: songsFail,
+                            },
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * 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 {Function} cb - gets called with the result
+     */
+    removeSongFromPlaylist: hooks.loginRequired(
+        async (session, songId, playlistId, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!songId || typeof songId !== "string")
+                            return next("Invalid song id.");
+                        if (!playlistId || typeof playlistId !== "string")
+                            return next("Invalid playlist id.");
+                        next();
+                    },
+
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist || playlist.createdBy !== session.userId)
+                            return next("Playlist not found");
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            { $pull: { songs: { songId: songId } } },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        playlists
+                            .runJob("UPDATE_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_REMOVE_SONG",
+                            `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "PLAYLIST_REMOVE_SONG",
+                            `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${session.userId}".`
+                        );
+                        cache.runJob("PUB", {
+                            channel: "playlist.removeSong",
+                            value: {
+                                playlistId: playlist._id,
+                                songId: songId,
+                                userId: session.userId,
+                            },
+                        });
+                        return cb({
+                            status: "success",
+                            message:
+                                "Song has been successfully removed from playlist",
+                            data: playlist.songs,
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * 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 {Function} cb - gets called with the result
+     */
+    updateDisplayName: hooks.loginRequired(
+        async (session, playlistId, displayName, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId, createdBy: session.userId },
+                            { $set: { displayName } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        playlists
+                            .runJob("UPDATE_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_UPDATE_DISPLAY_NAME",
+                            `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "PLAYLIST_UPDATE_DISPLAY_NAME",
+                        `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${session.userId}".`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "playlist.updateDisplayName",
+                        value: {
+                            playlistId: playlistId,
+                            displayName: displayName,
+                            userId: session.userId,
+                        },
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Playlist has been successfully updated",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Moves a song to the top of the list 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 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 {Function} cb - gets called with the result
+     */
+    moveSongToTop: hooks.loginRequired(
+        async (session, playlistId, songId, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist || playlist.createdBy !== session.userId)
+                            return next("Playlist not found");
+                        async.each(
+                            playlist.songs,
+                            (song, next) => {
+                                if (song.songId === songId) return next(song);
+                                next();
+                            },
+                            (err) => {
+                                if (err && err.songId) return next(null, err);
+                                next("Song not found");
+                            }
+                        );
+                    },
+
+                    (song, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            { $pull: { songs: { songId } } },
+                            (err) => {
+                                if (err) return next(err);
+                                return next(null, song);
+                            }
+                        );
+                    },
+
+                    (song, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            {
+                                $push: {
+                                    songs: {
+                                        $each: [song],
+                                        $position: 0,
+                                    },
+                                },
+                            },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        playlists
+                            .runJob("UPDATE_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_MOVE_SONG_TO_TOP",
+                            `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "PLAYLIST_MOVE_SONG_TO_TOP",
+                        `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${session.userId}".`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "playlist.moveSongToTop",
+                        value: {
+                            playlistId,
+                            songId,
+                            userId: session.userId,
+                        },
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Playlist has been successfully updated",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Moves a song to the bottom of the list 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 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 {Function} cb - gets called with the result
+     */
+    moveSongToBottom: hooks.loginRequired(
+        async (session, playlistId, songId, cb) => {
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        playlists
+                            .runJob("GET_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist || playlist.createdBy !== session.userId)
+                            return next("Playlist not found");
+                        async.each(
+                            playlist.songs,
+                            (song, next) => {
+                                if (song.songId === songId) return next(song);
+                                next();
+                            },
+                            (err) => {
+                                if (err && err.songId) return next(null, err);
+                                next("Song not found");
+                            }
+                        );
+                    },
+
+                    (song, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            { $pull: { songs: { songId } } },
+                            (err) => {
+                                if (err) return next(err);
+                                return next(null, song);
+                            }
+                        );
+                    },
+
+                    (song, next) => {
+                        playlistModel.updateOne(
+                            { _id: playlistId },
+                            {
+                                $push: {
+                                    songs: song,
+                                },
+                            },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        playlists
+                            .runJob("UPDATE_PLAYLIST", { playlistId })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                async (err, playlist) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "PLAYLIST_MOVE_SONG_TO_BOTTOM",
+                            `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "PLAYLIST_MOVE_SONG_TO_BOTTOM",
+                        `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "playlist.moveSongToBottom",
+                        value: {
+                            playlistId,
+                            songId,
+                            userId: session.userId,
+                        },
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Playlist has been successfully updated",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Removes a private 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 {Function} cb - gets called with the result
+     */
+    remove: hooks.loginRequired(async (session, playlistId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    playlists
+                        .runJob("DELETE_PLAYLIST", { playlistId })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+
+                (next) => {
+                    stationModel.find({ privatePlaylist: playlistId }, next);
+                },
 
-	/**
-	 * Gets the first song from a private playlist
-	 *
-	 * @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 {Function} cb - gets called with the result
-	 */
-	getFirstSong: hooks.loginRequired((session, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found.');
-				next(null, playlist.songs[0]);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_GET_FIRST_SONG", `Successfully got the first song of playlist "${playlistId}" for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				song: song
-			});
-		});
-	}),
-
-	/**
-	 * Gets all playlists for the user requesting it
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	indexForUser: hooks.loginRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.find({ createdBy: session.userId }, next);
-			}
-		], async (err, playlists) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${session.userId}" failed. "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_INDEX_FOR_USER", `Successfully indexed playlists for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				data: playlists
-			});
-		});
-	}),
-
-	/**
-	 * 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 {Function} cb - gets called with the result
-	 */
-	create: hooks.loginRequired((session, data, cb) => {
-		async.waterfall([
-
-			(next) => {
-				return (data) ? next() : cb({ 'status': 'failure', 'message': 'Invalid data' });
-			},
-
-			(next) => {
-				const { displayName, songs } = data;
-				db.models.playlist.create({
-					displayName,
-					songs,
-					createdBy: session.userId,
-					createdAt: Date.now()
-				}, next);
-			}
-
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			cache.pub('playlist.create', playlist._id);
-			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${session.userId}".`);
-			cb({ status: 'success', message: 'Successfully created playlist', data: {
-				_id: playlist._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 {Function} cb - gets called with the result
-	 */
-	getPlaylist: hooks.loginRequired((session, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
-				next(null, playlist);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_GET", `Successfully got private playlist "${playlistId}" for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				data: playlist
-			});
-		});
-	}),
-
-	//TODO Remove this
-	/**
-	 * Updates 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 {Function} cb - gets called with the result
-	 */
-	update: hooks.loginRequired((session, playlistId, playlist, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.updateOne({ _id: playlistId, createdBy: session.userId }, playlist, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next)
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_UPDATE", `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`);
-			cb({
-				status: 'success',
-				data: 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 {Function} cb - gets called with the result
-	 */
-	addSongToPlaylist: hooks.loginRequired((session, songId, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, (err, playlist) => {
-					if (err || !playlist || playlist.createdBy !== session.userId) return next('Something went wrong when trying to get the playlist');
-
-					async.each(playlist.songs, (song, next) => {
-						if (song.songId === 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);
-						});
-					} else {
-						next(null, {
-							_id: song._id,
-							songId: songId,
-							title: song.title,
-							duration: song.duration
-						});
-					}
-				});
-			},
-			(newSong, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {$push: {songs: newSong}}, {runValidators: true}, (err) => {
-					if (err) return next(err);
-					playlists.updatePlaylist(playlistId, (err, playlist) => {
-						next(err, playlist, newSong);
-					});
-				});
-			}
-		],
-		async (err, playlist, newSong) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			} else {
-				logger.success("PLAYLIST_ADD_SONG", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${session.userId}".`);
-				cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId: session.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
-	 *
-	 * @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 {Function} cb - gets called with the result
-	 */
-	addSetToPlaylist: hooks.loginRequired((session, url, playlistId, cb) => {
-		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();
-					});
-				}
-			},
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found.');
-				next(null, playlist);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			} else {
-				logger.success("PLAYLIST_IMPORT", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}".`);
-				cb({ status: 'success', message: 'Playlist has been successfully imported.', data: playlist.songs });
-			}
-		});
-	}),
-
-	/**
-	 * 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 {Function} cb - gets called with the result
-	 */
-	removeSongFromPlaylist: hooks.loginRequired((session, songId, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!songId || typeof songId !== 'string') return next('Invalid song id.');
-				if (!playlistId  || typeof playlistId !== 'string') return next('Invalid playlist id.');
-				next();
-			},
-
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
-				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			} else {
-				logger.success("PLAYLIST_REMOVE_SONG", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${session.userId}".`);
-				cache.pub('playlist.removeSong', { playlistId: playlist._id, songId: songId, userId: session.userId });
-				return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
-			}
-		});
-	}),
-
-	/**
-	 * 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 {Function} cb - gets called with the result
-	 */
-	updateDisplayName: hooks.loginRequired((session, playlistId, displayName, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.playlist.updateOne({ _id: playlistId, createdBy: session.userId }, { $set: { displayName } }, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_UPDATE_DISPLAY_NAME", `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${session.userId}".`);
-			cache.pub('playlist.updateDisplayName', {playlistId: playlistId, displayName: displayName, userId: session.userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
-	}),
-
-	/**
-	 * Moves a song to the top of the list 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 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 {Function} cb - gets called with the result
-	 */
-	moveSongToTop: hooks.loginRequired((session, playlistId, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
-				async.each(playlist.songs, (song, next) => {
-					if (song.songId === songId) return next(song);
-					next();
-				}, (err) => {
-					if (err && err.songId) return next(null, err);
-					next('Song not found');
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
-					if (err) return next(err);
-					return next(null, song);
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {
-					$push: {
-						songs: {
-							$each: [song],
-							$position: 0
-						}
-					}
-				}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_MOVE_SONG_TO_TOP", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_MOVE_SONG_TO_TOP", `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${session.userId}".`);
-			cache.pub('playlist.moveSongToTop', {playlistId, songId, userId: session.userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
-	}),
-
-	/**
-	 * Moves a song to the bottom of the list 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 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 {Function} cb - gets called with the result
-	 */
-	moveSongToBottom: hooks.loginRequired((session, playlistId, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.getPlaylist(playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
-				async.each(playlist.songs, (song, next) => {
-					if (song.songId === songId) return next(song);
-					next();
-				}, (err) => {
-					if (err && err.songId) return next(null, err);
-					next('Song not found');
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
-					if (err) return next(err);
-					return next(null, song);
-				});
-			},
-
-			(song, next) => {
-				db.models.playlist.updateOne({_id: playlistId}, {
-					$push: {
-						songs: song
-					}
-				}, next);
-			},
-
-			(res, next) => {
-				playlists.updatePlaylist(playlistId, next);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`);
-			cache.pub('playlist.moveSongToBottom', {playlistId, songId, userId: session.userId});
-			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-		});
-	}),
-
-	/**
-	 * Removes a private 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 {Function} cb - gets called with the result
-	 */
-	remove: hooks.loginRequired((session, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				playlists.deletePlaylist(playlistId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			}
-			logger.success("PLAYLIST_REMOVE", `Successfully removed private playlist "${playlistId}" for user "${session.userId}".`);
-			cache.pub('playlist.delete', {userId: session.userId, playlistId});
-			return cb({ status: 'success', message: 'Playlist successfully removed' });
-		});
-	})
+                (stations, next) => {
+                    async.each(
+                        stations,
+                        (station, next) => {
+                            async.waterfall(
+                                [
+                                    (next) => {
+                                        stationModel.updateOne(
+                                            { _id: station._id },
+                                            { $set: { privatePlaylist: null } },
+                                            { runValidators: true },
+                                            next
+                                        );
+                                    },
 
+                                    (res, next) => {
+                                        if (!station.partyMode) {
+                                            moduleManager.modules["stations"]
+                                                .runJob("UPDATE_STATION", {
+                                                    stationId: station._id,
+                                                })
+                                                .then((station) =>
+                                                    next(null, station)
+                                                )
+                                                .catch(next);
+                                            cache.runJob("PUB", {
+                                                channel:
+                                                    "privatePlaylist.selected",
+                                                value: {
+                                                    playlistId: null,
+                                                    stationId: station._id,
+                                                },
+                                            });
+                                        } else next();
+                                    },
+                                ],
+                                (err) => {
+                                    next();
+                                }
+                            );
+                        },
+                        (err) => {
+                            next();
+                        }
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PLAYLIST_REMOVE",
+                        `Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PLAYLIST_REMOVE",
+                    `Successfully removed private playlist "${playlistId}" for user "${session.userId}".`
+                );
+                cache.runJob("PUB", {
+                    channel: "playlist.delete",
+                    value: {
+                        userId: session.userId,
+                        playlistId,
+                    },
+                });
+                activities.runJob("ADD_ACTIVITY", {
+                    userId: session.userId,
+                    activityType: "deleted_playlist",
+                    payload: [playlistId],
+                });
+                return cb({
+                    status: "success",
+                    message: "Playlist successfully removed",
+                });
+            }
+        );
+    }),
 };
 
 module.exports = lib;

+ 154 - 108
backend/logic/actions/punishments.js

@@ -1,120 +1,166 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
-const moduleManager = require("../../index");
+const hooks = require("./hooks");
+// const moduleManager = require("../../index");
 
-const logger = moduleManager.modules["logger"];
-const utils = moduleManager.modules["utils"];
-const cache = moduleManager.modules["cache"];
-const db = moduleManager.modules["db"];
-const punishments = moduleManager.modules["punishments"];
+// const logger = require("logger");
+const utils = require("../utils");
+const cache = require("../cache");
+const db = require("../db");
+const punishments = require("../punishments");
 
-cache.sub('ip.ban', data => {
-	utils.emitToRoom('admin.punishments', 'event:admin.punishment.added', data.punishment);
-	utils.socketsFromIP(data.ip, sockets => {
-		sockets.forEach(socket => {
-			socket.disconnect(true);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "ip.ban",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.punishments",
+            args: ["event:admin.punishment.added", data.punishment],
+        });
+        utils.runJob("SOCKETS_FROM_IP", { ip: data.ip }).then((sockets) => {
+            sockets.forEach((socket) => {
+                socket.disconnect(true);
+            });
+        });
+    },
 });
 
 module.exports = {
+    /**
+     * Gets all punishments
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    index: hooks.adminRequired(async (session, cb) => {
+        const punishmentModel = await db.runJob("GET_MODEL", {
+            modelName: "punishment",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    punishmentModel.find({}, next);
+                },
+            ],
+            async (err, punishments) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "PUNISHMENTS_INDEX",
+                        `Indexing punishments failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "PUNISHMENTS_INDEX",
+                    "Indexing punishments successful."
+                );
+                cb({ status: "success", data: punishments });
+            }
+        );
+    }),
 
-	/**
-	 * Gets all punishments
-	 *
-	 * @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.punishment.find({}, next);
-			}
-		], async (err, punishments) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err});
-			}
-			logger.success("PUNISHMENTS_INDEX", "Indexing punishments successful.");
-			cb({ status: 'success', data: punishments });
-		});
-	}),
+    /**
+     * Bans an IP address
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @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: hooks.adminRequired((session, value, reason, expiresAt, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    if (!value)
+                        return next("You must provide an IP address to ban.");
+                    else if (!reason)
+                        return next("You must provide a reason for the ban.");
+                    else return next();
+                },
 
-	/**
-	 * Bans an IP address
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @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: hooks.adminRequired((session, value, reason, expiresAt, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!value) return next('You must provide an IP address to ban.');
-				else if (!reason) return next('You must provide a reason for the ban.');
-				else return next();
-			},
+                (next) => {
+                    if (!expiresAt || typeof expiresAt !== "string")
+                        return next("Invalid expire date.");
+                    let 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.");
+                    }
 
-			(next) => {
-				if (!expiresAt || typeof expiresAt !== 'string') return next('Invalid expire date.');
-				let 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.');
-				}
-
-				next();
-			},
-
-			(next) => {
-				punishments.addPunishment('banUserIp', value, reason, expiresAt, session.userId, next)
-			}
-		], async (err, punishment) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("BAN_IP", `User ${session.userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`);
-				cb({ status: 'failure', message: err });
-			}
-			logger.success("BAN_IP", `User ${session.userId} has successfully banned IP address ${value} with the reason ${reason}.`);
-			cache.pub('ip.ban', { ip: value, punishment });
-			return cb({
-				status: 'success',
-				message: 'Successfully banned IP address.'
-			});
-		});
-	}),
+                    next();
+                },
 
+                (next) => {
+                    punishments
+                        .runJob("ADD_PUNISHMENT", {
+                            type: "banUserIp",
+                            value,
+                            reason,
+                            expiresAt,
+                            punishedBy: session.userId,
+                        })
+                        .then((punishment) => next(null, punishment))
+                        .catch(next);
+                },
+            ],
+            async (err, punishment) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "BAN_IP",
+                        `User ${session.userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "BAN_IP",
+                    `User ${session.userId} has successfully banned IP address ${value} with the reason ${reason}.`
+                );
+                cache.runJob("PUB", {
+                    channel: "ip.ban",
+                    value: { ip: value, punishment },
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully banned IP address.",
+                });
+            }
+        );
+    }),
 };

+ 374 - 229
backend/logic/actions/queueSongs.js

@@ -1,249 +1,394 @@
-'use strict';
+"use strict";
 
-const config = require('config');
-const async = require('async');
-const request = require('request');
+const config = require("config");
+const async = require("async");
+const request = require("request");
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 
-const moduleManager = require("../../index");
+const db = require("../db");
+const utils = require("../utils");
+const cache = require("../cache");
+// const logger = moduleManager.modules["logger"];
 
-const db = moduleManager.modules["db"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const cache = moduleManager.modules["cache"];
-
-cache.sub('queue.newSong', songId => {
-	db.models.queueSong.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.queue', 'event:admin.queueSong.added', song);
-	});
+cache.runJob("SUB", {
+    channel: "queue.newSong",
+    cb: async (songId) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        queueSongModel.findOne({ _id: songId }, (err, song) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: "admin.queue",
+                args: ["event:admin.queueSong.added", song],
+            });
+        });
+    },
 });
 
-cache.sub('queue.removedSong', songId => {
-	utils.emitToRoom('admin.queue', 'event:admin.queueSong.removed', songId);
+cache.runJob("SUB", {
+    channel: "queue.removedSong",
+    cb: (songId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.queue",
+            args: ["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);
-	});
+cache.runJob("SUB", {
+    channel: "queue.update",
+    cb: async (songId) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        queueSongModel.findOne({ _id: songId }, (err, song) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: "admin.queue",
+                args: ["event:admin.queueSong.updated", song],
+            });
+        });
+    },
 });
 
 let lib = {
+    /**
+     * Returns the length of the queue songs list
+     *
+     * @param session
+     * @param cb
+     */
+    length: hooks.adminRequired(async (session, cb) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel.countDocuments({}, next);
+                },
+            ],
+            async (err, count) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_SONGS_LENGTH",
+                        `Failed to get length from queue songs. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_SONGS_LENGTH",
+                    `Got length from queue songs successfully.`
+                );
+                cb(count);
+            }
+        );
+    }),
 
-	/**
-	 * Returns the length of the queue songs list
-	 *
-	 * @param session
-	 * @param cb
-	 */
-	length: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.countDocuments({}, next);
-			}
-		], async (err, count) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_SONGS_LENGTH", `Failed to get length from queue songs. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("QUEUE_SONGS_LENGTH", `Got length from queue songs successfully.`);
-			cb(count);
-		});
-	}),
-
-	/**
-	 * Gets a set of queue songs
-	 *
-	 * @param session
-	 * @param set - the set number to return
-	 * @param cb
-	 */
-	getSet: hooks.adminRequired((session, set, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.find({}).skip(15 * (set - 1)).limit(15).exec(next);
-			},
-		], async (err, songs) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_SONGS_GET_SET", `Failed to get set from queue songs. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("QUEUE_SONGS_GET_SET", `Got set from queue songs successfully.`);
-			cb(songs);
-		});
-	}),
-
-	/**
-	 * 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
-	 */
-	update: hooks.adminRequired((session, songId, updatedSong, cb) => {
-		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.updateOne({_id: songId}, {$set}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await  utils.getError(err);
-				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			cache.pub('queue.update', songId);
-			logger.success("QUEUE_UPDATE", `User "${session.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
-	 */
-	remove: hooks.adminRequired((session, songId, cb, userId) => {
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.deleteOne({_id: songId}, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			cache.pub('queue.removedSong', songId);
-			logger.success("QUEUE_REMOVE", `User "${session.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
-	 */
-	add: hooks.loginRequired((session, songId, cb) => {
-		let requestedAt = Date.now();
-
-		async.waterfall([
-			(next) => {
-				db.models.queueSong.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (song) return next('This song is already in the queue.');
-				db.models.song.findOne({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.duration = -1;
-					song.artists = [];
-					song.genres = [];
-					song.skipDuration = 0;
-					song.thumbnail = `${config.get("domain")}/assets/notes.png`;
-					song.explicit = false;
-					song.requestedBy = session.userId;
-					song.requestedAt = requestedAt;
-					next(null, song);
-				});
-			},
-			/*(newSong, next) => {
+    /**
+     * Gets a set of queue songs
+     *
+     * @param session
+     * @param set - the set number to return
+     * @param cb
+     */
+    getSet: hooks.adminRequired(async (session, set, cb) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel
+                        .find({})
+                        .skip(15 * (set - 1))
+                        .limit(15)
+                        .exec(next);
+                },
+            ],
+            async (err, songs) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_SONGS_GET_SET",
+                        `Failed to get set from queue songs. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_SONGS_GET_SET",
+                    `Got set from queue songs successfully.`
+                );
+                cb(songs);
+            }
+        );
+    }),
+
+    /**
+     * 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
+     */
+    update: hooks.adminRequired(async (session, songId, updatedSong, cb) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel.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");
+                    queueSongModel.updateOne(
+                        { _id: songId },
+                        { $set },
+                        { runValidators: true },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_UPDATE",
+                        `Updating queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", { channel: "queue.update", value: songId });
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_UPDATE",
+                    `User "${session.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
+     */
+    remove: hooks.adminRequired(async (session, songId, cb, userId) => {
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel.deleteOne({ _id: songId }, next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_REMOVE",
+                        `Removing queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", {
+                    channel: "queue.removedSong",
+                    value: songId,
+                });
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_REMOVE",
+                    `User "${session.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
+     */
+    add: hooks.loginRequired(async (session, songId, cb) => {
+        let requestedAt = Date.now();
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const queueSongModel = await db.runJob("GET_MODEL", {
+            modelName: "queueSong",
+        });
+
+        async.waterfall(
+            [
+                (next) => {
+                    queueSongModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (song) return next("This song is already in the queue.");
+                    songModel.findOne({ 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
+                        .runJob("GET_SONG_FROM_YOUTUBE", { songId })
+                        .then((response) => {
+                            const song = response.song;
+                            song.duration = -1;
+                            song.artists = [];
+                            song.genres = [];
+                            song.skipDuration = 0;
+                            song.thumbnail = `${config.get(
+                                "domain"
+                            )}/assets/notes.png`;
+                            song.explicit = false;
+                            song.requestedBy = session.userId;
+                            song.requestedAt = requestedAt;
+                            next(null, song);
+                        })
+                        .catch(next);
+                },
+                /*(newSong, next) => {
 				utils.getSongFromSpotify(newSong, (err, song) => {
 					if (!song) next(null, newSong);
 					else next(err, song);
 				});
 			},*/
-			(newSong, next) => {
-				const song = new db.models.queueSong(newSong);
-				song.save({ validateBeforeSave: false }, (err, song) => {
-					if (err) return next(err);
-					next(null, song);
-				});
-			},
-			(newSong, next) => {
-				db.models.user.findOne({ _id: session.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);
-						});
-					}
-				});
-			}
-		], async (err, newSong) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_ADD", `Adding queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			cache.pub('queue.newSong', newSong._id);
-			logger.success("QUEUE_ADD", `User "${session.userId}" successfully added queuesong "${songId}".`);
-			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
-		});
-	}),
-
-	/**
-	 * Adds a set of songs to the queue
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} url - the url of the the YouTube playlist
-	 * @param {Function} cb - gets called with the result
-	 */
-	addSetToQueue: hooks.loginRequired((session, url, cb) => {
-		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.add(session, songs[s].contentDetails.videoId, () => {
-						processed++;
-						checkDone();
-					});
-				}
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("QUEUE_IMPORT", `Importing a YouTube playlist to the queue failed for user "${session.userId}". "${err}"`);
-				return cb({ status: 'failure', message: err});
-			} else {
-				logger.success("QUEUE_IMPORT", `Successfully imported a YouTube playlist to the queue for user "${session.userId}".`);
-				cb({ status: 'success', message: 'Playlist has been successfully imported.' });
-			}
-		});
-	})
+                (newSong, next) => {
+                    const song = new queueSongModel(newSong);
+                    song.save({ validateBeforeSave: false }, (err, song) => {
+                        if (err) return next(err);
+                        next(null, song);
+                    });
+                },
+                (newSong, next) => {
+                    userModel.findOne({ _id: session.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);
+                            });
+                        }
+                    });
+                },
+            ],
+            async (err, newSong) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_ADD",
+                        `Adding queuesong "${songId}" failed for user ${session.userId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                cache.runJob("PUB", {
+                    channel: "queue.newSong",
+                    value: newSong._id,
+                });
+                console.log(
+                    "SUCCESS",
+                    "QUEUE_ADD",
+                    `User "${session.userId}" successfully added queuesong "${songId}".`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully added that song to the queue",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Adds a set of songs to the queue
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} url - the url of the the YouTube playlist
+     * @param {Function} cb - gets called with the result
+     */
+    addSetToQueue: hooks.loginRequired((session, url, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    utils
+                        .runJob("GET_PLAYLIST_FROM_YOUTUBE", {
+                            url,
+                            musicOnly: false,
+                        })
+                        .then((songIds) => next(null, songIds))
+                        .catch(next);
+                },
+                (songIds, next) => {
+                    let processed = 0;
+                    function checkDone() {
+                        if (processed === songIds.length) next();
+                    }
+                    for (let s = 0; s < songIds.length; s++) {
+                        lib.add(session, songIds[s], () => {
+                            processed++;
+                            checkDone();
+                        });
+                    }
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "QUEUE_IMPORT",
+                        `Importing a YouTube playlist to the queue failed for user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "QUEUE_IMPORT",
+                        `Successfully imported a YouTube playlist to the queue for user "${session.userId}".`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Playlist has been successfully imported.",
+                    });
+                }
+            }
+        );
+    }),
 };
 
 module.exports = lib;

+ 342 - 233
backend/logic/actions/reports.js

@@ -1,250 +1,359 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 
 const moduleManager = require("../../index");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const songs = moduleManager.modules["songs"];
+const db = require("../db");
+const cache = require("../cache");
+const utils = require("../utils");
+// const logger = require("../logger");
+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'
-		]
-	}
+    {
+        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"],
+    },
 ];
 
-cache.sub('report.resolve', reportId => {
-	utils.emitToRoom('admin.reports', 'event:admin.report.resolved', reportId);
+cache.runJob("SUB", {
+    channel: "report.resolve",
+    cb: (reportId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.reports",
+            args: ["event:admin.report.resolved", reportId],
+        });
+    },
 });
 
-cache.sub('report.create', report => {
-	utils.emitToRoom('admin.reports', 'event:admin.report.created', report);
+cache.runJob("SUB", {
+    channel: "report.create",
+    cb: (report) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.reports",
+            args: ["event:admin.report.created", report],
+        });
+    },
 });
 
 module.exports = {
+    /**
+     * Gets all reports
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    index: hooks.adminRequired(async (session, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    reportModel
+                        .find({ resolved: false })
+                        .sort({ released: "desc" })
+                        .exec(next);
+                },
+            ],
+            async (err, reports) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REPORTS_INDEX",
+                        `Indexing reports failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "REPORTS_INDEX",
+                    "Indexing reports successful."
+                );
+                cb({ status: "success", data: reports });
+            }
+        );
+    }),
 
-	/**
-	 * Gets all reports
-	 *
-	 * @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.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
-			}
-		], async (err, reports) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("REPORTS_INDEX", `Indexing reports failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err});
-			}
-			logger.success("REPORTS_INDEX", "Indexing reports successful.");
-			cb({ status: 'success', data: reports });
-		});
-	}),
-
-	/**
-	 * Gets a specific report
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} reportId - the id of the report to return
-	 * @param {Function} cb - gets called with the result
-	 */
-	findOne: hooks.adminRequired((session, reportId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.report.findOne({ _id: reportId }).exec(next);
-			}
-		], async (err, report) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			}
-			logger.success("REPORTS_FIND_ONE", `Finding report "${reportId}" successful.`);
-			cb({ status: 'success', data: report });
-		});
-	}),
-
-	/**
-	 * Gets all reports for a songId (_id)
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} songId - the id of the song to index reports for
-	 * @param {Function} cb - gets called with the result
-	 */
-	getReportsForSong: hooks.adminRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.report.find({ song: { _id: songId }, resolved: false }).sort({ released: 'desc' }).exec(next);
-			},
-
-			(reports, next) => {
-				let data = [];
-				for (let i = 0; i < reports.length; i++) {
-					data.push(reports[i]._id);
-				}
-				next(null, data);
-			}
-		], async (err, data) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err});
-			} else {
-				logger.success("GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" successful.`);
-				return cb({ status: 'success', data });
-			}
-		});
-	}),
-
-	/**
-	 * Resolves a 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 {Function} cb - gets called with the result
-	 */
-	resolve: hooks.adminRequired((session, reportId, cb) => {
-		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();
-				});
-			}
-		], async (err) => {
-			if (err) {
-				err = await  utils.getError(err);
-				logger.error("REPORTS_RESOLVE", `Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`);
-				return cb({ 'status': 'failure', 'message': err});
-			} else {
-				cache.pub('report.resolve', reportId);
-				logger.success("REPORTS_RESOLVE", `User "${session.userId}" resolved report "${reportId}".`);
-				cb({ status: 'success', message: 'Successfully resolved Report' });
-			}
-		});
-	}),
-
-	/**
-	 * Creates a new report
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Object} data - the object of the report data
-	 * @param {Function} cb - gets called with the result
-	 */
-	create: hooks.loginRequired((session, data, cb) => {
-		async.waterfall([
-
-			(next) => {
-				db.models.song.findOne({ songId: data.songId }).exec(next);
-			},
-
-			(song, next) => {
-				if (!song) return next('Song not found.');
-				songs.getSong(song._id, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('Song not found.');
-
-				delete data.songId;
-				data.song = {
-					_id: song._id,
-					songId: song.songId
-				}
-
-				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' });
-				}
-
-				next();
-			},
-
-			(next) => {
-				let issues = [];
-
-				for (let r = 0; r < data.issues.length; r++) {
-					if (!data.issues[r].reasons.length <= 0) issues.push(data.issues[r]);
-				}
-
-				data.issues = issues;
-
-				next();
-			},
-
-			(next) => {
-				data.createdBy = session.userId;
-				data.createdAt = Date.now();
-				db.models.report.create(data, next);
-			}
-
-		], async (err, report) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("REPORTS_CREATE", `Creating report for "${data.song._id}" failed by user "${session.userId}". "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			} else {
-				cache.pub('report.create', report);
-				logger.success("REPORTS_CREATE", `User "${session.userId}" created report for "${data.songId}".`);
-				return cb({ 'status': 'success', 'message': 'Successfully created report' });
-			}
-		});
-	})
+    /**
+     * Gets a specific report
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} reportId - the id of the report to return
+     * @param {Function} cb - gets called with the result
+     */
+    findOne: hooks.adminRequired(async (session, reportId, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    reportModel.findOne({ _id: reportId }).exec(next);
+                },
+            ],
+            async (err, report) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REPORTS_FIND_ONE",
+                        `Finding report "${reportId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "REPORTS_FIND_ONE",
+                    `Finding report "${reportId}" successful.`
+                );
+                cb({ status: "success", data: report });
+            }
+        );
+    }),
 
+    /**
+     * Gets all reports for a songId (_id)
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} songId - the id of the song to index reports for
+     * @param {Function} cb - gets called with the result
+     */
+    getReportsForSong: hooks.adminRequired(async (session, songId, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    reportModel
+                        .find({ song: { _id: songId }, resolved: false })
+                        .sort({ released: "desc" })
+                        .exec(next);
+                },
+
+                (reports, next) => {
+                    let data = [];
+                    for (let i = 0; i < reports.length; i++) {
+                        data.push(reports[i]._id);
+                    }
+                    next(null, data);
+                },
+            ],
+            async (err, data) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "GET_REPORTS_FOR_SONG",
+                        `Indexing reports for song "${songId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "GET_REPORTS_FOR_SONG",
+                        `Indexing reports for song "${songId}" successful.`
+                    );
+                    return cb({ status: "success", data });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Resolves a 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 {Function} cb - gets called with the result
+     */
+    resolve: hooks.adminRequired(async (session, reportId, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    reportModel.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();
+                    });
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REPORTS_RESOLVE",
+                        `Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    cache.runJob("PUB", {
+                        channel: "report.resolve",
+                        value: reportId,
+                    });
+                    console.log(
+                        "SUCCESS",
+                        "REPORTS_RESOLVE",
+                        `User "${session.userId}" resolved report "${reportId}".`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully resolved Report",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Creates a new report
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Object} data - the object of the report data
+     * @param {Function} cb - gets called with the result
+     */
+    create: hooks.loginRequired(async (session, data, cb) => {
+        const reportModel = await db.runJob("GET_MODEL", {
+            modelName: "report",
+        });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId: data.songId }).exec(next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("Song not found.");
+                    songs
+                        .runJob("GET_SONG", { id: song._id })
+                        .then((response) => next(null, response.song))
+                        .catch(next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("Song not found.");
+
+                    delete data.songId;
+                    data.song = {
+                        _id: song._id,
+                        songId: song.songId,
+                    };
+
+                    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",
+                            });
+                    }
+
+                    next();
+                },
+
+                (next) => {
+                    let issues = [];
+
+                    for (let r = 0; r < data.issues.length; r++) {
+                        if (!data.issues[r].reasons.length <= 0)
+                            issues.push(data.issues[r]);
+                    }
+
+                    data.issues = issues;
+
+                    next();
+                },
+
+                (next) => {
+                    data.createdBy = session.userId;
+                    data.createdAt = Date.now();
+                    reportModel.create(data, next);
+                },
+            ],
+            async (err, report) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REPORTS_CREATE",
+                        `Creating report for "${data.song._id}" failed by user "${session.userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    cache.runJob("PUB", {
+                        channel: "report.create",
+                        value: report,
+                    });
+                    console.log(
+                        "SUCCESS",
+                        "REPORTS_CREATE",
+                        `User "${session.userId}" created report for "${data.songId}".`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully created report",
+                    });
+                }
+            }
+        );
+    }),
 };

+ 1022 - 469
backend/logic/actions/songs.js

@@ -1,491 +1,1044 @@
-'use strict';
+"use strict";
 
-const async = require('async');
+const async = require("async");
 
-const hooks = require('./hooks');
-const queueSongs = require('./queueSongs');
+const hooks = require("./hooks");
+const queueSongs = require("./queueSongs");
 
-const moduleManager = require("../../index");
+// const moduleManager = require("../../index");
 
-const db = moduleManager.modules["db"];
-const songs = moduleManager.modules["songs"];
-const cache = moduleManager.modules["cache"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
+const db = require("../db");
+const songs = require("../songs");
+const cache = require("../cache");
+const utils = require("../utils");
+const activities = require("../activities");
+// const logger = moduleManager.modules["logger"];
 
-cache.sub('song.removed', songId => {
-	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
+cache.runJob("SUB", {
+    channel: "song.removed",
+    cb: (songId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.songs",
+            args: ["event:admin.song.removed", songId],
+        });
+    },
 });
 
-cache.sub('song.added', songId => {
-	db.models.song.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
-	});
+cache.runJob("SUB", {
+    channel: "song.added",
+    cb: async (songId) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        songModel.findOne({ _id: songId }, (err, song) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: "admin.songs",
+                args: ["event:admin.song.added", song],
+            });
+        });
+    },
 });
 
-cache.sub('song.updated', songId => {
-	db.models.song.findOne({_id: songId}, (err, song) => {
-		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
-	});
+cache.runJob("SUB", {
+    channel: "song.updated",
+    cb: async (songId) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        songModel.findOne({ _id: songId }, (err, song) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: "admin.songs",
+                args: ["event:admin.song.updated", song],
+            });
+        });
+    },
 });
 
-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});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "song.like",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `song.${data.songId}`,
+            args: [
+                "event:song.like",
+                {
+                    songId: data.songId,
+                    likes: data.likes,
+                    dislikes: data.dislikes,
+                },
+            ],
+        });
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:song.newRatings", {
+                        songId: data.songId,
+                        liked: true,
+                        disliked: false,
+                    });
+                });
+            });
+    },
 });
 
-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});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "song.dislike",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `song.${data.songId}`,
+            args: [
+                "event:song.dislike",
+                {
+                    songId: data.songId,
+                    likes: data.likes,
+                    dislikes: data.dislikes,
+                },
+            ],
+        });
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:song.newRatings", {
+                        songId: data.songId,
+                        liked: false,
+                        disliked: true,
+                    });
+                });
+            });
+    },
 });
 
-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});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "song.unlike",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `song.${data.songId}`,
+            args: [
+                "event:song.unlike",
+                {
+                    songId: data.songId,
+                    likes: data.likes,
+                    dislikes: data.dislikes,
+                },
+            ],
+        });
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:song.newRatings", {
+                        songId: data.songId,
+                        liked: false,
+                        disliked: false,
+                    });
+                });
+            });
+    },
 });
 
-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});
-		});
-	});
+cache.runJob("SUB", {
+    channel: "song.undislike",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `song.${data.songId}`,
+            args: [
+                "event:song.undislike",
+                {
+                    songId: data.songId,
+                    likes: data.likes,
+                    dislikes: data.dislikes,
+                },
+            ],
+        });
+        utils
+            .runJob("SOCKETS_FROM_USER", { userId: data.userId })
+            .then((response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:song.newRatings", {
+                        songId: data.songId,
+                        liked: false,
+                        disliked: false,
+                    });
+                });
+            });
+    },
 });
 
 module.exports = {
+    /**
+     * Returns the length of the songs list
+     *
+     * @param session
+     * @param cb
+     */
+    length: hooks.adminRequired(async (session, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.countDocuments({}, next);
+                },
+            ],
+            async (err, count) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_LENGTH",
+                        `Failed to get length from songs. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_LENGTH",
+                    `Got length from songs successfully.`
+                );
+                cb(count);
+            }
+        );
+    }),
 
-	/**
-	 * Returns the length of the songs list
-	 *
-	 * @param session
-	 * @param cb
-	 */
-	length: hooks.adminRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.countDocuments({}, next);
-			}
-		], async (err, count) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_LENGTH", `Got length from songs successfully.`);
-			cb(count);
-		});
-	}),
-
-	/**
-	 * Gets a set of songs
-	 *
-	 * @param session
-	 * @param set - the set number to return
-	 * @param cb
-	 */
-	getSet: hooks.adminRequired((session, set, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.find({}).skip(15 * (set - 1)).limit(15).exec(next);
-			},
-		], async (err, songs) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_GET_SET", `Failed to get set from songs. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_GET_SET", `Got set from songs successfully.`);
-			cb(songs);
-		});
-	}),
-
-	/**
-	 * Gets a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	getSong: hooks.adminRequired((session, songId, cb) => {
-		console.log(songId);
-
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({ songId }).exec(next);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_GET_SONG", `Failed to get song ${songId}. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("SONGS_GET_SONG", `Got song ${songId} successfully.`);
-				cb({ status: "success", data: song });
-			}
-		});
-	}),
-
-	/**
-	 * Updates a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param song - the updated song object
-	 * @param cb
-	 */
-	update: hooks.adminRequired((session, songId, song, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.updateOne({_id: songId}, song, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				songs.updateSong(songId, next);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_UPDATE", `Successfully updated song "${songId}".`);
-			cache.pub('song.updated', song.songId);
-			cb({ status: 'success', message: 'Song has been successfully updated', data: song });
-		});
-	}),
-
-	/**
-	 * Removes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	remove: hooks.adminRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.deleteOne({_id: songId}, next);
-			},
-
-			(res, next) => {//TODO Check if res gets returned from above
-				cache.hdel('songs', songId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_UPDATE", `Failed to remove song "${songId}". "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_UPDATE", `Successfully remove song "${songId}".`);
-			cache.pub('song.removed', songId);
-			cb({status: 'success', message: 'Song has been successfully updated'});
-		});
-	}),
-
-	/**
-	 * Adds a song
-	 *
-	 * @param session
-	 * @param song - the song object
-	 * @param cb
-	 */
-	add: hooks.adminRequired((session, song, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId: song.songId}, next);
-			},
-
-			(existingSong, next) => {
-				if (existingSong) return next('Song is already in rotation.');
-				next();
-			},
-
-			(next) => {
-				const newSong = new db.models.song(song);
-				newSong.acceptedBy = session.userId;
-				newSong.acceptedAt = Date.now();
-				newSong.save(next);
-			},
-
-			(res, next) => {
-				queueSongs.remove(session, song._id, () => {
-					next();
-				});
-			},
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_ADD", `User "${session.userId}" failed to add song. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("SONGS_ADD", `User "${session.userId}" successfully added song "${song.songId}".`);
-			cache.pub('song.added', song.songId);
-			cb({status: 'success', message: 'Song has been moved from the queue successfully.'});
-		});
-		//TODO Check if video is in queue and Add the song to the appropriate stations
-	}),
-
-	/**
-	 * Likes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	like: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_LIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let oldSongId = songId;
-			songId = song._id;
-			db.models.user.findOne({ _id: session.userId }, (err, user) => {
-				if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
-				db.models.user.updateOne({_id: session.userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
-					if (!err) {
-						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-							db.models.user.countDocuments({"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: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
-									return cb({ status: 'success', message: 'You have successfully liked this song.' });
-								});
-							});
-						});
-					} else return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-				});
-			});
-		});
-	}),
-
-	/**
-	 * Dislikes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	dislike: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_DISLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let oldSongId = songId;
-			songId = song._id;
-			db.models.user.findOne({ _id: session.userId }, (err, user) => {
-				if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
-				db.models.user.updateOne({_id: session.userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
-					if (!err) {
-						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-							db.models.user.countDocuments({"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, res) => {
-									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: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
-									return cb({ status: 'success', message: 'You have successfully disliked this song.' });
-								});
-							});
-						});
-					} else return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-				});
-			});
-		});
-	}),
-
-	/**
-	 * Undislikes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	undislike: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_UNDISLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let oldSongId = songId;
-			songId = song._id;
-			db.models.user.findOne({_id: session.userId}, (err, user) => {
-				if (user.disliked.indexOf(songId) === -1) return cb({
-					status: 'failure',
-					message: 'You have not disliked this song.'
-				});
-				db.models.user.updateOne({_id: session.userId}, {$pull: {liked: songId, disliked: songId}}, err => {
-					if (!err) {
-						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
-							if (err) return cb({
-								status: 'failure',
-								message: 'Something went wrong while undisliking this song.'
-							});
-							db.models.user.countDocuments({"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: oldSongId,
-										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.'});
-				});
-			});
-		});
-	}),
-
-	/**
-	 * Unlikes a song
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	unlike: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_UNLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let oldSongId = songId;
-			songId = song._id;
-			db.models.user.findOne({ _id: session.userId }, (err, user) => {
-				if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
-				db.models.user.updateOne({_id: session.userId}, {$pull: {liked: songId, disliked: songId}}, err => {
-					if (!err) {
-						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
-							if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
-								if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
-								db.models.song.updateOne({_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: oldSongId, 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.' });
-				});
-			});
-		});
-	}),
-
-	/**
-	 * Gets user's own song ratings
-	 *
-	 * @param session
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	getOwnSongRatings: hooks.loginRequired((session, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.song.findOne({songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) return next('No song found with that id.');
-				next(null, song);
-			}
-		], async (err, song) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("SONGS_GET_OWN_RATINGS", `User "${session.userId}" failed to get ratings for ${songId}. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			let newSongId = song._id;
-			db.models.user.findOne({_id: session.userId}, (err, user) => {
-				if (!err && user) {
-					return cb({
-						status: 'success',
-						songId: songId,
-						liked: (user.liked.indexOf(newSongId) !== -1),
-						disliked: (user.disliked.indexOf(newSongId) !== -1)
-					});
-				} else {
-					return cb({
-						status: 'failure',
-						message: utils.getError(err)
-					});
-				}
-			});
-		});
-	})
+    /**
+     * Gets a set of songs
+     *
+     * @param session
+     * @param set - the set number to return
+     * @param cb
+     */
+    getSet: hooks.adminRequired(async (session, set, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel
+                        .find({})
+                        .skip(15 * (set - 1))
+                        .limit(15)
+                        .exec(next);
+                },
+            ],
+            async (err, songs) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_GET_SET",
+                        `Failed to get set from songs. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_GET_SET",
+                    `Got set from songs successfully.`
+                );
+                cb(songs);
+            }
+        );
+    }),
+
+    /**
+     * Gets a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    getSong: hooks.adminRequired((session, songId, cb) => {
+        console.log(songId);
+
+        async.waterfall(
+            [
+                (next) => {
+                    song.getSong(songId, next);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_GET_SONG",
+                        `Failed to get song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "SONGS_GET_SONG",
+                        `Got song ${songId} successfully.`
+                    );
+                    cb({ status: "success", data: song });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Obtains basic metadata of a song in order to format an activity
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    getSongForActivity: (session, songId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    songs
+                        .runJob("GET_SONG_FROM_ID", { songId })
+                        .then((responsesong) => next(null, response.song))
+                        .catch(next);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_GET_SONG_FOR_ACTIVITY",
+                        `Failed to obtain metadata of song ${songId} for activity formatting. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    if (song) {
+                        console.log(
+                            "SUCCESS",
+                            "SONGS_GET_SONG_FOR_ACTIVITY",
+                            `Obtained metadata of song ${songId} for activity formatting successfully.`
+                        );
+                        cb({
+                            status: "success",
+                            data: {
+                                title: song.title,
+                                thumbnail: song.thumbnail,
+                            },
+                        });
+                    } else {
+                        console.log(
+                            "ERROR",
+                            "SONGS_GET_SONG_FOR_ACTIVITY",
+                            `Song ${songId} does not exist so failed to obtain for activity formatting.`
+                        );
+                        cb({ status: "failure" });
+                    }
+                }
+            }
+        );
+    },
+
+    /**
+     * Updates a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param song - the updated song object
+     * @param cb
+     */
+    update: hooks.adminRequired(async (session, songId, song, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.updateOne(
+                        { _id: songId },
+                        song,
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    songs
+                        .runJob("UPDATE_SONG", { songId })
+                        .then((song) => next(null, song))
+                        .catch(next);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_UPDATE",
+                        `Failed to update song "${songId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_UPDATE",
+                    `Successfully updated song "${songId}".`
+                );
+                cache.runJob("PUB", {
+                    channel: "song.updated",
+                    value: song.songId,
+                });
+                cb({
+                    status: "success",
+                    message: "Song has been successfully updated",
+                    data: song,
+                });
+            }
+        );
+    }),
+
+    /**
+     * Removes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    remove: hooks.adminRequired(async (session, songId, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.deleteOne({ _id: songId }, next);
+                },
+
+                (res, next) => {
+                    //TODO Check if res gets returned from above
+                    cache
+                        .runJob("HDEL", { table: "songs", key: songId })
+                        .then(() => next())
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_UPDATE",
+                        `Failed to remove song "${songId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_UPDATE",
+                    `Successfully remove song "${songId}".`
+                );
+                cache.runJob("PUB", { channel: "song.removed", value: songId });
+                cb({
+                    status: "success",
+                    message: "Song has been successfully updated",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Adds a song
+     *
+     * @param session
+     * @param song - the song object
+     * @param cb
+     */
+    add: hooks.adminRequired(async (session, song, cb) => {
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId: song.songId }, next);
+                },
+
+                (existingSong, next) => {
+                    if (existingSong)
+                        return next("Song is already in rotation.");
+                    next();
+                },
+
+                (next) => {
+                    const newSong = new songModel(song);
+                    newSong.acceptedBy = session.userId;
+                    newSong.acceptedAt = Date.now();
+                    newSong.save(next);
+                },
+
+                (res, next) => {
+                    queueSongs.remove(session, song._id, () => {
+                        next();
+                    });
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_ADD",
+                        `User "${session.userId}" failed to add song. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "SONGS_ADD",
+                    `User "${session.userId}" successfully added song "${song.songId}".`
+                );
+                cache.runJob("PUB", {
+                    channel: "song.added",
+                    value: song.songId,
+                });
+                cb({
+                    status: "success",
+                    message: "Song has been moved from the queue successfully.",
+                });
+            }
+        );
+        //TODO Check if video is in queue and Add the song to the appropriate stations
+    }),
+
+    /**
+     * Likes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    like: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_LIKE",
+                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let oldSongId = songId;
+                songId = song._id;
+                userModel.findOne({ _id: session.userId }, (err, user) => {
+                    if (user.liked.indexOf(songId) !== -1)
+                        return cb({
+                            status: "failure",
+                            message: "You have already liked this song.",
+                        });
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        {
+                            $push: { liked: songId },
+                            $pull: { disliked: songId },
+                        },
+                        (err) => {
+                            if (!err) {
+                                userModel.countDocuments(
+                                    { liked: songId },
+                                    (err, likes) => {
+                                        if (err)
+                                            return cb({
+                                                status: "failure",
+                                                message:
+                                                    "Something went wrong while liking this song.",
+                                            });
+                                        userModel.countDocuments(
+                                            { disliked: songId },
+                                            (err, dislikes) => {
+                                                if (err)
+                                                    return cb({
+                                                        status: "failure",
+                                                        message:
+                                                            "Something went wrong while liking this song.",
+                                                    });
+                                                songModel.update(
+                                                    { _id: songId },
+                                                    {
+                                                        $set: {
+                                                            likes: likes,
+                                                            dislikes: dislikes,
+                                                        },
+                                                    },
+                                                    (err) => {
+                                                        if (err)
+                                                            return cb({
+                                                                status:
+                                                                    "failure",
+                                                                message:
+                                                                    "Something went wrong while liking this song.",
+                                                            });
+                                                        songs.runJob(
+                                                            "UPDATE_SONG",
+                                                            { songId }
+                                                        );
+                                                        cache.runJob("PUB", {
+                                                            channel:
+                                                                "song.like",
+                                                            value: JSON.stringify(
+                                                                {
+                                                                    songId: oldSongId,
+                                                                    userId:
+                                                                        session.userId,
+                                                                    likes: likes,
+                                                                    dislikes: dislikes,
+                                                                }
+                                                            ),
+                                                        });
+                                                        activities.runJob(
+                                                            "ADD_ACTIVITY",
+                                                            {
+                                                                userId:
+                                                                    session.userId,
+                                                                activityType:
+                                                                    "liked_song",
+                                                                payload: [
+                                                                    songId,
+                                                                ],
+                                                            }
+                                                        );
+                                                        return cb({
+                                                            status: "success",
+                                                            message:
+                                                                "You have successfully liked this song.",
+                                                        });
+                                                    }
+                                                );
+                                            }
+                                        );
+                                    }
+                                );
+                            } else
+                                return cb({
+                                    status: "failure",
+                                    message:
+                                        "Something went wrong while liking this song.",
+                                });
+                        }
+                    );
+                });
+            }
+        );
+    }),
+
+    /**
+     * Dislikes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    dislike: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_DISLIKE",
+                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let oldSongId = songId;
+                songId = song._id;
+                userModel.findOne({ _id: session.userId }, (err, user) => {
+                    if (user.disliked.indexOf(songId) !== -1)
+                        return cb({
+                            status: "failure",
+                            message: "You have already disliked this song.",
+                        });
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        {
+                            $push: { disliked: songId },
+                            $pull: { liked: songId },
+                        },
+                        (err) => {
+                            if (!err) {
+                                userModel.countDocuments(
+                                    { liked: songId },
+                                    (err, likes) => {
+                                        if (err)
+                                            return cb({
+                                                status: "failure",
+                                                message:
+                                                    "Something went wrong while disliking this song.",
+                                            });
+                                        userModel.countDocuments(
+                                            { disliked: songId },
+                                            (err, dislikes) => {
+                                                if (err)
+                                                    return cb({
+                                                        status: "failure",
+                                                        message:
+                                                            "Something went wrong while disliking this song.",
+                                                    });
+                                                songModel.update(
+                                                    { _id: songId },
+                                                    {
+                                                        $set: {
+                                                            likes: likes,
+                                                            dislikes: dislikes,
+                                                        },
+                                                    },
+                                                    (err, res) => {
+                                                        if (err)
+                                                            return cb({
+                                                                status:
+                                                                    "failure",
+                                                                message:
+                                                                    "Something went wrong while disliking this song.",
+                                                            });
+                                                        songs.runJob(
+                                                            "UPDATE_SONG",
+                                                            { songId }
+                                                        );
+                                                        cache.runJob("PUB", {
+                                                            channel:
+                                                                "song.dislike",
+                                                            value: JSON.stringify(
+                                                                {
+                                                                    songId: oldSongId,
+                                                                    userId:
+                                                                        session.userId,
+                                                                    likes: likes,
+                                                                    dislikes: dislikes,
+                                                                }
+                                                            ),
+                                                        });
+                                                        return cb({
+                                                            status: "success",
+                                                            message:
+                                                                "You have successfully disliked this song.",
+                                                        });
+                                                    }
+                                                );
+                                            }
+                                        );
+                                    }
+                                );
+                            } else
+                                return cb({
+                                    status: "failure",
+                                    message:
+                                        "Something went wrong while disliking this song.",
+                                });
+                        }
+                    );
+                });
+            }
+        );
+    }),
+
+    /**
+     * Undislikes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    undislike: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_UNDISLIKE",
+                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let oldSongId = songId;
+                songId = song._id;
+                userModel.findOne({ _id: session.userId }, (err, user) => {
+                    if (user.disliked.indexOf(songId) === -1)
+                        return cb({
+                            status: "failure",
+                            message: "You have not disliked this song.",
+                        });
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $pull: { liked: songId, disliked: songId } },
+                        (err) => {
+                            if (!err) {
+                                userModel.countDocuments(
+                                    { liked: songId },
+                                    (err, likes) => {
+                                        if (err)
+                                            return cb({
+                                                status: "failure",
+                                                message:
+                                                    "Something went wrong while undisliking this song.",
+                                            });
+                                        userModel.countDocuments(
+                                            { disliked: songId },
+                                            (err, dislikes) => {
+                                                if (err)
+                                                    return cb({
+                                                        status: "failure",
+                                                        message:
+                                                            "Something went wrong while undisliking this song.",
+                                                    });
+                                                songModel.update(
+                                                    { _id: songId },
+                                                    {
+                                                        $set: {
+                                                            likes: likes,
+                                                            dislikes: dislikes,
+                                                        },
+                                                    },
+                                                    (err) => {
+                                                        if (err)
+                                                            return cb({
+                                                                status:
+                                                                    "failure",
+                                                                message:
+                                                                    "Something went wrong while undisliking this song.",
+                                                            });
+                                                        songs.runJob(
+                                                            "UPDATE_SONG",
+                                                            { songId }
+                                                        );
+                                                        cache.runJob("PUB", {
+                                                            channel:
+                                                                "song.undislike",
+                                                            value: JSON.stringify(
+                                                                {
+                                                                    songId: oldSongId,
+                                                                    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.",
+                                });
+                        }
+                    );
+                });
+            }
+        );
+    }),
+
+    /**
+     * Unlikes a song
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    unlike: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_UNLIKE",
+                        `User "${session.userId}" failed to like song ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let oldSongId = songId;
+                songId = song._id;
+                userModel.findOne({ _id: session.userId }, (err, user) => {
+                    if (user.liked.indexOf(songId) === -1)
+                        return cb({
+                            status: "failure",
+                            message: "You have not liked this song.",
+                        });
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $pull: { liked: songId, disliked: songId } },
+                        (err) => {
+                            if (!err) {
+                                userModel.countDocuments(
+                                    { liked: songId },
+                                    (err, likes) => {
+                                        if (err)
+                                            return cb({
+                                                status: "failure",
+                                                message:
+                                                    "Something went wrong while unliking this song.",
+                                            });
+                                        userModel.countDocuments(
+                                            { disliked: songId },
+                                            (err, dislikes) => {
+                                                if (err)
+                                                    return cb({
+                                                        status: "failure",
+                                                        message:
+                                                            "Something went wrong while undiking this song.",
+                                                    });
+                                                songModel.updateOne(
+                                                    { _id: songId },
+                                                    {
+                                                        $set: {
+                                                            likes: likes,
+                                                            dislikes: dislikes,
+                                                        },
+                                                    },
+                                                    (err) => {
+                                                        if (err)
+                                                            return cb({
+                                                                status:
+                                                                    "failure",
+                                                                message:
+                                                                    "Something went wrong while unliking this song.",
+                                                            });
+                                                        songs.runJob(
+                                                            "UPDATE_SONG",
+                                                            { songId }
+                                                        );
+                                                        cache.runJob("PUB", {
+                                                            channel:
+                                                                "song.unlike",
+                                                            value: JSON.stringify(
+                                                                {
+                                                                    songId: oldSongId,
+                                                                    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.",
+                                });
+                        }
+                    );
+                });
+            }
+        );
+    }),
+
+    /**
+     * Gets user's own song ratings
+     *
+     * @param session
+     * @param songId - the song id
+     * @param cb
+     */
+    getOwnSongRatings: hooks.loginRequired(async (session, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const songModel = await db.runJob("GET_MODEL", { modelName: "song" });
+        async.waterfall(
+            [
+                (next) => {
+                    songModel.findOne({ songId }, next);
+                },
+
+                (song, next) => {
+                    if (!song) return next("No song found with that id.");
+                    next(null, song);
+                },
+            ],
+            async (err, song) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "SONGS_GET_OWN_RATINGS",
+                        `User "${session.userId}" failed to get ratings for ${songId}. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                let newSongId = song._id;
+                userModel.findOne(
+                    { _id: session.userId },
+                    async (err, user) => {
+                        if (!err && user) {
+                            return cb({
+                                status: "success",
+                                songId: songId,
+                                liked: user.liked.indexOf(newSongId) !== -1,
+                                disliked:
+                                    user.disliked.indexOf(newSongId) !== -1,
+                            });
+                        } else {
+                            return cb({
+                                status: "failure",
+                                message: await utils.runJob("GET_ERROR", {
+                                    error: err,
+                                }),
+                            });
+                        }
+                    }
+                );
+            }
+        );
+    }),
 };

+ 2344 - 1185
backend/logic/actions/stations.js

@@ -1,1218 +1,2377 @@
-'use strict';
+"use strict";
 
-const async   = require('async'),
-	  request = require('request'),
-	  config  = require('config'),
-	  _		  =  require('underscore')._;
+const async = require("async"),
+    request = require("request"),
+    config = require("config"),
+    _ = require("underscore")._;
 
-const hooks = require('./hooks');
+const hooks = require("./hooks");
 
-const moduleManager = require("../../index");
+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 activities = require("../activities");
 
-const db = moduleManager.modules["db"];
-const cache = moduleManager.modules["cache"];
-const notifications = moduleManager.modules["notifications"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-const stations = moduleManager.modules["stations"];
-const songs = moduleManager.modules["songs"];
+// const logger = moduleManager.modules["logger"];
 
 let userList = {};
 let usersPerStation = {};
 let usersPerStationCount = {};
 
-setInterval(() => {
-	let stationsCountUpdated = [];
-	let stationsUpdated = [];
-
-	let oldUsersPerStation = usersPerStation;
-	usersPerStation = {};
-
-	let oldUsersPerStationCount = usersPerStationCount;
-	usersPerStationCount = {};
-
-	async.each(Object.keys(userList), function(socketId, next) {
-		utils.socketFromSession(socketId).then((socket) => {
-			let stationId = userList[socketId];
-			if (!socket || Object.keys(socket.rooms).indexOf(`station.${stationId}`) === -1) {
-				if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
-				if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
-				delete userList[socketId];
-				return next();
-			}
-			if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0;
-			usersPerStationCount[stationId]++;
-			if (!usersPerStation[stationId]) usersPerStation[stationId] = [];
-
-			async.waterfall([
-				(next) => {
-					if (!socket.session || !socket.session.sessionId) return next('No session found.');
-					cache.hget('sessions', socket.session.sessionId, next);
-				},
-
-				(session, next) => {
-					if (!session) return next('Session not found.');
-					db.models.user.findOne({_id: session.userId}, next);
-				},
-
-				(user, next) => {
-					if (!user) return next('User not found.');
-					if (usersPerStation[stationId].indexOf(user.username) !== -1) return next('User already in the list.');
-					next(null, user.username);
-				}
-			], (err, username) => {
-				if (!err) {
-					usersPerStation[stationId].push(username);
-				}
-				next();
-			});
-		});
-		//TODO Code to show users
-	}, (err) => {
-		for (let stationId in usersPerStationCount) {
-			if (oldUsersPerStationCount[stationId] !== usersPerStationCount[stationId]) {
-				if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
-			}
-		}
-
-		for (let stationId in usersPerStation) {
-			if (_.difference(usersPerStation[stationId], oldUsersPerStation[stationId]).length > 0 || _.difference(oldUsersPerStation[stationId], usersPerStation[stationId]).length > 0) {
-				if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
-			}
-		}
-
-		stationsCountUpdated.forEach((stationId) => {
-			//logger.info("UPDATE_STATION_USER_COUNT", `Updating user count of ${stationId}.`);
-			cache.pub('station.updateUserCount', stationId);
-		});
-
-		stationsUpdated.forEach((stationId) => {
-			//logger.info("UPDATE_STATION_USER_LIST", `Updating user list of ${stationId}.`);
-			cache.pub('station.updateUsers', stationId);
-		});
-
-		//console.log("Userlist", usersPerStation);
-	});
-}, 3000);
-
-cache.sub('station.updateUsers', stationId => {
-	let list = usersPerStation[stationId] || [];
-	utils.emitToRoom(`station.${stationId}`, "event:users.updated", list);
+// Temporarily disabled until the messages in console can be limited
+// setInterval(async () => {
+//     let stationsCountUpdated = [];
+//     let stationsUpdated = [];
+
+//     let oldUsersPerStation = usersPerStation;
+//     usersPerStation = {};
+
+//     let oldUsersPerStationCount = usersPerStationCount;
+//     usersPerStationCount = {};
+
+//     const userModel = await db.runJob("GET_MODEL", {
+//         modelName: "user",
+//     });
+//
+//     async.each(
+//         Object.keys(userList),
+//         function(socketId, next) {
+//             utils.runJob("SOCKET_FROM_SESSION", { socketId }).then((socket) => {
+//                 let stationId = userList[socketId];
+//                 if (
+//                     !socket ||
+//                     Object.keys(socket.rooms).indexOf(
+//                         `station.${stationId}`
+//                     ) === -1
+//                 ) {
+//                     if (stationsCountUpdated.indexOf(stationId) === -1)
+//                         stationsCountUpdated.push(stationId);
+//                     if (stationsUpdated.indexOf(stationId) === -1)
+//                         stationsUpdated.push(stationId);
+//                     delete userList[socketId];
+//                     return next();
+//                 }
+//                 if (!usersPerStationCount[stationId])
+//                     usersPerStationCount[stationId] = 0;
+//                 usersPerStationCount[stationId]++;
+//                 if (!usersPerStation[stationId])
+//                     usersPerStation[stationId] = [];
+
+//                 async.waterfall(
+//                     [
+//                         (next) => {
+//                             if (!socket.session || !socket.session.sessionId)
+//                                 return next("No session found.");
+//                             cache
+//                                 .runJob("HGET", {
+//                                     table: "sessions",
+//                                     key: socket.session.sessionId,
+//                                 })
+//                                 .then((session) => next(null, session))
+//                                 .catch(next);
+//                         },
+
+//                         (session, next) => {
+//                             if (!session) return next("Session not found.");
+//                             userModel.findOne({ _id: session.userId }, next);
+//                         },
+
+//                         (user, next) => {
+//                             if (!user) return next("User not found.");
+//                             if (
+//                                 usersPerStation[stationId].indexOf(
+//                                     user.username
+//                                 ) !== -1
+//                             )
+//                                 return next("User already in the list.");
+//                             next(null, user.username);
+//                         },
+//                     ],
+//                     (err, username) => {
+//                         if (!err) {
+//                             usersPerStation[stationId].push(username);
+//                         }
+//                         next();
+//                     }
+//                 );
+//             });
+//             //TODO Code to show users
+//         },
+//         (err) => {
+//             for (let stationId in usersPerStationCount) {
+//                 if (
+//                     oldUsersPerStationCount[stationId] !==
+//                     usersPerStationCount[stationId]
+//                 ) {
+//                     if (stationsCountUpdated.indexOf(stationId) === -1)
+//                         stationsCountUpdated.push(stationId);
+//                 }
+//             }
+
+//             for (let stationId in usersPerStation) {
+//                 if (
+//                     _.difference(
+//                         usersPerStation[stationId],
+//                         oldUsersPerStation[stationId]
+//                     ).length > 0 ||
+//                     _.difference(
+//                         oldUsersPerStation[stationId],
+//                         usersPerStation[stationId]
+//                     ).length > 0
+//                 ) {
+//                     if (stationsUpdated.indexOf(stationId) === -1)
+//                         stationsUpdated.push(stationId);
+//                 }
+//             }
+
+//             stationsCountUpdated.forEach((stationId) => {
+//                 //console.log("INFO", "UPDATE_STATION_USER_COUNT", `Updating user count of ${stationId}.`);
+//                 cache.runJob("PUB", {
+//                     table: "station.updateUserCount",
+//                     value: stationId,
+//                 });
+//             });
+
+//             stationsUpdated.forEach((stationId) => {
+//                 //console.log("INFO", "UPDATE_STATION_USER_LIST", `Updating user list of ${stationId}.`);
+//                 cache.runJob("PUB", {
+//                     table: "station.updateUsers",
+//                     value: stationId,
+//                 });
+//             });
+
+//             //console.log("Userlist", usersPerStation);
+//         }
+//     );
+// }, 3000);
+
+cache.runJob("SUB", {
+    channel: "station.updateUsers",
+    cb: (stationId) => {
+        let list = usersPerStation[stationId] || [];
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${stationId}`,
+            args: ["event:users.updated", list],
+        });
+    },
 });
 
-cache.sub('station.updateUserCount', stationId => {
-	let count = usersPerStationCount[stationId] || 0;
-	utils.emitToRoom(`station.${stationId}`, "event:userCount.updated", count);
-	stations.getStation(stationId, async (err, station) => {
-		if (station.privacy === 'public') utils.emitToRoom('home', "event:userCount.updated", stationId, count);
-		else {
-			let sockets = await 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:userCount.updated", stationId, count);
-								else if (station.type === "community" && station.owner === session.userId) socket.emit("event:userCount.updated", stationId, count);
-							});
-						}
-					});
-				}
-			}
-		}
-	})
+cache.runJob("SUB", {
+    channel: "station.updateUserCount",
+    cb: (stationId) => {
+        let count = usersPerStationCount[stationId] || 0;
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${stationId}`,
+            args: ["event:userCount.updated", count],
+        });
+        stations.runJob("GET_STATION", { stationId }).then(async (station) => {
+            if (station.privacy === "public")
+                utils.runJob("EMIT_TO_ROOM", {
+                    room: "home",
+                    args: ["event:userCount.updated", stationId, count],
+                });
+            else {
+                let sockets = await utils.runJob("GET_ROOM_SOCKETS", {
+                    room: "home",
+                });
+                for (let socketId in sockets) {
+                    let socket = sockets[socketId];
+                    let session = sockets[socketId].session;
+                    if (session.sessionId) {
+                        cache
+                            .runJob("HGET", {
+                                table: "sessions",
+                                key: session.sessionId,
+                            })
+                            .then((session) => {
+                                if (session)
+                                    db.runJob("GET_MODEL", {
+                                        modelName: "user",
+                                    }).then((userModel) =>
+                                        userModel.findOne(
+                                            { _id: session.userId },
+                                            (err, user) => {
+                                                if (user.role === "admin")
+                                                    socket.emit(
+                                                        "event:userCount.updated",
+                                                        stationId,
+                                                        count
+                                                    );
+                                                else if (
+                                                    station.type ===
+                                                        "community" &&
+                                                    station.owner ===
+                                                        session.userId
+                                                )
+                                                    socket.emit(
+                                                        "event:userCount.updated",
+                                                        stationId,
+                                                        count
+                                                    );
+                                            }
+                                        )
+                                    );
+                            });
+                    }
+                }
+            }
+        });
+    },
 });
 
-cache.sub('station.queueLockToggled', data => {
-	utils.emitToRoom(`station.${data.stationId}`, "event:queueLockToggled", data.locked)
+cache.runJob("SUB", {
+    channel: "station.queueLockToggled",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${data.stationId}`,
+            args: ["event:queueLockToggled", data.locked],
+        });
+    },
 });
 
-cache.sub('station.updatePartyMode', data => {
-	utils.emitToRoom(`station.${data.stationId}`, "event:partyMode.updated", data.partyMode);
+cache.runJob("SUB", {
+    channel: "station.updatePartyMode",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${data.stationId}`,
+            args: ["event:partyMode.updated", data.partyMode],
+        });
+    },
 });
 
-cache.sub('privatePlaylist.selected', data => {
-	utils.emitToRoom(`station.${data.stationId}`, "event:privatePlaylist.selected", data.playlistId);
+cache.runJob("SUB", {
+    channel: "privatePlaylist.selected",
+    cb: (data) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${data.stationId}`,
+            args: ["event:privatePlaylist.selected", data.playlistId],
+        });
+    },
 });
 
-cache.sub('station.pause', stationId => {
-	stations.getStation(stationId, (err, station) => {
-		utils.emitToRoom(`station.${stationId}`, "event:stations.pause", { pausedAt: station.pausedAt });
-	});
+cache.runJob("SUB", {
+    channel: "station.pause",
+    cb: (stationId) => {
+        stations.runJob("GET_STATION", { stationId }).then((station) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: `station.${stationId}`,
+                args: ["event:stations.pause", { pausedAt: station.pausedAt }],
+            });
+        });
+    },
 });
 
-cache.sub('station.resume', stationId => {
-	stations.getStation(stationId, (err, station) => {
-		utils.emitToRoom(`station.${stationId}`, "event:stations.resume", { timePaused: station.timePaused });
-	});
+cache.runJob("SUB", {
+    channel: "station.resume",
+    cb: (stationId) => {
+        stations.runJob("GET_STATION", { stationId }).then((station) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: `station.${stationId}`,
+                args: [
+                    "event:stations.resume",
+                    { timePaused: station.timePaused },
+                ],
+            });
+        });
+    },
 });
 
-cache.sub('station.queueUpdate', stationId => {
-	stations.getStation(stationId, (err, station) => {
-		if (!err) utils.emitToRoom(`station.${stationId}`, "event:queue.update", station.queue);
-	});
+cache.runJob("SUB", {
+    channel: "station.queueUpdate",
+    cb: (stationId) => {
+        stations.runJob("GET_STATION", { stationId }).then((station) => {
+            utils.runJob("EMIT_TO_ROOM", {
+                room: `station.${stationId}`,
+                args: ["event:queue.update", station.queue],
+            });
+        });
+    },
 });
 
-cache.sub('station.voteSkipSong', stationId => {
-	utils.emitToRoom(`station.${stationId}`, "event:song.voteSkipSong");
+cache.runJob("SUB", {
+    channel: "station.voteSkipSong",
+    cb: (stationId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${stationId}`,
+            args: ["event:song.voteSkipSong"],
+        });
+    },
 });
 
-cache.sub('station.remove', stationId => {
-	utils.emitToRoom(`station.${stationId}`, 'event:stations.remove');
-	utils.emitToRoom('admin.stations', 'event:admin.station.removed', stationId);
+cache.runJob("SUB", {
+    channel: "station.remove",
+    cb: (stationId) => {
+        utils.runJob("EMIT_TO_ROOM", {
+            room: `station.${stationId}`,
+            args: ["event:stations.remove"],
+        });
+        utils.runJob("EMIT_TO_ROOM", {
+            room: "admin.stations",
+            args: ["event:admin.station.removed", stationId],
+        });
+    },
 });
 
-cache.sub('station.create', stationId => {
-	stations.initializeStation(stationId, async (err, station) => {
-		station.userCount = usersPerStationCount[stationId] || 0;
-		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 = await 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);
-							});
-						}
-					});
-				}
-			}
-		}
-	});
+cache.runJob("SUB", {
+    channel: "station.create",
+    cb: async (stationId) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+
+        stations
+            .runJob("INITIALIZE_STATION", { stationId })
+            .then(async (response) => {
+                const station = response.station;
+                station.userCount = usersPerStationCount[stationId] || 0;
+                utils.runJob("EMIT_TO_ROOM", {
+                    room: "admin.stations",
+                    args: ["event:admin.station.added", station],
+                });
+                // TODO If community, check if on whitelist
+                if (station.privacy === "public")
+                    utils.runJob("EMIT_TO_ROOM", {
+                        room: "home",
+                        args: ["event:stations.created", station],
+                    });
+                else {
+                    let sockets = await utils.runJob("GET_ROOM_SOCKETS", {
+                        room: "home",
+                    });
+                    for (let socketId in sockets) {
+                        let socket = sockets[socketId];
+                        let session = sockets[socketId].session;
+                        if (session.sessionId) {
+                            cache
+                                .runJob("HGET", {
+                                    table: "sessions",
+                                    key: session.sessionId,
+                                })
+                                .then((session) => {
+                                    if (session) {
+                                        userModel.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
+                                                    );
+                                            }
+                                        );
+                                    }
+                                });
+                        }
+                    }
+                }
+            });
+    },
 });
 
 module.exports = {
-
-	/**
-	 * Get a list of all the stations
-	 *
-	 * @param session
-	 * @param cb
-	 * @return {{ status: String, stations: Array }}
-	 */
-	index: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('stations', next);
-			},
-
-			(stations, next) => {
-				let resultStations = [];
-				for (let id in stations) {
-					resultStations.push(stations[id]);
-				}
-				next(null, stations);
-			},
-
-			(stationsArray, next) => {
-				let resultStations = [];
-				async.each(stationsArray, (station, next) => {
-					async.waterfall([
-						(next) => {
-							stations.canUserViewStation(station, session.userId, (err, exists) => {
-								next(err, exists);
-							});
-						}
-					], (err, exists) => {
-						station.userCount = usersPerStationCount[station._id] || 0;
-						if (exists) resultStations.push(station);
-						next();
-					});
-				}, () => {
-					next(null, resultStations);
-				});
-			}
-		], async (err, stations) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_INDEX", `Indexing stations successful.`, false);
-			return cb({'status': 'success', 'stations': stations});
-		});
-	},
-
-	/**
-	 * Verifies that a station exists
-	 *
-	 * @param session
-	 * @param stationName - the station name
-	 * @param cb
-	 */
-	existsByName: (session, stationName, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStationByName(stationName, next);
-			},
-
-			(station, next) => {
-				if (!station) return next(null, false);
-				stations.canUserViewStation(station, session.userId, (err, exists) => {
-					next(err, exists);
-				});
-			}
-		], async (err, exists) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATION_EXISTS_BY_NAME", `Checking if station "${stationName}" exists failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATION_EXISTS_BY_NAME", `Station "${stationName}" exists successfully.`/*, false*/);
-			cb({status: 'success', exists});
-		});
-	},
-
-	/**
-	 * Gets the official playlist for a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	getPlaylist: (session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next(null, station);
-					return next('Insufficient permissions.');
-				});
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				else if (station.type !== 'official') return next('This is not an official station.');
-				else next();
-			},
-
-			(next) => {
-				cache.hget('officialPlaylists', stationId, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist) return next('Playlist not found.');
-				next(null, playlist);
-			}
-		], async (err, playlist) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("STATIONS_GET_PLAYLIST", `Got playlist for station "${stationId}" successfully.`, false);
-				cb({ status: 'success', data: playlist.songs });
-			}
-		});
-	},
-
-	/**
-	 * Joins the station by its name
-	 *
-	 * @param session
-	 * @param stationName - the station name
-	 * @param cb
-	 * @return {{ status: String, userCount: Integer }}
-	 */
-	join: (session, stationName, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStationByName(stationName, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (!canView) next("Not allowed to join station.");
-					else next(null, station);
-				});
-			},
-
-			(station, next) => {
-				utils.socketJoinRoom(session.socketId, `station.${station._id}`);
-				let 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,
-					privacy: station.privacy,
-					locked: station.locked,
-					partyMode: station.partyMode,
-					owner: station.owner,
-					privatePlaylist: station.privatePlaylist
-				};
-				userList[session.socketId] = station._id;
-				next(null, data);
-			},
-
-			(data, next) => {
-				data.userCount = usersPerStationCount[data._id] || 0;
-				data.users = usersPerStation[data._id] || [];
-				if (!data.currentSong || !data.currentSong.title) return next(null, data);
-				utils.socketJoinSongRoom(session.socketId, `song.${data.currentSong.songId}`);
-				data.currentSong.skipVotes = data.currentSong.skipVotes.length;
-				songs.getSongFromId(data.currentSong.songId, (err, song) => {
-					if (!err && song) {
-						data.currentSong.likes = song.likes;
-						data.currentSong.dislikes = song.dislikes;
-					} else {
-						data.currentSong.likes = -1;
-						data.currentSong.dislikes = -1;
-					}
-					next(null, data);
-				});
-			}
-		], async (err, data) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_JOIN", `Joining station "${stationName}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_JOIN", `Joined station "${data._id}" successfully.`);
-			cb({status: 'success', data});
-		});
-	},
-
-	/**
-	 * Toggles if a station is locked
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	toggleLock: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				db.models.station.updateOne({ _id: stationId }, { $set: { locked: !station.locked} }, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_LOCKED_STATUS", `Toggling the queue lock for station "${stationId}" failed. "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("STATIONS_UPDATE_LOCKED_STATUS", `Toggled the queue lock for station "${stationId}" successfully to "${station.locked}".`);
-				cache.pub('station.queueLockToggled', {stationId, locked: station.locked});
-				return cb({ status: 'success', data: station.locked });
-			}
-		});
-	}),
-
-	/**
-	 * Votes to skip a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	voteSkip: hooks.loginRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next(null, station);
-					return next('Insufficient permissions.');
-				});
-			},
-
-			(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.');
-				next(null, station);
-			},
-
-			(station, next) => {
-				db.models.station.updateOne({_id: stationId}, {$push: {"currentSong.skipVotes": session.userId}}, next)
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				next(null, station);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_VOTE_SKIP", `Vote skipping "${stationId}" successful.`);
-			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.' });
-		});
-	}),
-
-	/**
-	 * Force skips a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	forceSkip: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				next();
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			notifications.unschedule(`stations.nextSong?id=${stationId}`);
-			stations.skipStation(stationId)();
-			logger.success("STATIONS_FORCE_SKIP", `Force skipped station "${stationId}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully skipped station.'});
-		});
-	}),
-
-	/**
-	 * Leaves the user's current station
-	 *
-	 * @param session
-	 * @param stationId
-	 * @param cb
-	 * @return {{ status: String, userCount: Integer }}
-	 */
-	leave: (session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				next();
-			}
-		], async (err, userCount) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_LEAVE", `Left station "${stationId}" successfully.`);
-			utils.socketLeaveRooms(session);
-			delete userList[session.socketId];
-			return cb({'status': 'success', 'message': 'Successfully left station.', userCount});
-		});
-	},
-
-	/**
-	 * Updates a station's name
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param newName - the new station name
-	 * @param cb
-	 */
-	updateName: hooks.ownerRequired((session, stationId, newName, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {name: newName}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_NAME", `Updating station "${stationId}" name to "${newName}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_NAME", `Updated station "${stationId}" name to "${newName}" successfully.`);
-			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: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {displayName: newDisplayName}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_DISPLAY_NAME", `Updated station "${stationId}" displayName to "${newDisplayName}" successfully.`);
-			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: hooks.ownerRequired((session, stationId, newDescription, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {description: newDescription}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_DESCRIPTION", `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_DESCRIPTION", `Updated station "${stationId}" description to "${newDescription}" successfully.`);
-			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: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {privacy: newPrivacy}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_PRIVACY", `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_PRIVACY", `Updated station "${stationId}" privacy to "${newPrivacy}" successfully.`);
-			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: hooks.ownerRequired((session, stationId, newGenres, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {genres: newGenres}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_GENRES", `Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_GENRES", `Updated station "${stationId}" genres to "${newGenres}" successfully.`);
-			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: hooks.ownerRequired((session, stationId, newBlacklistedGenres, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$set: {blacklistedGenres: newBlacklistedGenres}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`);
-			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: hooks.ownerRequired((session, stationId, newPartyMode, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.partyMode === newPartyMode) return next('The party mode was already ' + ((newPartyMode) ? 'enabled.' : 'disabled.'));
-				db.models.station.updateOne({_id: stationId}, {$set: {partyMode: newPartyMode}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_PARTY_MODE", `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_UPDATE_PARTY_MODE", `Updated station "${stationId}" party mode to "${newPartyMode}" successfully.`);
-			cache.pub('station.updatePartyMode', {stationId: stationId, partyMode: newPartyMode});
-			stations.skipStation(stationId)();
-			return cb({'status': 'success', 'message': 'Successfully updated the party mode.'});
-		});
-	}),
-
-	/**
-	 * Pauses a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	pause: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.paused) return next('That station was already paused.');
-				db.models.station.updateOne({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_PAUSE", `Paused station "${stationId}" successfully.`);
-			cache.pub('station.pause', stationId);
-			notifications.unschedule(`stations.nextSong?id=${stationId}`);
-			return cb({'status': 'success', 'message': 'Successfully paused.'});
-		});
-	}),
-
-	/**
-	 * Resumes a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	resume: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, 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);
-				db.models.station.updateOne({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_RESUME", `Resuming station "${stationId}" successfully.`);
-			cache.pub('station.resume', stationId);
-			return cb({'status': 'success', 'message': 'Successfully resumed.'});
-		});
-	}),
-
-	/**
-	 * Removes a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	remove: hooks.ownerRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.station.deleteOne({ _id: stationId }, err => next(err));
-			},
-
-			(next) => {
-				cache.hdel('stations', stationId, err => next(err));
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
-				return cb({ 'status': 'failure', 'message': err });
-			}
-			logger.success("STATIONS_REMOVE", `Removing station "${stationId}" successfully.`);
-			cache.pub('station.remove', stationId);
-			return cb({ 'status': 'success', 'message': 'Successfully removed.' });
-		});
-	}),
-
-	/**
-	 * Create a station
-	 *
-	 * @param session
-	 * @param data - the station data
-	 * @param cb
-	 */
-	create: hooks.loginRequired((session, data, cb) => {
-		data.name = data.name.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([
-			(next) => {
-				if (!data) return next('Invalid data.');
-				next();
-			},
-
-			(next) => {
-				db.models.station.findOne({ $or: [{name: data.name}, {displayName: new RegExp(`^${data.displayName}$`, 'i')}] }, next);
-			},
-
-			(station, next) => {
-				if (station) return next('A station with that name or display name already exists.');
-				const { name, displayName, description, genres, playlist, type, blacklistedGenres } = data;
-				if (type === 'official') {
-					db.models.user.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.');
-						db.models.station.create({
-							name,
-							displayName,
-							description,
-							type,
-							privacy: 'private',
-							playlist,
-							genres,
-							blacklistedGenres,
-							currentSong: stations.defaultSong
-						}, next);
-					});
-				} else if (type === 'community') {
-					if (blacklist.indexOf(name) !== -1) return next('That name is blacklisted. Please use a different name.');
-					db.models.station.create({
-						name,
-						displayName,
-						description,
-						type,
-						privacy: 'private',
-						owner: session.userId,
-						queue: [],
-						currentSong: null
-					}, next);
-				}
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_CREATE", `Creating station failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_CREATE", `Created station "${station._id}" successfully.`);
-			cache.pub('station.create', station._id);
-			return cb({'status': 'success', 'message': 'Successfully created station.'});
-		});
-	}),
-
-	/**
-	 * Adds song to station queue
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	addToQueue: hooks.loginRequired((session, stationId, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.locked) {
-					db.models.user.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.');
-						else return next(null, station);
-					});
-				} else {
-					return next(null, station);
-				}
-			},
-
-			(station, next) => {
-				if (station.type !== 'community') return next('That station is not a community station.');
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next(null, station);
-					return next('Insufficient permissions.');
-				});
-			},
-
-			(station, next) => {
-				if (station.currentSong && station.currentSong.songId === songId) return next('That song is currently playing.');
-				async.each(station.queue, (queueSong, next) => {
-					if (queueSong.songId === songId) return next('That song is already in the queue.');
-					next();
-				}, (err) => {
-					next(err, station);
-				});
-			},
-
-			(station, next) => {
-				songs.getSong(songId, (err, song) => {
-					if (!err && song) return next(null, song, station);
-					utils.getSongFromYouTube(songId, (song) => {
-						song.artists = [];
-						song.skipDuration = 0;
-						song.likes = -1;
-						song.dislikes = -1;
-						song.thumbnail = "empty";
-						song.explicit = false;
-						next(null, song, station);
-					});
-				});
-			},
-
-			(song, station, next) => {
-				let queue = station.queue;
-				song.requestedBy = session.userId;
-				queue.push(song);
-
-				let totalDuration = 0;
-				queue.forEach((song) => {
-					totalDuration += song.duration;
-				});
-				if (totalDuration >= 3600 * 3) return next('The max length of the queue is 3 hours.');
-				next(null, song, station);
-			},
-
-			(song, station, next) => {
-				let queue = station.queue;
-				if (queue.length === 0) return next(null, song, station);
-				let totalDuration = 0;
-				const userId = queue[queue.length - 1].requestedBy;
-				station.queue.forEach((song) => {
-					if (userId === song.requestedBy) {
-						totalDuration += song.duration;
-					}
-				});
-
-				if(totalDuration >= 900) return next('The max length of songs per user is 15 minutes.');
-				next(null, song, station);
-			},
-
-			(song, station, next) => {
-				let queue = station.queue;
-				if (queue.length === 0) return next(null, song);
-				let totalSongs = 0;
-				const userId = queue[queue.length - 1].requestedBy;
-				queue.forEach((song) => {
-					if (userId === song.requestedBy) {
-						totalSongs++;
-					}
-				});
-
-				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 (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId) return next('The max amount of songs per user is 3, and only 2 in a row is allowed.');
-				next(null, song);
-			},
-
-			(song, next) => {
-				db.models.station.updateOne({_id: stationId}, {$push: {queue: song}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_ADD_SONG_TO_QUEUE", `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_ADD_SONG_TO_QUEUE", `Added song "${songId}" to station "${stationId}" successfully.`);
-			cache.pub('station.queueUpdate', stationId);
-			return cb({'status': 'success', 'message': 'Successfully added song to queue.'});
-		});
-	}),
-
-	/**
-	 * Removes song from station queue
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param songId - the song id
-	 * @param cb
-	 */
-	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!songId) return next('Invalid song id.');
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.type !== 'community') return next('Station is not a community station.');
-				async.each(station.queue, (queueSong, next) => {
-					if (queueSong.songId === songId) return next(true);
-					next();
-				}, (err) => {
-					if (err === true) return next();
-					next('Song is not currently in the queue.');
-				});
-			},
-
-			(next) => {
-				db.models.station.updateOne({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_REMOVE_SONG_TO_QUEUE", `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_REMOVE_SONG_TO_QUEUE", `Removed song "${songId}" from station "${stationId}" successfully.`);
-			cache.pub('station.queueUpdate', stationId);
-			return cb({'status': 'success', 'message': 'Successfully removed song from queue.'});
-		});
-	}),
-
-	/**
-	 * Gets the queue from a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param cb
-	 */
-	getQueue: (session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.type !== 'community') return next('Station is not a community station.');
-				next(null, station);
-			},
-
-			(station, next) => {
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next(null, station);
-					return next('Insufficient permissions.');
-				});
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_GET_QUEUE", `Getting queue for station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_GET_QUEUE", `Got queue for station "${stationId}" successfully.`);
-			return cb({'status': 'success', 'message': 'Successfully got queue.', queue: station.queue});
-		});
-	},
-
-	/**
-	 * Selects a private playlist for a station
-	 *
-	 * @param session
-	 * @param stationId - the station id
-	 * @param playlistId - the private playlist id
-	 * @param cb
-	 */
-	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				if (station.type !== 'community') return next('Station is not a community station.');
-				if (station.privatePlaylist === playlistId) return next('That private playlist is already selected.');
-				db.models.playlist.findOne({_id: playlistId}, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist) return next('Playlist not found.');
-				let currentSongIndex = (playlist.songs.length > 0) ? playlist.songs.length - 1 : 0;
-				db.models.station.updateOne({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				stations.updateStation(stationId, next);
-			}
-		], async (err, station) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selected private playlist "${playlistId}" for station "${stationId}" successfully.`);
-			notifications.unschedule(`stations.nextSong?id${stationId}`);
-			if (!station.partyMode) stations.skipStation(stationId)();
-			cache.pub('privatePlaylist.selected', {playlistId, stationId});
-			return cb({'status': 'success', 'message': 'Successfully selected playlist.'});
-		});
-	}),
-
-	favoriteStation: hooks.loginRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				stations.getStation(stationId, next);
-			},
-
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				stations.canUserViewStation(station, session.userId, (err, canView) => {
-					if (err) return next(err);
-					if (canView) return next();
-					return next('Insufficient permissions.');
-				});
-			},
-
-			(next) => {
-				db.models.user.updateOne({ _id: session.userId }, { $addToSet: { favoriteStations: stationId } }, next);
-			},
-
-			(res, next) => {
-				if (res.nModified === 0) return next("The station was already favorited.");
-				next();
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("FAVORITE_STATION", `Favoriting station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("FAVORITE_STATION", `Favorited station "${stationId}" successfully.`);
-			cache.pub('user.favoritedStation', { userId: session.userId, stationId });
-			return cb({'status': 'success', 'message': 'Succesfully favorited station.'});
-		});
-	}),
-
-	unfavoriteStation: hooks.loginRequired((session, stationId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.updateOne({ _id: session.userId }, { $pull: { favoriteStations: stationId } }, next);
-			},
-
-			(res, next) => {
-				if (res.nModified === 0) return next("The station wasn't favorited.");
-				next();
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("UNFAVORITE_STATION", `Unfavoriting station "${stationId}" failed. "${err}"`);
-				return cb({'status': 'failure', 'message': err});
-			}
-			logger.success("UNFAVORITE_STATION", `Unfavorited station "${stationId}" successfully.`);
-			cache.pub('user.unfavoritedStation', { userId: session.userId, stationId });
-			return cb({'status': 'success', 'message': 'Succesfully unfavorited station.'});
-		});
-	}),
+    /**
+     * Get a list of all the stations
+     *
+     * @param session
+     * @param cb
+     * @return {{ status: String, stations: Array }}
+     */
+    index: (session, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    console.log(111);
+                    cache
+                        .runJob("HGETALL", { table: "stations" })
+                        .then((stations) => {
+                            next(null, stations);
+                        });
+                },
+
+                (stations, next) => {
+                    console.log(222);
+
+                    let resultStations = [];
+                    for (let id in stations) {
+                        resultStations.push(stations[id]);
+                    }
+                    next(null, stations);
+                },
+
+                (stationsArray, next) => {
+                    console.log(333);
+
+                    let resultStations = [];
+                    async.each(
+                        stationsArray,
+                        (station, next) => {
+                            async.waterfall(
+                                [
+                                    (next) => {
+                                        stations
+                                            .runJob("CAN_USER_VIEW_STATION", {
+                                                station,
+                                                userId: session.userId,
+                                            })
+                                            .then((exists) => {
+                                                console.log(444, exists);
+
+                                                next(null, exists);
+                                            })
+                                            .catch(next);
+                                    },
+                                ],
+                                (err, exists) => {
+                                    if (err) console.log(err);
+                                    station.userCount =
+                                        usersPerStationCount[station._id] || 0;
+                                    if (exists) resultStations.push(station);
+                                    next();
+                                }
+                            );
+                        },
+                        () => {
+                            next(null, resultStations);
+                        }
+                    );
+                },
+            ],
+            async (err, stations) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_INDEX",
+                        `Indexing stations failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_INDEX",
+                    `Indexing stations successful.`,
+                    false
+                );
+                return cb({ status: "success", stations: stations });
+            }
+        );
+    },
+
+    /**
+     * Obtains basic metadata of a station in order to format an activity
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    getStationForActivity: (session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_GET_STATION_FOR_ACTIVITY",
+                        `Failed to obtain metadata of station ${stationId} for activity formatting. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.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
+     *
+     * @param session
+     * @param stationName - the station name
+     * @param cb
+     */
+    existsByName: (session, stationName, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION_BY_NAME", { stationName })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next(null, false);
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((exists) => next(null, exists))
+                        .catch(next);
+                },
+            ],
+            async (err, exists) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATION_EXISTS_BY_NAME",
+                        `Checking if station "${stationName}" exists failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATION_EXISTS_BY_NAME",
+                    `Station "${stationName}" exists successfully.` /*, false*/
+                );
+                cb({ status: "success", exists });
+            }
+        );
+    },
+
+    /**
+     * Gets the official playlist for a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    getPlaylist: (session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next(null, station);
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    else if (station.type !== "official")
+                        return next("This is not an official station.");
+                    else next();
+                },
+
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "officialPlaylists",
+                            key: stationId,
+                        })
+                        .then((playlist) => next(null, playlist))
+                        .catch(next);
+                },
+
+                (playlist, next) => {
+                    if (!playlist) return next("Playlist not found.");
+                    next(null, playlist);
+                },
+            ],
+            async (err, playlist) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_GET_PLAYLIST",
+                        `Getting playlist for station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_GET_PLAYLIST",
+                        `Got playlist for station "${stationId}" successfully.`,
+                        false
+                    );
+                    cb({ status: "success", data: playlist.songs });
+                }
+            }
+        );
+    },
+
+    /**
+     * Joins the station by its name
+     *
+     * @param session
+     * @param stationName - the station name
+     * @param cb
+     * @return {{ status: String, userCount: Integer }}
+     */
+    join: (session, stationName, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION_BY_NAME", { stationName })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (!canView) next("Not allowed to join station.");
+                            else next(null, station);
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (station, next) => {
+                    utils.runJob("SOCKET_JOIN_ROOM", {
+                        socketId: session.socketId,
+                        room: `station.${station._id}`,
+                    });
+                    let 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,
+                        privacy: station.privacy,
+                        locked: station.locked,
+                        partyMode: station.partyMode,
+                        owner: station.owner,
+                        privatePlaylist: station.privatePlaylist,
+                    };
+                    userList[session.socketId] = station._id;
+                    next(null, data);
+                },
+
+                (data, next) => {
+                    data = JSON.parse(JSON.stringify(data));
+                    data.userCount = usersPerStationCount[data._id] || 0;
+                    data.users = usersPerStation[data._id] || [];
+                    if (!data.currentSong || !data.currentSong.title)
+                        return next(null, data);
+                    utils.runJob("SOCKET_JOIN_SONG_ROOM", {
+                        socketId: session.socketId,
+                        room: `song.${data.currentSong.songId}`,
+                    });
+                    data.currentSong.skipVotes =
+                        data.currentSong.skipVotes.length;
+                    songs
+                        .runJob("GET_SONG_FROM_ID", {
+                            songId: data.currentSong.songId,
+                        })
+                        .then((response) => {
+                            const song = response.song;
+                            if (song) {
+                                data.currentSong.likes = song.likes;
+                                data.currentSong.dislikes = song.dislikes;
+                            } else {
+                                data.currentSong.likes = -1;
+                                data.currentSong.dislikes = -1;
+                            }
+                        })
+                        .catch((err) => {
+                            data.currentSong.likes = -1;
+                            data.currentSong.dislikes = -1;
+                        })
+                        .finally(() => {
+                            next(null, data);
+                        });
+                },
+            ],
+            async (err, data) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_JOIN",
+                        `Joining station "${stationName}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_JOIN",
+                    `Joined station "${data._id}" successfully.`
+                );
+                cb({ status: "success", data });
+            }
+        );
+    },
+
+    /**
+     * Toggles if a station is locked
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    toggleLock: hooks.ownerRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $set: { locked: !station.locked } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_UPDATE_LOCKED_STATUS",
+                        `Toggling the queue lock for station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_LOCKED_STATUS",
+                        `Toggled the queue lock for station "${stationId}" successfully to "${station.locked}".`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "station.queueLockToggled",
+                        value: {
+                            stationId,
+                            locked: station.locked,
+                        },
+                    });
+                    return cb({ status: "success", data: station.locked });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Votes to skip a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    voteSkip: hooks.loginRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+
+        let skipVotes = 0;
+        let shouldSkip = false;
+
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next(null, station);
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return 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."
+                        );
+                    next(null, station);
+                },
+
+                (station, next) => {
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $push: { "currentSong.skipVotes": session.userId } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    next(null, station);
+                },
+
+                (station, next) => {
+                    skipVotes = station.currentSong.skipVotes.length;
+                    utils
+                        .runJob("GET_ROOM_SOCKETS", {
+                            room: `station.${stationId}`,
+                        })
+                        .then((sockets) => next(null, sockets))
+                        .catch(next);
+                },
+
+                (sockets, next) => {
+                    if (sockets.length <= skipVotes) shouldSkip = true;
+                    next();
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_VOTE_SKIP",
+                        `Vote skipping station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_VOTE_SKIP",
+                    `Vote skipping "${stationId}" successful.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.voteSkipSong",
+                    value: stationId,
+                });
+                cb({
+                    status: "success",
+                    message: "Successfully voted to skip the song.",
+                });
+                if (shouldSkip) stations.runJob("SKIP_STATION", { stationId });
+            }
+        );
+    }),
+
+    /**
+     * Force skips a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    forceSkip: hooks.ownerRequired((session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    next();
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_FORCE_SKIP",
+                        `Force skipping station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                notifications.runJob("UNSCHEDULE", {
+                    name: `stations.nextSong?id=${stationId}`,
+                });
+                stations.runJob("SKIP_STATION", { stationId });
+                console.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 session
+     * @param stationId
+     * @param cb
+     * @return {{ status: String, userCount: Integer }}
+     */
+    leave: (session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    next();
+                },
+            ],
+            async (err, userCount) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_LEAVE",
+                        `Leaving station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_LEAVE",
+                    `Left station "${stationId}" successfully.`
+                );
+                utils.runJob("SOCKET_LEAVE_ROOMS", { socketId: session });
+                delete userList[session.socketId];
+                return cb({
+                    status: "success",
+                    message: "Successfully left station.",
+                    userCount,
+                });
+            }
+        );
+    },
+
+    /**
+     * Updates a station's name
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param newName - the new station name
+     * @param cb
+     */
+    updateName: hooks.ownerRequired(async (session, stationId, newName, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $set: { name: newName } },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_UPDATE_NAME",
+                        `Updating station "${stationId}" name to "${newName}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_UPDATE_NAME",
+                    `Updated station "${stationId}" name to "${newName}" successfully.`
+                );
+                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: hooks.ownerRequired(
+        async (session, stationId, newDisplayName, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { displayName: newDisplayName } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_DISPLAY_NAME",
+                            `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_DISPLAY_NAME",
+                        `Updated station "${stationId}" displayName to "${newDisplayName}" successfully.`
+                    );
+                    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: hooks.ownerRequired(
+        async (session, stationId, newDescription, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { description: newDescription } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_DESCRIPTION",
+                            `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_DESCRIPTION",
+                        `Updated station "${stationId}" description to "${newDescription}" successfully.`
+                    );
+                    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: hooks.ownerRequired(
+        async (session, stationId, newPrivacy, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { privacy: newPrivacy } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_PRIVACY",
+                            `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_PRIVACY",
+                        `Updated station "${stationId}" privacy to "${newPrivacy}" successfully.`
+                    );
+                    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: hooks.ownerRequired(
+        async (session, stationId, newGenres, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { genres: newGenres } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_GENRES",
+                            `Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_GENRES",
+                        `Updated station "${stationId}" genres to "${newGenres}" successfully.`
+                    );
+                    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: hooks.ownerRequired(
+        async (session, stationId, newBlacklistedGenres, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            {
+                                $set: {
+                                    blacklistedGenres: newBlacklistedGenres,
+                                },
+                            },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_BLACKLISTED_GENRES",
+                            `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_BLACKLISTED_GENRES",
+                        `Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`
+                    );
+                    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: hooks.ownerRequired(
+        async (session, stationId, newPartyMode, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stations
+                            .runJob("GET_STATION", { stationId })
+                            .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.")
+                            );
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $set: { partyMode: newPartyMode } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_UPDATE_PARTY_MODE",
+                            `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_UPDATE_PARTY_MODE",
+                        `Updated station "${stationId}" party mode to "${newPartyMode}" successfully.`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "station.updatePartyMode",
+                        value: {
+                            stationId: stationId,
+                            partyMode: newPartyMode,
+                        },
+                    });
+                    stations.runJob("SKIP_STATION", { stationId });
+                    return cb({
+                        status: "success",
+                        message: "Successfully updated the party mode.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Pauses a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    pause: hooks.ownerRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .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.");
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $set: { paused: true, pausedAt: Date.now() } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_PAUSE",
+                        `Pausing station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_PAUSE",
+                    `Paused station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.pause",
+                    value: stationId,
+                });
+                notifications.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: hooks.ownerRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .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;
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        {
+                            $set: { paused: false },
+                            $inc: { timePaused: Date.now() - station.pausedAt },
+                        },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_RESUME",
+                        `Resuming station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_RESUME",
+                    `Resuming station "${stationId}" successfully.`
+                );
+                cache.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: hooks.ownerRequired(async (session, stationId, cb) => {
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stationModel.deleteOne({ _id: stationId }, (err) =>
+                        next(err)
+                    );
+                },
+
+                (next) => {
+                    cache
+                        .runJob("HDEL", { table: "stations", key: stationId })
+                        .then(next)
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_REMOVE",
+                        `Removing station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_REMOVE",
+                    `Removing station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.remove",
+                    value: stationId,
+                });
+                activities.runJob("ADD_ACTIVITY", {
+                    userId: session.userId,
+                    activityType: "deleted_station",
+                    payload: [stationId],
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully removed.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Create a station
+     *
+     * @param session
+     * @param data - the station data
+     * @param cb
+     */
+    create: hooks.loginRequired(async (session, data, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+
+        data.name = data.name.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(
+            [
+                (next) => {
+                    if (!data) return next("Invalid data.");
+                    next();
+                },
+
+                (next) => {
+                    stationModel.findOne(
+                        {
+                            $or: [
+                                { name: data.name },
+                                {
+                                    displayName: new RegExp(
+                                        `^${data.displayName}$`,
+                                        "i"
+                                    ),
+                                },
+                            ],
+                        },
+                        next
+                    );
+                },
+
+                (station, next) => {
+                    if (station)
+                        return next(
+                            "A station with that name or display name already exists."
+                        );
+                    const {
+                        name,
+                        displayName,
+                        description,
+                        genres,
+                        playlist,
+                        type,
+                        blacklistedGenres,
+                    } = data;
+                    if (type === "official") {
+                        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.");
+                                stationModel.create(
+                                    {
+                                        name,
+                                        displayName,
+                                        description,
+                                        type,
+                                        privacy: "private",
+                                        playlist,
+                                        genres,
+                                        blacklistedGenres,
+                                        currentSong: stations.defaultSong,
+                                    },
+                                    next
+                                );
+                            }
+                        );
+                    } else if (type === "community") {
+                        if (blacklist.indexOf(name) !== -1)
+                            return next(
+                                "That name is blacklisted. Please use a different name."
+                            );
+                        stationModel.create(
+                            {
+                                name,
+                                displayName,
+                                description,
+                                type,
+                                privacy: "private",
+                                owner: session.userId,
+                                queue: [],
+                                currentSong: null,
+                            },
+                            next
+                        );
+                    }
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_CREATE",
+                        `Creating station failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_CREATE",
+                    `Created station "${station._id}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.create",
+                    value: station._id,
+                });
+                activities.runJob("ADD_ACTIVITY", {
+                    userId: session.userId,
+                    activityType: "created_station",
+                    payload: [station._id],
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully created station.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Adds song to station queue
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param songId - the song id
+     * @param cb
+     */
+    addToQueue: hooks.loginRequired(async (session, stationId, songId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const stationModel = await db.runJob("GET_MODEL", {
+            modelName: "station",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    if (station.locked) {
+                        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."
+                                    );
+                                else return next(null, station);
+                            }
+                        );
+                    } else {
+                        return next(null, station);
+                    }
+                },
+
+                (station, next) => {
+                    if (station.type !== "community")
+                        return next("That station is not a community station.");
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next(null, station);
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (station, next) => {
+                    if (
+                        station.currentSong &&
+                        station.currentSong.songId === songId
+                    )
+                        return next("That song is currently playing.");
+                    async.each(
+                        station.queue,
+                        (queueSong, next) => {
+                            if (queueSong.songId === songId)
+                                return next(
+                                    "That song is already in the queue."
+                                );
+                            next();
+                        },
+                        (err) => {
+                            next(err, station);
+                        }
+                    );
+                },
+
+                (station, next) => {
+                    // songs
+                    //     .runJob("GET_SONG", { id: songId })
+                    //     .then((song) => {
+                    //         if (song) return next(null, song, station);
+                    //         else {
+                    utils
+                        .runJob("GET_SONG_FROM_YOUTUBE", { songId })
+                        .then((response) => {
+                            const song = response.song;
+                            song.artists = [];
+                            song.skipDuration = 0;
+                            song.likes = -1;
+                            song.dislikes = -1;
+                            song.thumbnail = "empty";
+                            song.explicit = false;
+                            next(null, song, station);
+                        })
+                        .catch((err) => {
+                            next(err);
+                        });
+                    //     }
+                    // })
+                    // .catch((err) => {
+                    //     next(err);
+                    // });
+                },
+
+                (song, station, next) => {
+                    let queue = station.queue;
+                    song.requestedBy = session.userId;
+                    queue.push(song);
+
+                    let totalDuration = 0;
+                    queue.forEach((song) => {
+                        totalDuration += song.duration;
+                    });
+                    if (totalDuration >= 3600 * 3)
+                        return next("The max length of the queue is 3 hours.");
+                    next(null, song, station);
+                },
+
+                (song, station, next) => {
+                    let queue = station.queue;
+                    if (queue.length === 0) return next(null, song, station);
+                    let totalDuration = 0;
+                    const userId = queue[queue.length - 1].requestedBy;
+                    station.queue.forEach((song) => {
+                        if (userId === song.requestedBy) {
+                            totalDuration += song.duration;
+                        }
+                    });
+
+                    if (totalDuration >= 900)
+                        return next(
+                            "The max length of songs per user is 15 minutes."
+                        );
+                    next(null, song, station);
+                },
+
+                (song, station, next) => {
+                    let queue = station.queue;
+                    if (queue.length === 0) return next(null, song);
+                    let totalSongs = 0;
+                    const userId = queue[queue.length - 1].requestedBy;
+                    queue.forEach((song) => {
+                        if (userId === song.requestedBy) {
+                            totalSongs++;
+                        }
+                    });
+
+                    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 (
+                        queue[queue.length - 2].requestedBy !== userId ||
+                        queue[queue.length - 3] !== userId
+                    )
+                        return next(
+                            "The max amount of songs per user is 3, and only 2 in a row is allowed."
+                        );
+                    next(null, song);
+                },
+
+                (song, next) => {
+                    stationModel.updateOne(
+                        { _id: stationId },
+                        { $push: { queue: song } },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    stations
+                        .runJob("UPDATE_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_ADD_SONG_TO_QUEUE",
+                        `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_ADD_SONG_TO_QUEUE",
+                    `Added song "${songId}" to station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "station.queueUpdate",
+                    value: stationId,
+                });
+                return cb({
+                    status: "success",
+                    message: "Successfully added song to queue.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Removes song from station queue
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param songId - the song id
+     * @param cb
+     */
+    removeFromQueue: hooks.ownerRequired(
+        async (session, stationId, songId, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!songId) return next("Invalid song id.");
+                        stations
+                            .runJob("GET_STATION", { stationId })
+                            .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.");
+                        async.each(
+                            station.queue,
+                            (queueSong, next) => {
+                                if (queueSong.songId === songId)
+                                    return next(true);
+                                next();
+                            },
+                            (err) => {
+                                if (err === true) return next();
+                                next("Song is not currently in the queue.");
+                            }
+                        );
+                    },
+
+                    (next) => {
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            { $pull: { queue: { songId: songId } } },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err, station) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_REMOVE_SONG_TO_QUEUE",
+                            `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_REMOVE_SONG_TO_QUEUE",
+                        `Removed song "${songId}" from station "${stationId}" successfully.`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "station.queueUpdate",
+                        value: stationId,
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Successfully removed song from queue.",
+                    });
+                }
+            );
+        }
+    ),
+
+    /**
+     * Gets the queue from a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param cb
+     */
+    getQueue: (session, stationId, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .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.");
+                    next(null, station);
+                },
+
+                (station, next) => {
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next(null, station);
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+            ],
+            async (err, station) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "STATIONS_GET_QUEUE",
+                        `Getting queue for station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "STATIONS_GET_QUEUE",
+                    `Got queue for station "${stationId}" successfully.`
+                );
+                return cb({
+                    status: "success",
+                    message: "Successfully got queue.",
+                    queue: station.queue,
+                });
+            }
+        );
+    },
+
+    /**
+     * Selects a private playlist for a station
+     *
+     * @param session
+     * @param stationId - the station id
+     * @param playlistId - the private playlist id
+     * @param cb
+     */
+    selectPrivatePlaylist: hooks.ownerRequired(
+        async (session, stationId, playlistId, cb) => {
+            const stationModel = await db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            const playlistModel = await db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stations
+                            .runJob("GET_STATION", { stationId })
+                            .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.privatePlaylist === playlistId)
+                            return next(
+                                "That private playlist is already selected."
+                            );
+                        playlistModel.findOne({ _id: playlistId }, next);
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist) return next("Playlist not found.");
+                        let currentSongIndex =
+                            playlist.songs.length > 0
+                                ? playlist.songs.length - 1
+                                : 0;
+                        stationModel.updateOne(
+                            { _id: stationId },
+                            {
+                                $set: {
+                                    privatePlaylist: playlistId,
+                                    currentSongIndex: currentSongIndex,
+                                },
+                            },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        stations
+                            .runJob("UPDATE_STATION", { stationId })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err, station) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "STATIONS_SELECT_PRIVATE_PLAYLIST",
+                            `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+                        );
+                        return cb({ status: "failure", message: err });
+                    }
+                    console.log(
+                        "SUCCESS",
+                        "STATIONS_SELECT_PRIVATE_PLAYLIST",
+                        `Selected private playlist "${playlistId}" for station "${stationId}" successfully.`
+                    );
+                    notifications.runJob("UNSCHEDULE", {
+                        name: `stations.nextSong?id${stationId}`,
+                    });
+                    if (!station.partyMode)
+                        stations.runJob("SKIP_STATION", { stationId });
+                    cache.runJob("PUB", {
+                        channel: "privatePlaylist.selected",
+                        value: {
+                            playlistId,
+                            stationId,
+                        },
+                    });
+                    return cb({
+                        status: "success",
+                        message: "Successfully selected playlist.",
+                    });
+                }
+            );
+        }
+    ),
+
+    favoriteStation: hooks.loginRequired(async (session, stationId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    stations
+                        .runJob("GET_STATION", { stationId })
+                        .then((station) => next(null, station))
+                        .catch(next);
+                },
+
+                (station, next) => {
+                    if (!station) return next("Station not found.");
+                    stations
+                        .runJob("CAN_USER_VIEW_STATION", {
+                            station,
+                            userId: session.userId,
+                        })
+                        .then((canView) => {
+                            if (canView) return next();
+                            return next("Insufficient permissions.");
+                        })
+                        .catch((err) => {
+                            return next(err);
+                        });
+                },
+
+                (next) => {
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $addToSet: { favoriteStations: stationId } },
+                        next
+                    );
+                },
+
+                (res, next) => {
+                    if (res.nModified === 0)
+                        return next("The station was already favorited.");
+                    next();
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "FAVORITE_STATION",
+                        `Favoriting station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "FAVORITE_STATION",
+                    `Favorited station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "user.favoritedStation",
+                    value: {
+                        userId: session.userId,
+                        stationId,
+                    },
+                });
+                return cb({
+                    status: "success",
+                    message: "Succesfully favorited station.",
+                });
+            }
+        );
+    }),
+
+    unfavoriteStation: hooks.loginRequired(async (session, stationId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        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.");
+                    next();
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "UNFAVORITE_STATION",
+                        `Unfavoriting station "${stationId}" failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "UNFAVORITE_STATION",
+                    `Unfavorited station "${stationId}" successfully.`
+                );
+                cache.runJob("PUB", {
+                    channel: "user.unfavoritedStation",
+                    value: {
+                        userId: session.userId,
+                        stationId,
+                    },
+                });
+                return cb({
+                    status: "success",
+                    message: "Succesfully unfavorited station.",
+                });
+            }
+        );
+    }),
 };

+ 2072 - 1139
backend/logic/actions/users.js

@@ -1,1159 +1,2092 @@
-'use strict';
-
-const async = require('async');
-const config = require('config');
-const request = require('request');
-const bcrypt = require('bcrypt');
-const sha256 = require('sha256');
-
-const hooks = require('./hooks');
-
-const moduleManager = require("../../index");
-
-const db = moduleManager.modules["db"];
-const mail = moduleManager.modules["mail"];
-const cache = moduleManager.modules["cache"];
-const punishments = moduleManager.modules["punishments"];
-const utils = moduleManager.modules["utils"];
-const logger = moduleManager.modules["logger"];
-
-cache.sub('user.updateUsername', user => {
-	utils.socketsFromUser(user._id, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.username.changed', user.username);
-		});
-	});
+"use strict";
+
+const async = require("async");
+const config = require("config");
+const request = require("request");
+const bcrypt = require("bcrypt");
+const sha256 = require("sha256");
+
+const hooks = require("./hooks");
+
+// const moduleManager = require("../../index");
+
+const db = require("../db");
+const mail = require("../mail");
+const cache = require("../cache");
+const punishments = require("../punishments");
+const utils = require("../utils");
+// const logger = require("../logger");
+const activities = require("../activities");
+
+cache.runJob("SUB", {
+    channel: "user.updateUsername",
+    cb: (user) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: user._id,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:user.username.changed", user.username);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.removeSessions', userId => {
-	utils.socketsFromUserWithoutCache(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('keep.event:user.session.removed');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.removeSessions",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER_WITHOUT_CACHE", {
+            userId: userId,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("keep.event:user.session.removed");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.linkPassword', userId => {
-	utils.socketsFromUser(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.linkPassword');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.linkPassword",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: userId,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:user.linkPassword");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.linkGitHub', userId => {
-	utils.socketsFromUser(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.linkGitHub');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.linkGitHub",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: userId,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:user.linkGitHub");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.unlinkPassword', userId => {
-	utils.socketsFromUser(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.unlinkPassword');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.unlinkPassword",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: userId,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:user.unlinkPassword");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.unlinkGitHub', userId => {
-	utils.socketsFromUser(userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.unlinkGitHub');
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.unlinkGitHub",
+    cb: (userId) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: userId,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:user.unlinkGitHub");
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.ban', data => {
-	utils.socketsFromUser(data.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('keep.event:banned', data.punishment);
-			socket.disconnect(true);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.ban",
+    cb: (data) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: data.userId,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("keep.event:banned", data.punishment);
+                    socket.disconnect(true);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.favoritedStation', data => {
-	utils.socketsFromUser(data.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.favoritedStation', data.stationId);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.favoritedStation",
+    cb: (data) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: data.userId,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit("event:user.favoritedStation", data.stationId);
+                });
+            },
+        });
+    },
 });
 
-cache.sub('user.unfavoritedStation', data => {
-	utils.socketsFromUser(data.userId, sockets => {
-		sockets.forEach(socket => {
-			socket.emit('event:user.unfavoritedStation', data.stationId);
-		});
-	});
+cache.runJob("SUB", {
+    channel: "user.unfavoritedStation",
+    cb: (data) => {
+        utils.runJob("SOCKETS_FROM_USER", {
+            userId: data.userId,
+            cb: (response) => {
+                response.sockets.forEach((socket) => {
+                    socket.emit(
+                        "event:user.unfavoritedStation",
+                        data.stationId
+                    );
+                });
+            },
+        });
+    },
 });
 
 module.exports = {
-
-	/**
-	 * Lists all Users
-	 *
-	 * @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.user.find({}).exec(next);
-			}
-		], async (err, users) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("USER_INDEX", `Indexing users failed. "${err}"`);
-				return cb({status: 'failure', message: err});
-			} else {
-				logger.success("USER_INDEX", `Indexing users successful.`);
-				let filteredUsers = [];
-				users.forEach(user => {
-					filteredUsers.push({
-						_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 }
-					});
-				});
-				return cb({ status: 'success', data: filteredUsers });
-			}
-		});
-	}),
-
-	/**
-	 * Logs user in
-	 *
-	 * @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 {Function} cb - gets called with the result
-	 */
-	login: (session, identifier, password, cb) => {
-
-		identifier = identifier.toLowerCase();
-
-		async.waterfall([
-
-			// check if a user with the requested identifier exists
-			(next) => {
-				db.models.user.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.');
-				bcrypt.compare(sha256(password), user.services.password.password, (err, match) => {
-					if (err) return next(err);
-					if (!match) return next('Incorrect password');
-					next(null, user);
-				});
-			},
-
-			(user, next) => {
-				utils.guid().then((sessionId) => {
-					next(null, user, sessionId);
-				});
-			},
-
-			(user, sessionId, next) => {
-				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
-					if (err) return next(err);
-					next(null, sessionId);
-				});
-			}
-
-		], async (err, sessionId) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("USER_PASSWORD_LOGIN", `Login failed with password for user "${identifier}". "${err}"`);
-				return cb({status: 'failure', message: err});
-			}
-			logger.success("USER_PASSWORD_LOGIN", `Login successful with password for user "${identifier}"`);
-			cb({ status: 'success', message: 'Login successful', user: {}, SID: sessionId });
-		});
-
-	},
-
-	/**
-	 * 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 {Function} cb - gets called with the result
-	 */
-	register: async function(session, username, email, password, recaptcha, cb) {
-		email = email.toLowerCase();
-		let verificationToken = await utils.generateRandomString(64);
-		async.waterfall([
-
-			// verify the request with google recaptcha
-			(next) => {
-				if (!db.passwordValid(password)) return next('Invalid password. Check if it meets all the requirements.');
-				return next();
-			},
-
-			(next) => {
-				request({
-					url: 'https://www.google.com/recaptcha/api/siteverify',
-					method: 'POST',
-					form: {
-						'secret': config.get("apis").recaptcha.secret,
-						'response': recaptcha
-					}
-				}, next);
-			},
-
-			// 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);
-			},
-
-			// 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);
-			},
-
-			// 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);
-			},
-
-			// hash the password
-			(salt, next) => {
-				bcrypt.hash(sha256(password), salt, next)
-			},
-
-			(hash, next) => {
-				utils.generateRandomString(12).then((_id) => {
-					next(null, hash, _id);
-				});
-			},
-
-			// save the new user to the database
-			(hash, _id, next) => {
-				db.models.user.create({
-					_id,
-					username,
-					email: {
-						address: email,
-						verificationToken
-					},
-					services: {
-						password: {
-							password: hash
-						}
-					}
-				}, next);
-			},
-
-			// respond with the new user
-			(newUser, next) => {
-				//TODO Send verification email
-				mail.schemas.verifyEmail(email, username, verificationToken, () => {
-					next();
-				});
-			}
-
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("USER_PASSWORD_REGISTER", `Register failed with password for user "${username}"."${err}"`);
-				cb({status: 'failure', message: err});
-			} 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);
-				});
-			}
-		});
-
-	},
-
-	/**
-	 * Logs out a user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	logout: (session, cb) => {
-
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
-
-			(session, next) => {
-				if (!session) return next('Session not found');
-				next(null, session);
-			},
-
-			(session, next) => {
-				cache.hdel('sessions', session.sessionId, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("USER_LOGOUT", `Logout failed. "${err}" `);
-				cb({ status: 'failure', message: err });
-			} else {
-				logger.success("USER_LOGOUT", `Logout successful.`);
-				cb({ status: 'success', message: 'Successfully logged out.' });
-			}
-		});
-
-	},
-
-	/**
-	 * Removes all sessions for a user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} userId - the id of the user we are trying to delete the sessions of
-	 * @param {Function} cb - gets called with the result
-	 */
-	removeSessions:  hooks.loginRequired((session, userId, cb) => {
-
-		async.waterfall([
-
-			(next) => {
-				db.models.user.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.');
-					else return next();
-				});
-			},
-
-			(next) => {
-				cache.hgetall('sessions', next);
-			},
-
-			(sessions, next) => {
-				if (!sessions) return next('There are no sessions for this user to remove.');
-				else {
-					let keys = Object.keys(sessions);
-					next(null, keys, sessions);
-				}
-			},
-
-			(keys, sessions, next) => {
-				cache.pub('user.removeSessions', userId);
-				async.each(keys, (sessionId, callback) => {
-					let session = sessions[sessionId];
-					if (session.userId === userId) {
-						cache.hdel('sessions', sessionId, err => {
-							if (err) return callback(err);
-							else callback(null);
-						});
-					}
-				}, err => {
-					next(err);
-				});
-			}
-
-		], async err => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("REMOVE_SESSIONS_FOR_USER", `Couldn't remove all sessions for user "${userId}". "${err}"`);
-				return cb({ status: 'failure', message: err });
-			} else {
-				logger.success("REMOVE_SESSIONS_FOR_USER", `Removed all sessions for user "${userId}".`);
-				return cb({ status: 'success', message: 'Successfully removed all sessions.' });
-			}
-		});
-
-	}),
-
-	/**
-	 * 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 {Function} cb - gets called with the result
-	 */
-	findByUsername: (session, username, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ username: new RegExp(`^${username}$`, 'i') }, next);
-			},
-
-			(account, next) => {
-				if (!account) return next('User not found.');
-				next(null, account);
-			}
-		], async (err, account) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("FIND_BY_USERNAME", `User found for username "${username}".`);
-				return cb({
-					status: 'success',
-					data: {
-						_id: account._id,
-						username: account.username,
-						role: account.role,
-						email: account.email.address,
-						createdAt: account.createdAt,
-						statistics: account.statistics,
-						liked: account.liked,
-						disliked: account.disliked
-					}
-				});
-			}
-		});
-	},
-
-
-	/**
-	 * Gets a username from an userId
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} userId - the userId of the person we are trying to get the username from
-	 * @param {Function} cb - gets called with the result
-	 */
-	getUsernameFromId: (session, userId, cb) => {
-		db.models.user.findById(userId).then(user => {
-			if (user) {
-				logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
-				return cb({
-					status: 'success',
-					data: user.username
-				});
-			} else {
-				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. User not found.`);
-				cb({
-					status: 'failure',
-					message: "Couldn't find the user."
-				});
-			}
-			
-		}).catch(async err => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. "${err}"`);
-				cb({ status: 'failure', message: err });
-			}
-		});
-	},
-
-	//TODO Fix security issues
-	/**
-	 * Gets user info from session
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	findBySession: (session, cb) => {
-		async.waterfall([
-			(next) => {
-				cache.hget('sessions', session.sessionId, next);
-			},
-
-			(session, next) => {
-				if (!session) return next('Session not found.');
-				next(null, session);
-			},
-
-			(session, next) => {
-				db.models.user.findOne({ _id: session.userId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				next(null, user);
-			}
-		], async (err, user) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("FIND_BY_SESSION", `User not found. "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				let data = {
-					email: {
-						address: user.email.address
-					},
-					username: user.username
-				};
-				if (user.services.password && user.services.password.password) data.password = true;
-				if (user.services.github && user.services.github.id) data.github = true;
-				logger.success("FIND_BY_SESSION", `User found. "${user.username}".`);
-				return cb({
-					status: 'success',
-					data
-				});
-			}
-		});
-	},
-
-	/**
-	 * 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 {Function} cb - gets called with the result
-	 */
-	updateUsername: hooks.loginRequired((session, updatingUserId, newUsername, cb) => {
-		async.waterfall([
-			(next) => {
-				if (updatingUserId === session.userId) return next(null, true);
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
-				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);
-			},
-
-			(user, next) => {
-				if (!user) return next();
-				if (user._id === updatingUserId) return next();
-				next('That username is already in use.');
-			},
-
-			(next) => {
-				db.models.user.updateOne({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_USERNAME", `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				cache.pub('user.updateUsername', {
-					username: newUsername,
-					_id: updatingUserId
-				});
-				logger.success("UPDATE_USERNAME", `Updated username for user "${updatingUserId}" to username "${newUsername}".`);
-				cb({ status: 'success', message: 'Username updated successfully' });
-			}
-		});
-	}),
-
-	/**
-	 * 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 {Function} cb - gets called with the result
-	 */
-	updateEmail: hooks.loginRequired(async (session, updatingUserId, newEmail, cb) => {
-		newEmail = newEmail.toLowerCase();
-		let verificationToken = await utils.generateRandomString(64);
-		async.waterfall([
-			(next) => {
-				if (updatingUserId === session.userId) return next(null, true);
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
-				db.models.user.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.');
-				next();
-			},
-
-			(next) => {
-				db.models.user.findOne({"email.address": newEmail}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next();
-				if (user._id === updatingUserId) return next();
-				next('That email is already in use.');
-			},
-
-			(next) => {
-				db.models.user.updateOne({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, {runValidators: true}, next);
-			},
-
-			(res, next) => {
-				db.models.user.findOne({ _id: updatingUserId }, next);
-			},
-
-			(user, next) => {
-				mail.schemas.verifyEmail(newEmail, user.username, verificationToken, () => {
-					next();
-				});
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_EMAIL", `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UPDATE_EMAIL", `Updated email for user "${updatingUserId}" to email "${newEmail}".`);
-				cb({ status: 'success', message: 'Email updated successfully.' });
-			}
-		});
-	}),
-
-	/**
-	 * 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 {Function} cb - gets called with the result
-	 */
-	updateRole: hooks.adminRequired((session, updatingUserId, newRole, cb) => {
-		newRole = newRole.toLowerCase();
-		async.waterfall([
-
-			(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.updateOne({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
-			}
-
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_ROLE", `User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UPDATE_ROLE", `User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`);
-				cb({
-					status: 'success',
-					message: 'Role successfully updated.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Updates a user's password
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} newPassword - the new password
-	 * @param {Function} cb - gets called with the result
-	 */
-	updatePassword: hooks.loginRequired((session, newPassword, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user.services.password) return next('This account does not have a password set.');
-				next();
-			},
-
-			(next) => {
-				if (!db.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) => {
-				db.models.user.updateOne({_id: session.userId}, {$set: {"services.password.password": hashedPassword}}, next);
-			}
-		], async (err) => {
-			if (err) {
-				err = await utils.getError(err);
-				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${session.userId}'. '${err}'.`);
-				return cb({ status: 'failure', message: err });
-			}
-
-			logger.success("UPDATE_PASSWORD", `User '${session.userId}' updated their password.`);
-			cb({
-				status: 'success',
-				message: 'Password successfully updated.'
-			});
-		});
-	}),
-
-	/**
-	 * Requests a password for a session
-	 *
-	 * @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 {Function} cb - gets called with the result
-	 */
-	requestPassword: hooks.loginRequired(async (session, cb) => {
-		let code = await utils.generateRandomString(8);
-		async.waterfall([
-			(next) => {
-				db.models.user.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.');
-				next(null, user);
-			},
-
-			(user, next) => {
-				let expires = new Date();
-				expires.setDate(expires.getDate() + 1);
-				db.models.user.findOneAndUpdate({"email.address": user.email.address}, {$set: {"services.password": {set: {code: code, expires}}}}, {runValidators: true}, next);
-			},
-
-			(user, next) => {
-				mail.schemas.passwordRequest(user.email.address, user.username, code, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("REQUEST_PASSWORD", `UserId '${session.userId}' failed to request password. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("REQUEST_PASSWORD", `UserId '${session.userId}' successfully requested a password.`);
-				cb({
-					status: 'success',
-					message: 'Successfully requested password.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Verifies a password code
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password code
-	 * @param {Function} cb - gets called with the result
-	 */
-	verifyPasswordCode: hooks.loginRequired((session, code, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!code || typeof code !== 'string') return next('Invalid code1.');
-				db.models.user.findOne({"services.password.set.code": code, _id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('Invalid code2.');
-				if (user.services.password.set.expires < new Date()) return next('That code has expired.');
-				next(null);
-			}
-		], async(err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("VERIFY_PASSWORD_CODE", `Code '${code}' successfully verified.`);
-				cb({
-					status: 'success',
-					message: 'Successfully verified password code.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Adds a password to a user with a code
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password code
-	 * @param {String} newPassword - the new password code
-	 * @param {Function} cb - gets called with the result
-	 */
-	changePasswordWithCode: hooks.loginRequired((session, code, newPassword, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!code || typeof code !== 'string') return next('Invalid code1.');
-				db.models.user.findOne({"services.password.set.code": code}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('Invalid code2.');
-				if (!user.services.password.set.expires > new Date()) return next('That code has expired.');
-				next();
-			},
-
-			(next) => {
-				if (!db.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) => {
-				db.models.user.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 utils.getError(err);
-				logger.error("ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
-				cache.pub('user.linkPassword', session.userId);
-				cb({
-					status: 'success',
-					message: 'Successfully added password.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Unlinks password from user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	unlinkPassword: hooks.loginRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.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.');
-				db.models.user.updateOne({_id: session.userId}, {$unset: {"services.password": ''}}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UNLINK_PASSWORD", `Unlinking password failed for userId '${session.userId}'. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UNLINK_PASSWORD", `Unlinking password successful for userId '${session.userId}'.`);
-				cache.pub('user.unlinkPassword', session.userId);
-				cb({
-					status: 'success',
-					message: 'Successfully unlinked password.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Unlinks GitHub from user
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
-	 */
-	unlinkGitHub: hooks.loginRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.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.');
-				db.models.user.updateOne({_id: session.userId}, {$unset: {"services.github": ''}}, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${session.userId}'. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("UNLINK_GITHUB", `Unlinking GitHub successful for userId '${session.userId}'.`);
-				cache.pub('user.unlinkGitHub', session.userId);
-				cb({
-					status: 'success',
-					message: 'Successfully unlinked GitHub.'
-				});
-			}
-		});
-	}),
-
-	/**
-	 * Requests a password reset for an email
-	 *
-	 * @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 {Function} cb - gets called with the result
-	 */
-	requestPasswordReset: async (session, email, cb) => {
-		let code = await utils.generateRandomString(8);
-		async.waterfall([
-			(next) => {
-				if (!email || typeof email !== 'string') return next('Invalid email.');
-				email = email.toLowerCase();
-				db.models.user.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.');
-				next(null, user);
-			},
-
-			(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}}}, {runValidators: true}, next);
-			},
-
-			(user, next) => {
-				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("REQUEST_PASSWORD_RESET", `Email '${email}' successfully requested a password reset.`);
-				cb({
-					status: 'success',
-					message: 'Successfully requested password reset.'
-				});
-			}
-		});
-	},
-
-	/**
-	 * Verifies a reset code
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {String} code - the password reset code
-	 * @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);
-			},
-
-			(user, next) => {
-				if (!user) return next('Invalid code.');
-				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
-				next(null);
-			}
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' successfully verified.`);
-				cb({
-					status: 'success',
-					message: 'Successfully verified password 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 {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);
-			},
-
-			(user, next) => {
-				if (!user) return next('Invalid code.');
-				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
-				next();
-			},
-
-			(next) => {
-				if (!db.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) => {
-				db.models.user.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 utils.getError(err);
-				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' successfully changed password.`);
-				cb({
-					status: 'success',
-					message: 'Successfully changed password.'
-				});
-			}
-		});
-	},
-
-	/**
-	 * Bans a user by userId
-	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @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: hooks.adminRequired((session, userId, reason, expiresAt, cb) => {
-		async.waterfall([
-			(next) => {
-				if (!userId) return next('You must provide a userId to ban.');
-				else if (!reason) return next('You must provide a reason for the ban.');
-				else return next();
-			},
-
-			(next) => {
-				if (!expiresAt || typeof expiresAt !== 'string') return next('Invalid expire date.');
-				let 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.');
-				}
-
-				next();
-			},
-
-			(next) => {
-				punishments.addPunishment('banUserId', userId, reason, expiresAt, userId, next)
-			},
-
-			(punishment, next) => {
-				cache.pub('user.ban', { userId, punishment });
-				next();
-			},
-		], async (err) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("BAN_USER_BY_ID", `User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("BAN_USER_BY_ID", `User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`);
-				cb({
-					status: 'success',
-					message: 'Successfully banned user.'
-				});
-			}
-		});
-	}),
-
-	getFavoriteStations: hooks.loginRequired((session, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ _id: session.userId }, next);
-			},
-
-			(user, next) => {
-				if (!user) return next("User not found.");
-				next(null, user);
-			}
-		], async (err, user) => {
-			if (err && err !== true) {
-				err = await utils.getError(err);
-				logger.error("GET_FAVORITE_STATIONS", `User ${session.userId} failed to get favorite stations. '${err}'`);
-				cb({status: 'failure', message: err});
-			} else {
-				logger.success("GET_FAVORITE_STATIONS", `User ${session.userId} got favorite stations.`);
-				cb({
-					status: 'success',
-					favoriteStations: user.favoriteStations
-				});
-			}
-		});
-	})
+    /**
+     * Lists all Users
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    index: hooks.adminRequired(async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.find({}).exec(next);
+                },
+            ],
+            async (err, users) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "USER_INDEX",
+                        `Indexing users failed. "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "USER_INDEX",
+                        `Indexing users successful.`
+                    );
+                    let filteredUsers = [];
+                    users.forEach((user) => {
+                        filteredUsers.push({
+                            _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 },
+                        });
+                    });
+                    return cb({ status: "success", data: filteredUsers });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Logs user in
+     *
+     * @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 {Function} cb - gets called with the result
+     */
+    login: async (session, identifier, password, cb) => {
+        identifier = identifier.toLowerCase();
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const sessionSchema = await cache.runJob("GET_SCHEMA", {
+            schemaName: "session",
+        });
+
+        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."
+                        );
+                    bcrypt.compare(
+                        sha256(password),
+                        user.services.password.password,
+                        (err, match) => {
+                            if (err) return next(err);
+                            if (!match) return next("Incorrect password");
+                            next(null, user);
+                        }
+                    );
+                },
+
+                (user, next) => {
+                    utils.runJob("GUID", {}).then((sessionId) => {
+                        next(null, user, sessionId);
+                    });
+                },
+
+                (user, sessionId, next) => {
+                    cache
+                        .runJob("HSET", {
+                            table: "sessions",
+                            key: sessionId,
+                            value: sessionSchema(sessionId, user._id),
+                        })
+                        .then(() => next(null, sessionId))
+                        .catch(next);
+                },
+            ],
+            async (err, sessionId) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "USER_PASSWORD_LOGIN",
+                        `Login failed with password for user "${identifier}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+                console.log(
+                    "SUCCESS",
+                    "USER_PASSWORD_LOGIN",
+                    `Login successful with password for user "${identifier}"`
+                );
+                cb({
+                    status: "success",
+                    message: "Login successful",
+                    user: {},
+                    SID: sessionId,
+                });
+            }
+        );
+    },
+
+    /**
+     * 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 {Function} cb - gets called with the result
+     */
+    register: async function(
+        session,
+        username,
+        email,
+        password,
+        recaptcha,
+        cb
+    ) {
+        email = email.toLowerCase();
+        let verificationToken = await utils.runJob("GENERATE_RANDOM_STRING", {
+            length: 64,
+        });
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const verifyEmailSchema = await mail.runJob("GET_SCHEMA", {
+            schemaName: "verifyEmail",
+        });
+
+        async.waterfall(
+            [
+                // verify the request with google recaptcha
+                (next) => {
+                    if (!db.passwordValid(password))
+                        return next(
+                            "Invalid password. Check if it meets all the requirements."
+                        );
+                    return next();
+                },
+
+                (next) => {
+                    request(
+                        {
+                            url:
+                                "https://www.google.com/recaptcha/api/siteverify",
+                            method: "POST",
+                            form: {
+                                secret: config.get("apis").recaptcha.secret,
+                                response: recaptcha,
+                            },
+                        },
+                        next
+                    );
+                },
+
+                // 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."
+                        );
+                    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."
+                        );
+                    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.");
+                    bcrypt.genSalt(10, next);
+                },
+
+                // hash the password
+                (salt, next) => {
+                    bcrypt.hash(sha256(password), salt, next);
+                },
+
+                (hash, next) => {
+                    utils
+                        .runJob("GENERATE_RANDOM_STRING", { length: 12 })
+                        .then((_id) => {
+                            next(null, hash, _id);
+                        });
+                },
+
+                // create the user object
+                (hash, _id, next) => {
+                    next(null, {
+                        _id,
+                        username,
+                        email: {
+                            address: email,
+                            verificationToken,
+                        },
+                        services: {
+                            password: {
+                                password: hash,
+                            },
+                        },
+                    });
+                },
+
+                // generate the url for gravatar avatar
+                (user, next) => {
+                    utils
+                        .runJob("CREATE_GRAVATAR", {
+                            email: user.email.address,
+                        })
+                        .then((url) => {
+                            user.avatar = url;
+                            next(null, user);
+                        });
+                },
+
+                // save the new user to the database
+                (user, next) => {
+                    userModel.create(user, next);
+                },
+
+                // respond with the new user
+                (newUser, next) => {
+                    verifyEmailSchema(
+                        email,
+                        username,
+                        verificationToken,
+                        () => {
+                            next(null, newUser);
+                        }
+                    );
+                },
+            ],
+            async (err, user) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "USER_PASSWORD_REGISTER",
+                        `Register failed with password for user "${username}"."${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    module.exports.login(session, email, password, (result) => {
+                        let obj = {
+                            status: "success",
+                            message: "Successfully registered.",
+                        };
+                        if (result.status === "success") {
+                            obj.SID = result.SID;
+                        }
+                        activities.runJob("ADD_ACTIVITY", {
+                            userId: user._id,
+                            activityType: "created_account",
+                        });
+                        console.log(
+                            "SUCCESS",
+                            "USER_PASSWORD_REGISTER",
+                            `Register successful with password for user "${username}".`
+                        );
+                        return cb(obj);
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Logs out a user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    logout: (session, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then((session) => next(null, session))
+                        .catch(next);
+                },
+
+                (session, next) => {
+                    if (!session) return next("Session not found");
+                    next(null, session);
+                },
+
+                (session, next) => {
+                    cache
+                        .runJob("HDEL", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then(() => next())
+                        .catch(next);
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "USER_LOGOUT",
+                        `Logout failed. "${err}" `
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log("SUCCESS", "USER_LOGOUT", `Logout successful.`);
+                    cb({
+                        status: "success",
+                        message: "Successfully logged out.",
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Removes all sessions for a user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} userId - the id of the user we are trying to delete the sessions of
+     * @param {Function} cb - gets called with the result
+     */
+    removeSessions: hooks.loginRequired(async (session, userId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        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."
+                            );
+                        else return next();
+                    });
+                },
+
+                (next) => {
+                    cache
+                        .runJob("HGETALL", { table: "sessions" })
+                        .then((sessions) => next(null, sessions))
+                        .catch(next);
+                },
+
+                (sessions, next) => {
+                    if (!sessions)
+                        return next(
+                            "There are no sessions for this user to remove."
+                        );
+                    else {
+                        let keys = Object.keys(sessions);
+                        next(null, keys, sessions);
+                    }
+                },
+
+                (keys, sessions, next) => {
+                    cache.runJob("PUB", {
+                        channel: "user.removeSessions",
+                        value: userId,
+                    });
+                    async.each(
+                        keys,
+                        (sessionId, callback) => {
+                            let session = sessions[sessionId];
+                            if (session.userId === userId) {
+                                cache
+                                    .runJob("HDEL", {
+                                        channel: "sessions",
+                                        key: sessionId,
+                                    })
+                                    .then(() => callback(null))
+                                    .catch(next);
+                            }
+                        },
+                        (err) => {
+                            next(err);
+                        }
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REMOVE_SESSIONS_FOR_USER",
+                        `Couldn't remove all sessions for user "${userId}". "${err}"`
+                    );
+                    return cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "REMOVE_SESSIONS_FOR_USER",
+                        `Removed all sessions for user "${userId}".`
+                    );
+                    return cb({
+                        status: "success",
+                        message: "Successfully removed all sessions.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * 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 {Function} cb - gets called with the result
+     */
+    findByUsername: async (session, username, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne(
+                        { username: new RegExp(`^${username}$`, "i") },
+                        next
+                    );
+                },
+
+                (account, next) => {
+                    if (!account) return next("User not found.");
+                    next(null, account);
+                },
+            ],
+            async (err, account) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "FIND_BY_USERNAME",
+                        `User not found for username "${username}". "${err}"`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "FIND_BY_USERNAME",
+                        `User found for username "${username}".`
+                    );
+                    return cb({
+                        status: "success",
+                        data: {
+                            _id: account._id,
+                            name: account.name,
+                            username: account.username,
+                            location: account.location,
+                            bio: account.bio,
+                            role: account.role,
+                            avatar: account.avatar,
+                            createdAt: account.createdAt,
+                        },
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Gets a username from an userId
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} userId - the userId of the person we are trying to get the username from
+     * @param {Function} cb - gets called with the result
+     */
+    getUsernameFromId: async (session, userId, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        userModel
+            .findById(userId)
+            .then((user) => {
+                if (user) {
+                    console.log(
+                        "SUCCESS",
+                        "GET_USERNAME_FROM_ID",
+                        `Found username for userId "${userId}".`
+                    );
+                    return cb({
+                        status: "success",
+                        data: user.username,
+                    });
+                } else {
+                    console.log(
+                        "ERROR",
+                        "GET_USERNAME_FROM_ID",
+                        `Getting the username from userId "${userId}" failed. User not found.`
+                    );
+                    cb({
+                        status: "failure",
+                        message: "Couldn't find the user.",
+                    });
+                }
+            })
+            .catch(async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "GET_USERNAME_FROM_ID",
+                        `Getting the username from userId "${userId}" failed. "${err}"`
+                    );
+                    cb({ status: "failure", message: err });
+                }
+            });
+    },
+
+    //TODO Fix security issues
+    /**
+     * Gets user info from session
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    findBySession: async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    cache
+                        .runJob("HGET", {
+                            table: "sessions",
+                            key: session.sessionId,
+                        })
+                        .then((session) => next(null, session))
+                        .catch(next);
+                },
+
+                (session, next) => {
+                    if (!session) return next("Session not found.");
+                    next(null, session);
+                },
+
+                (session, next) => {
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+
+                (user, next) => {
+                    if (!user) return next("User not found.");
+                    next(null, user);
+                },
+            ],
+            async (err, user) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "FIND_BY_SESSION",
+                        `User not found. "${err}"`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    let data = {
+                        email: {
+                            address: user.email.address,
+                        },
+                        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;
+                    if (user.services.github && user.services.github.id)
+                        data.github = true;
+                    console.log(
+                        "SUCCESS",
+                        "FIND_BY_SESSION",
+                        `User found. "${user.username}".`
+                    );
+                    return cb({
+                        status: "success",
+                        data,
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * 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 {Function} cb - gets called with the result
+     */
+    updateUsername: hooks.loginRequired(
+        async (session, updatingUserId, newUsername, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        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."
+                            );
+                        next(null);
+                    },
+
+                    (next) => {
+                        userModel.findOne(
+                            { username: new RegExp(`^${newUsername}$`, "i") },
+                            next
+                        );
+                    },
+
+                    (user, next) => {
+                        if (!user) return next();
+                        if (user._id === updatingUserId) return next();
+                        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 utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_USERNAME",
+                            `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        cache.runJob("PUB", {
+                            channel: "user.updateUsername",
+                            value: {
+                                username: newUsername,
+                                _id: updatingUserId,
+                            },
+                        });
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_USERNAME",
+                            `Updated username for user "${updatingUserId}" to username "${newUsername}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Username updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * 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 {Function} cb - gets called with the result
+     */
+    updateEmail: hooks.loginRequired(
+        async (session, updatingUserId, newEmail, cb) => {
+            newEmail = newEmail.toLowerCase();
+            let verificationToken = await utils.runJob(
+                "GENERATE_RANDOM_STRING",
+                { length: 64 }
+            );
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            const verifyEmailSchema = await mail.runJob("GET_SCHEMA", {
+                schemaName: "verifyEmail",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        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."
+                            );
+                        next();
+                    },
+
+                    (next) => {
+                        userModel.findOne({ "email.address": newEmail }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next();
+                        if (user._id === updatingUserId) return next();
+                        next("That email is already in use.");
+                    },
+
+                    // regenerate the url for gravatar avatar
+                    (next) => {
+                        utils
+                            .runJob("CREATE_GRAVATAR", { email: newEmail })
+                            .then((url) => next(null, url));
+                    },
+
+                    (avatar, next) => {
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            {
+                                $set: {
+                                    avatar: avatar,
+                                    "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,
+                            () => {
+                                next();
+                            }
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_EMAIL",
+                            `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_EMAIL",
+                            `Updated email for user "${updatingUserId}" to email "${newEmail}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Email updated successfully.",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's name
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newBio - the new name
+     * @param {Function} cb - gets called with the result
+     */
+    updateName: hooks.loginRequired(
+        async (session, updatingUserId, newName, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { name: newName } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_NAME",
+                            `Couldn't update name for user "${updatingUserId}" to name "${newName}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_NAME",
+                            `Updated name for user "${updatingUserId}" to name "${newName}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Name updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's location
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newLocation - the new location
+     * @param {Function} cb - gets called with the result
+     */
+    updateLocation: hooks.loginRequired(
+        async (session, updatingUserId, newLocation, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { location: newLocation } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_LOCATION",
+                            `Couldn't update location for user "${updatingUserId}" to location "${newLocation}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_LOCATION",
+                            `Updated location for user "${updatingUserId}" to location "${newLocation}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Location updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's bio
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newBio - the new bio
+     * @param {Function} cb - gets called with the result
+     */
+    updateBio: hooks.loginRequired(
+        async (session, updatingUserId, newBio, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { bio: newBio } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_BIO",
+                            `Couldn't update bio for user "${updatingUserId}" to bio "${newBio}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_BIO",
+                            `Updated bio for user "${updatingUserId}" to bio "${newBio}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Bio updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates the type of a user's avatar
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} updatingUserId - the updating user's id
+     * @param {String} newType - the new type
+     * @param {Function} cb - gets called with the result
+     */
+    updateAvatarType: hooks.loginRequired(
+        async (session, updatingUserId, newType, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (updatingUserId === session.userId)
+                            return next(null, true);
+                        userModel.findOne({ _id: session.userId }, next);
+                    },
+
+                    (user, next) => {
+                        if (user !== true && (!user || user.role !== "admin"))
+                            return next("Invalid permissions.");
+                        userModel.findOne({ _id: updatingUserId }, next);
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("User not found.");
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { "avatar.type": newType } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_AVATAR_TYPE",
+                            `Couldn't update avatar type for user "${updatingUserId}" to type "${newType}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_AVATAR_TYPE",
+                            `Updated avatar type for user "${updatingUserId}" to type "${newType}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Avatar type updated successfully",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * 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 {Function} cb - gets called with the result
+     */
+    updateRole: hooks.adminRequired(
+        async (session, updatingUserId, newRole, cb) => {
+            newRole = newRole.toLowerCase();
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        userModel.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) => {
+                        userModel.updateOne(
+                            { _id: updatingUserId },
+                            { $set: { role: newRole } },
+                            { runValidators: true },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "UPDATE_ROLE",
+                            `User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "UPDATE_ROLE",
+                            `User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Role successfully updated.",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Updates a user's password
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} newPassword - the new password
+     * @param {Function} cb - gets called with the result
+     */
+    updatePassword: hooks.loginRequired(async (session, newPassword, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        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."
+                        );
+                    next();
+                },
+
+                (next) => {
+                    if (!db.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(
+                        { _id: session.userId },
+                        {
+                            $set: {
+                                "services.password.password": hashedPassword,
+                            },
+                        },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "UPDATE_PASSWORD",
+                        `Failed updating user password of user '${session.userId}'. '${err}'.`
+                    );
+                    return cb({ status: "failure", message: err });
+                }
+
+                console.log(
+                    "SUCCESS",
+                    "UPDATE_PASSWORD",
+                    `User '${session.userId}' updated their password.`
+                );
+                cb({
+                    status: "success",
+                    message: "Password successfully updated.",
+                });
+            }
+        );
+    }),
+
+    /**
+     * Requests a password for a session
+     *
+     * @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 {Function} cb - gets called with the result
+     */
+    requestPassword: hooks.loginRequired(async (session, cb) => {
+        let code = await utils.runJob("GENERATE_RANDOM_STRING", { length: 8 });
+        const passwordRequestSchema = await mail.runJob("GET_SCHEMA", {
+            schemaName: "passwordRequest",
+        });
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        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.");
+                    next(null, user);
+                },
+
+                (user, next) => {
+                    let expires = new Date();
+                    expires.setDate(expires.getDate() + 1);
+                    userModel.findOneAndUpdate(
+                        { "email.address": user.email.address },
+                        {
+                            $set: {
+                                "services.password": {
+                                    set: { code: code, expires },
+                                },
+                            },
+                        },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (user, next) => {
+                    passwordRequestSchema(
+                        user.email.address,
+                        user.username,
+                        code,
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REQUEST_PASSWORD",
+                        `UserId '${session.userId}' failed to request password. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "REQUEST_PASSWORD",
+                        `UserId '${session.userId}' successfully requested a password.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully requested password.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Verifies a password code
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} code - the password code
+     * @param {Function} cb - gets called with the result
+     */
+    verifyPasswordCode: hooks.loginRequired(async (session, code, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!code || typeof code !== "string")
+                        return next("Invalid code1.");
+                    userModel.findOne(
+                        {
+                            "services.password.set.code": code,
+                            _id: session.userId,
+                        },
+                        next
+                    );
+                },
+
+                (user, next) => {
+                    if (!user) return next("Invalid code2.");
+                    if (user.services.password.set.expires < new Date())
+                        return next("That code has expired.");
+                    next(null);
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "VERIFY_PASSWORD_CODE",
+                        `Code '${code}' failed to verify. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "VERIFY_PASSWORD_CODE",
+                        `Code '${code}' successfully verified.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully verified password code.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Adds a password to a user with a code
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} code - the password code
+     * @param {String} newPassword - the new password code
+     * @param {Function} cb - gets called with the result
+     */
+    changePasswordWithCode: hooks.loginRequired(
+        async (session, code, newPassword, cb) => {
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!code || typeof code !== "string")
+                            return next("Invalid code1.");
+                        userModel.findOne(
+                            { "services.password.set.code": code },
+                            next
+                        );
+                    },
+
+                    (user, next) => {
+                        if (!user) return next("Invalid code2.");
+                        if (!user.services.password.set.expires > new Date())
+                            return next("That code has expired.");
+                        next();
+                    },
+
+                    (next) => {
+                        if (!db.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 utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "ADD_PASSWORD_WITH_CODE",
+                            `Code '${code}' failed to add password. '${err}'`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "ADD_PASSWORD_WITH_CODE",
+                            `Code '${code}' successfully added password.`
+                        );
+                        cache.runJob("PUB", {
+                            channel: "user.linkPassword",
+                            value: session.userId,
+                        });
+                        cb({
+                            status: "success",
+                            message: "Successfully added password.",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    /**
+     * Unlinks password from user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    unlinkPassword: hooks.loginRequired(async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        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."
+                        );
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $unset: { "services.password": "" } },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "UNLINK_PASSWORD",
+                        `Unlinking password failed for userId '${session.userId}'. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "UNLINK_PASSWORD",
+                        `Unlinking password successful for userId '${session.userId}'.`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "user.unlinkPassword",
+                        value: session.userId,
+                    });
+                    cb({
+                        status: "success",
+                        message: "Successfully unlinked password.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Unlinks GitHub from user
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {Function} cb - gets called with the result
+     */
+    unlinkGitHub: hooks.loginRequired(async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        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."
+                        );
+                    userModel.updateOne(
+                        { _id: session.userId },
+                        { $unset: { "services.github": "" } },
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "UNLINK_GITHUB",
+                        `Unlinking GitHub failed for userId '${session.userId}'. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "UNLINK_GITHUB",
+                        `Unlinking GitHub successful for userId '${session.userId}'.`
+                    );
+                    cache.runJob("PUB", {
+                        channel: "user.unlinkGithub",
+                        value: session.userId,
+                    });
+                    cb({
+                        status: "success",
+                        message: "Successfully unlinked GitHub.",
+                    });
+                }
+            }
+        );
+    }),
+
+    /**
+     * Requests a password reset for an email
+     *
+     * @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 {Function} cb - gets called with the result
+     */
+    requestPasswordReset: async (session, email, cb) => {
+        let code = await utils.runJob("GENERATE_RANDOM_STRING", { length: 8 });
+        console.log(111, code);
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        const resetPasswordRequestSchema = await mail.runJob("GET_SCHEMA", {
+            schemaName: "resetPasswordRequest",
+        });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!email || typeof email !== "string")
+                        return next("Invalid email.");
+                    email = email.toLowerCase();
+                    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."
+                        );
+                    next(null, user);
+                },
+
+                (user, next) => {
+                    let expires = new Date();
+                    expires.setDate(expires.getDate() + 1);
+                    userModel.findOneAndUpdate(
+                        { "email.address": email },
+                        {
+                            $set: {
+                                "services.password.reset": {
+                                    code: code,
+                                    expires,
+                                },
+                            },
+                        },
+                        { runValidators: true },
+                        next
+                    );
+                },
+
+                (user, next) => {
+                    resetPasswordRequestSchema(
+                        user.email.address,
+                        user.username,
+                        code,
+                        next
+                    );
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "REQUEST_PASSWORD_RESET",
+                        `Email '${email}' failed to request password reset. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "REQUEST_PASSWORD_RESET",
+                        `Email '${email}' successfully requested a password reset.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully requested password reset.",
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Verifies a reset code
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @param {String} code - the password reset code
+     * @param {Function} cb - gets called with the result
+     */
+    verifyPasswordResetCode: async (session, code, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!code || typeof code !== "string")
+                        return next("Invalid code.");
+                    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.");
+                    next(null);
+                },
+            ],
+            async (err) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "VERIFY_PASSWORD_RESET_CODE",
+                        `Code '${code}' failed to verify. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "VERIFY_PASSWORD_RESET_CODE",
+                        `Code '${code}' successfully verified.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully verified password 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 {Function} cb - gets called with the result
+     */
+    changePasswordWithResetCode: async (session, code, newPassword, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    if (!code || typeof code !== "string")
+                        return next("Invalid code.");
+                    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.");
+                    next();
+                },
+
+                (next) => {
+                    if (!db.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 utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "CHANGE_PASSWORD_WITH_RESET_CODE",
+                        `Code '${code}' failed to change password. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "CHANGE_PASSWORD_WITH_RESET_CODE",
+                        `Code '${code}' successfully changed password.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully changed password.",
+                    });
+                }
+            }
+        );
+    },
+
+    /**
+     * Bans a user by userId
+     *
+     * @param {Object} session - the session object automatically added by socket.io
+     * @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: hooks.adminRequired(
+        (session, userId, reason, expiresAt, cb) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!userId)
+                            return next("You must provide a userId to ban.");
+                        else if (!reason)
+                            return next(
+                                "You must provide a reason for the ban."
+                            );
+                        else return next();
+                    },
+
+                    (next) => {
+                        if (!expiresAt || typeof expiresAt !== "string")
+                            return next("Invalid expire date.");
+                        let 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.");
+                        }
+
+                        next();
+                    },
+
+                    (next) => {
+                        punishments
+                            .runJob("ADD_PUNISHMENT", {
+                                type: "banUserId",
+                                value: userId,
+                                reason,
+                                expiresAt,
+                                punishedBy,
+                            })
+                            .then((punishment) => next(null, punishment))
+                            .catch(next);
+                    },
+
+                    (punishment, next) => {
+                        cache.runJob("PUB", {
+                            channel: "user.ban",
+                            value: { userId, punishment },
+                        });
+                        next();
+                    },
+                ],
+                async (err) => {
+                    if (err && err !== true) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        console.log(
+                            "ERROR",
+                            "BAN_USER_BY_ID",
+                            `User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`
+                        );
+                        cb({ status: "failure", message: err });
+                    } else {
+                        console.log(
+                            "SUCCESS",
+                            "BAN_USER_BY_ID",
+                            `User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`
+                        );
+                        cb({
+                            status: "success",
+                            message: "Successfully banned user.",
+                        });
+                    }
+                }
+            );
+        }
+    ),
+
+    getFavoriteStations: hooks.loginRequired(async (session, cb) => {
+        const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+        async.waterfall(
+            [
+                (next) => {
+                    userModel.findOne({ _id: session.userId }, next);
+                },
+
+                (user, next) => {
+                    if (!user) return next("User not found.");
+                    next(null, user);
+                },
+            ],
+            async (err, user) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "GET_FAVORITE_STATIONS",
+                        `User ${session.userId} failed to get favorite stations. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "GET_FAVORITE_STATIONS",
+                        `User ${session.userId} got favorite stations.`
+                    );
+                    cb({
+                        status: "success",
+                        favoriteStations: user.favoriteStations,
+                    });
+                }
+            }
+        );
+    }),
 };

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

@@ -0,0 +1,98 @@
+"use strict";
+
+const async = require("async");
+
+const hooks = require("./hooks");
+
+const utils = require("../utils");
+
+module.exports = {
+    getModules: hooks.adminRequired((session, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    next(null, utils.moduleManager.modules);
+                },
+
+                (modules, next) => {
+                    // console.log(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.length(),
+                                jobsInProgress: module.jobQueue.running(),
+                                concurrency: module.jobQueue.concurrency,
+                            };
+                        })
+                    );
+                },
+            ],
+            async (err, modules) => {
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "GET_MODULES",
+                        `User ${session.userId} failed to get modules. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "GET_MODULES",
+                        `User ${session.userId} has successfully got the modules info.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully got modules.",
+                        modules,
+                    });
+                }
+            }
+        );
+    }),
+
+    getModule: hooks.adminRequired((session, moduleName, cb) => {
+        async.waterfall(
+            [
+                (next) => {
+                    next(null, utils.moduleManager.modules[moduleName]);
+                },
+            ],
+            async (err, module) => {
+                const jobsInQueue = module.jobQueue._tasks.heap.map((task) => {
+                    return task.data;
+                });
+
+                // console.log(module.runningJobs);
+                if (err && err !== true) {
+                    err = await utils.runJob("GET_ERROR", { error: err });
+                    console.log(
+                        "ERROR",
+                        "GET_MODULE",
+                        `User ${session.userId} failed to get module. '${err}'`
+                    );
+                    cb({ status: "failure", message: err });
+                } else {
+                    console.log(
+                        "SUCCESS",
+                        "GET_MODULE",
+                        `User ${session.userId} has successfully got the module info.`
+                    );
+                    cb({
+                        status: "success",
+                        message: "Successfully got module info.",
+                        runningJobs: module.runningJobs,
+                        jobStatistics: module.jobStatistics,
+                        jobsInQueue,
+                    });
+                }
+            }
+        );
+    }),
+};

+ 80 - 0
backend/logic/activities.js

@@ -0,0 +1,80 @@
+const CoreClass = require("../core.js");
+
+const async = require("async");
+const mongoose = require("mongoose");
+
+class ActivitiesModule extends CoreClass {
+    constructor() {
+        super("activities");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.db = this.moduleManager.modules["db"];
+            this.io = this.moduleManager.modules["io"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            resolve();
+        });
+    }
+
+    // TODO: Migrate
+    ADD_ACTIVITY(payload) {
+        //userId, activityType, payload
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.db
+                            .runJob("GET_MODEL", { modelName: "activity" })
+                            .then((res) => {
+                                next(null, res);
+                            })
+                            .catch(next);
+                    },
+                    (activityModel, next) => {
+                        const activity = new activityModel({
+                            userId: payload.userId,
+                            activityType: payload.activityType,
+                            payload: payload.payload,
+                        });
+
+                        activity.save((err, activity) => {
+                            if (err) return next(err);
+                            next(null, activity);
+                        });
+                    },
+
+                    (activity, next) => {
+                        this.utils
+                            .runJob("SOCKETS_FROM_USER", {
+                                userId: activity.userId,
+                            })
+                            .then((response) => {
+                                response.sockets.forEach((socket) => {
+                                    socket.emit(
+                                        "event:activity.create",
+                                        activity
+                                    );
+                                });
+                                next();
+                            })
+                            .catch(next);
+                    },
+                ],
+                async (err, activity) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        resolve({ activity });
+                    }
+                }
+            );
+        });
+    }
+}
+
+module.exports = new ActivitiesModule();

+ 44 - 39
backend/logic/api.js

@@ -1,40 +1,45 @@
-const coreClass = require("../core");
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["app", "db", "cache"];
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.app = this.moduleManager.modules["app"];
-
-			this.app.app.get('/', (req, res) => {
-				res.json({
-					status: 'success',
-					message: 'Coming Soon'
-				});
-			});
-
-			const actions = require("../logic/actions");
-	
-			Object.keys(actions).forEach((namespace) => {
-				Object.keys(actions[namespace]).forEach((action) => {
-					let name = `/${namespace}/${action}`;
-	
-					this.app.app.get(name, (req, res) => {
-						actions[namespace][action](null, (result) => {
-							if (typeof cb === 'function') return res.json(result);
-						});
-					});
-				})
-			});
-
-			resolve();
-		});
-	}
+const CoreClass = require("../core.js");
+
+class APIModule extends CoreClass {
+    constructor() {
+        super("api");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            const app = this.moduleManager.modules["app"];
+
+            const actions = require("./actions");
+
+            app.runJob("GET_APP", {})
+                .then((response) => {
+                    response.app.get("/", (req, res) => {
+                        res.json({
+                            status: "success",
+                            message: "Coming Soon",
+                        });
+                    });
+
+                    // Object.keys(actions).forEach(namespace => {
+                    //     Object.keys(actions[namespace]).forEach(action => {
+                    //         let name = `/${namespace}/${action}`;
+
+                    //         response.app.get(name, (req, res) => {
+                    //             actions[namespace][action](null, result => {
+                    //                 if (typeof cb === "function")
+                    //                     return res.json(result);
+                    //             });
+                    //         });
+                    //     });
+                    // });
+
+                    resolve();
+                })
+                .catch((err) => {
+                    reject(err);
+                });
+        });
+    }
 }
+
+module.exports = new APIModule();

+ 539 - 246
backend/logic/app.js

@@ -1,247 +1,540 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const express = require('express');
-const bodyParser = require('body-parser');
-const cookieParser = require('cookie-parser');
-const cors = require('cors');
-const config = require('config');
-const async = require('async');
-const request = require('request');
-const OAuth2 = require('oauth').OAuth2;
-
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			const 	logger 	= this.logger,
-					mail	= this.moduleManager.modules["mail"],
-					cache	= this.moduleManager.modules["cache"],
-					db		= this.moduleManager.modules["db"];
-			
-			this.utils = this.moduleManager.modules["utils"];
-
-			let app = this.app = express();
-			const SIDname = config.get("cookie.SIDname");
-			this.server = app.listen(config.get('serverPort'));
-
-			app.use(cookieParser());
-
-			app.use(bodyParser.json());
-			app.use(bodyParser.urlencoded({ extended: true }));
-
-			let corsOptions = Object.assign({}, config.get('cors'));
-
-			app.use(cors(corsOptions));
-			app.options('*', cors(corsOptions));
-
-			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
-			);
-
-			let redirect_uri = config.get('serverDomain') + '/auth/github/authorize/callback';
-
-			app.get('/auth/github/authorize', async (req, res) => {
-				try { await this._validateHook(); } catch { return; }
-				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/link', async (req, res) => {
-				try { await this._validateHook(); } catch { return; }
-				let 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('&');
-				res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-			});
-
-			function redirectOnErr (res, err){
-				return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
-			}
-
-			app.get('/auth/github/authorize/callback', async (req, res) => {
-				try { await this._validateHook(); } catch { return; }
-				let code = req.query.code;
-				let access_token;
-				let body;
-				let address;
-				const state = req.query.state;
-
-				async.waterfall([
-					(next) => {
-						if (req.query.error) return next(req.query.error_description);
-						next();
-					},
-
-					(next) => {
-						oauth2.getOAuthAccessToken(code, {redirect_uri}, next);
-					},
-
-					(_access_token, refresh_token, results, next) => {
-						if (results.error) return next(results.error_description);
-						access_token = _access_token;
-						request.get({
-							url: `https://api.github.com/user?access_token=${access_token}`,
-							headers: {'User-Agent': 'request'}
-						}, next);
-					},
-
-					(httpResponse, _body, next) => {
-						body = _body = JSON.parse(_body);
-						if (httpResponse.statusCode !== 200) return next(body.message);
-						if (state) {
-							return async.waterfall([
-								(next) => {
-									cache.hget('sessions', state, next);
-								},
-
-								(session, next) => {
-									if (!session) return next('Invalid session.');
-									db.models.user.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.');
-									db.models.user.updateOne({_id: user._id}, {$set: {"services.github": {id: body.id, access_token}}}, {runValidators: true}, (err) => {
-										if (err) return next(err);
-										next(null, user, body);
-									});
-								},
-
-								(user) => {
-									cache.pub('user.linkGitHub', user._id);
-									res.redirect(`${config.get('domain')}/settings`);
-								}
-							], next);
-						}
-						if (!body.id) return next("Something went wrong, no id.");
-						db.models.user.findOne({'services.github.id': body.id}, (err, user) => {
-							next(err, user, body);
-						});
-					},
-
-					(user, body, next) => {
-						if (user) {
-							user.services.github.access_token = access_token;
-							return user.save(() => {
-								next(true, user._id);
-							});
-						}
-						db.models.user.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.');
-						request.get({
-							url: `https://api.github.com/user/emails?access_token=${access_token}`,
-							headers: {'User-Agent': 'request'}
-						}, next);
-					},
-
-					(httpResponse, body2, next) => {
-						body2 = JSON.parse(body2);
-						if (!Array.isArray(body2)) return next(body2.message);
-						body2.forEach(email => {
-							if (email.primary) address = email.email.toLowerCase();
-						});
-						db.models.user.findOne({'email.address': address}, next);
-					},
-
-					async (user, next) => {
-						const verificationToken = await this.utils.generateRandomString(64);
-						if (user) return next('An account with that email address already exists.');
-						db.models.user.create({
-							_id: await this.utils.generateRandomString(12),//TODO Check if exists
-							username: body.login,
-							email: {
-								address,
-								verificationToken: verificationToken
-							},
-							services: {
-								github: {id: body.id, access_token}
-							}
-						}, next);
-					},
-
-					(user, next) => {
-						mail.schemas.verifyEmail(address, body.login, user.email.verificationToken);
-						next(null, user._id);
-					}
-				], async (err, userId) => {
-					if (err && err !== true) {
-						err = await this.utils.getError(err);
-						logger.error('AUTH_GITHUB_AUTHORIZE_CALLBACK', `Failed to authorize with GitHub. "${err}"`);
-						return redirectOnErr(res, err);
-					}
-
-					const sessionId = await this.utils.guid();
-					cache.hset('sessions', sessionId, cache.schemas.session(sessionId, userId), 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(SIDname, sessionId, {
-							expires: date,
-							secure: config.get("cookie.secure"),
-							path: "/",
-							domain: config.get("cookie.domain")
-						});
-						logger.success('AUTH_GITHUB_AUTHORIZE_CALLBACK', `User "${userId}" successfully authorized with GitHub.`);
-						res.redirect(`${config.get('domain')}/`);
-					});
-				});
-			});
-
-			app.get('/auth/verify_email', async (req, res) => {
-				try { await this._validateHook(); } catch { return; }
-
-				let code = req.query.code;
-
-				async.waterfall([
-					(next) => {
-						if (!code) return next('Invalid code.');
-						next();
-					},
-
-					(next) => {
-						db.models.user.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.');
-						db.models.user.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;
-						logger.error("VERIFY_EMAIL", `Verifying email failed. "${error}"`);
-						return res.json({ status: 'failure', message: error});
-					}
-					logger.success("VERIFY_EMAIL", `Successfully verified email.`);
-					res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
-				});
-			});
-
-			resolve();
-		});
-	}
+const CoreClass = require("../core.js");
+
+const express = require("express");
+const bodyParser = require("body-parser");
+const cookieParser = require("cookie-parser");
+const cors = require("cors");
+const config = require("config");
+const async = require("async");
+const request = require("request");
+const OAuth2 = require("oauth").OAuth2;
+
+class AppModule extends CoreClass {
+    constructor() {
+        super("app");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            const mail = this.moduleManager.modules["mail"],
+                cache = this.moduleManager.modules["cache"],
+                db = this.moduleManager.modules["db"],
+                activities = this.moduleManager.modules["activities"];
+
+            this.utils = this.moduleManager.modules["utils"];
+
+            let app = (this.app = express());
+            const SIDname = config.get("cookie.SIDname");
+            this.server = app.listen(config.get("serverPort"));
+
+            app.use(cookieParser());
+
+            app.use(bodyParser.json());
+            app.use(bodyParser.urlencoded({ extended: true }));
+
+            const userModel = await db.runJob("GET_MODEL", {
+                modelName: "user",
+            });
+
+            let corsOptions = Object.assign({}, config.get("cors"));
+
+            app.use(cors(corsOptions));
+            app.options("*", cors(corsOptions));
+
+            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
+            );
+
+            let redirect_uri =
+                config.get("serverDomain") + "/auth/github/authorize/callback";
+
+            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."
+                    );
+                }
+
+                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/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."
+                    );
+                }
+                let 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("&");
+                res.redirect(
+                    `https://github.com/login/oauth/authorize?${params}`
+                );
+            });
+
+            function redirectOnErr(res, err) {
+                return res.redirect(
+                    `${config.get("domain")}/?err=${encodeURIComponent(err)}`
+                );
+            }
+
+            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."
+                    );
+                }
+
+                let code = req.query.code;
+                let access_token;
+                let body;
+                let address;
+
+                const state = req.query.state;
+
+                const verificationToken = await this.utils.runJob(
+                    "GENERATE_RANDOM_STRING",
+                    { length: 64 }
+                );
+
+                async.waterfall(
+                    [
+                        (next) => {
+                            if (req.query.error)
+                                return next(req.query.error_description);
+                            next();
+                        },
+
+                        (next) => {
+                            oauth2.getOAuthAccessToken(
+                                code,
+                                { redirect_uri },
+                                next
+                            );
+                        },
+
+                        (_access_token, refresh_token, results, next) => {
+                            if (results.error)
+                                return next(results.error_description);
+                            access_token = _access_token;
+                            request.get(
+                                {
+                                    url: `https://api.github.com/user`,
+                                    headers: {
+                                        "User-Agent": "request",
+                                        Authorization: `token ${access_token}`,
+                                    },
+                                },
+                                next
+                            );
+                        },
+
+                        (httpResponse, _body, next) => {
+                            body = _body = JSON.parse(_body);
+                            if (httpResponse.statusCode !== 200)
+                                return next(body.message);
+                            if (state) {
+                                return async.waterfall(
+                                    [
+                                        (next) => {
+                                            cache
+                                                .runJob("HGET", {
+                                                    table: "sessions",
+                                                    key: state,
+                                                })
+                                                .then((session) =>
+                                                    next(null, session)
+                                                )
+                                                .catch(next);
+                                        },
+
+                                        (session, next) => {
+                                            if (!session)
+                                                return next("Invalid session.");
+                                            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."
+                                                );
+                                            userModel.updateOne(
+                                                { _id: user._id },
+                                                {
+                                                    $set: {
+                                                        "services.github": {
+                                                            id: body.id,
+                                                            access_token,
+                                                        },
+                                                    },
+                                                },
+                                                { runValidators: true },
+                                                (err) => {
+                                                    if (err) return next(err);
+                                                    next(null, user, body);
+                                                }
+                                            );
+                                        },
+
+                                        (user) => {
+                                            cache.runJob("PUB", {
+                                                channel: "user.linkGithub",
+                                                value: user._id,
+                                            });
+                                            res.redirect(
+                                                `${config.get(
+                                                    "domain"
+                                                )}/settings`
+                                            );
+                                        },
+                                    ],
+                                    next
+                                );
+                            }
+
+                            if (!body.id)
+                                return next("Something went wrong, no id.");
+
+                            userModel.findOne(
+                                { "services.github.id": body.id },
+                                (err, user) => {
+                                    next(err, user, body);
+                                }
+                            );
+                        },
+
+                        (user, body, next) => {
+                            if (user) {
+                                user.services.github.access_token = access_token;
+                                return user.save(() => {
+                                    next(true, user._id);
+                                });
+                            }
+
+                            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.`
+                                );
+
+                            request.get(
+                                {
+                                    url: `https://api.github.com/user/emails`,
+                                    headers: {
+                                        "User-Agent": "request",
+                                        Authorization: `token ${access_token}`,
+                                    },
+                                },
+                                next
+                            );
+                        },
+
+                        (httpResponse, body2, next) => {
+                            body2 = JSON.parse(body2);
+                            if (!Array.isArray(body2))
+                                return next(body2.message);
+
+                            body2.forEach((email) => {
+                                if (email.primary)
+                                    address = email.email.toLowerCase();
+                            });
+
+                            userModel.findOne(
+                                { "email.address": address },
+                                next
+                            );
+                        },
+
+                        (user, next) => {
+                            this.utils
+                                .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.`)    
+                                else
+                                    return next(`An account with that email address already exists.`);
+                            }
+
+                            next(null, {
+                                _id, //TODO Check if exists
+                                username: body.login,
+                                name: body.name,
+                                location: body.location,
+                                bio: body.bio,
+                                email: {
+                                    address,
+                                    verificationToken,
+                                },
+                                services: {
+                                    github: { id: body.id, access_token },
+                                },
+                            });
+                        },
+
+                        // generate the url for gravatar avatar
+                        (user, next) => {
+                            this.utils
+                                .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);
+                        },
+
+                        // add the activity of account creation
+                        (user, next) => {
+                            activities.runJob("ADD_ACTIVITY", {
+                                userId: user._id,
+                                activityType: "created_account",
+                            });
+                            next(null, user);
+                        },
+
+                        (user, next) => {
+                            mail.runJob("GET_SCHEMA", {
+                                schemaName: "verifyEmail",
+                            }).then((verifyEmailSchema) => {
+                                verifyEmailSchema(
+                                    address,
+                                    body.login,
+                                    user.email.verificationToken
+                                );
+                                next(null, user._id);
+                            });
+                        },
+                    ],
+                    async (err, userId) => {
+                        if (err && err !== true) {
+                            err = await this.utils.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 this.utils.runJob("GUID", {});
+                        const sessionSchema = await cache.runJob("GET_SCHEMA", {
+                            schemaName: "session",
+                        });
+                        cache
+                            .runJob("HSET", {
+                                table: "sessions",
+                                key: sessionId,
+                                value: sessionSchema(sessionId, userId),
+                            })
+                            .then(() => {
+                                let date = new Date();
+                                date.setTime(
+                                    new Date().getTime() +
+                                        2 * 365 * 24 * 60 * 60 * 1000
+                                );
+
+                                res.cookie(SIDname, sessionId, {
+                                    expires: date,
+                                    secure: config.get("cookie.secure"),
+                                    path: "/",
+                                    domain: config.get("cookie.domain"),
+                                });
+
+                                this.log(
+                                    "INFO",
+                                    "AUTH_GITHUB_AUTHORIZE_CALLBACK",
+                                    `User "${userId}" successfully authorized with GitHub.`
+                                );
+
+                                res.redirect(`${config.get("domain")}/`);
+                            })
+                            .catch((err) => {
+                                return 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."
+                    );
+                }
+
+                let code = req.query.code;
+
+                async.waterfall(
+                    [
+                        (next) => {
+                            if (!code) return next("Invalid code.");
+                            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.");
+
+                            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: "failure",
+                                message: error,
+                            });
+                        }
+
+                        this.log(
+                            "INFO",
+                            "VERIFY_EMAIL",
+                            `Successfully verified email.`
+                        );
+
+                        res.redirect(
+                            `${config.get(
+                                "domain"
+                            )}?msg=Thank you for verifying your email`
+                        );
+                    }
+                );
+            });
+
+            resolve();
+        });
+    }
+
+    SERVER(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.server);
+        });
+    }
+
+    GET_APP(payload) {
+        return new Promise((resolve, reject) => {
+            resolve({ app: this.app });
+        });
+    }
+
+    EXAMPLE_JOB(payload) {
+        return new Promise((resolve, reject) => {
+            if (true) {
+                resolve({});
+            } else {
+                reject(new Error("Nothing changed."));
+            }
+        });
+    }
 }
+
+module.exports = new AppModule();

+ 261 - 206
backend/logic/cache/index.js

@@ -1,211 +1,266 @@
-'use strict';
+const CoreClass = require("../../core.js");
 
-const coreClass = require("../../core");
-
-const redis = require('redis');
-const config = require('config');
-const mongoose = require('mongoose');
+const redis = require("redis");
+const config = require("config");
+const mongoose = require("mongoose");
 
 // Lightweight / convenience wrapper around redis module for our needs
 
-const pubs = {}, subs = {};
-
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.schemas = {
-				session: require('./schemas/session'),
-				station: require('./schemas/station'),
-				playlist: require('./schemas/playlist'),
-				officialPlaylist: require('./schemas/officialPlaylist'),
-				song: require('./schemas/song'),
-				punishment: require('./schemas/punishment')
-			}
-
-			this.url = config.get("redis").url;
-			this.password = config.get("redis").password;
-
-			this.logger.info("REDIS", "Connecting...");
-
-			this.client = redis.createClient({
-				url: this.url,
-				password: this.password,
-				retry_strategy: (options) => {
-					if (this.state === "LOCKDOWN") return;
-					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
-
-					this.logger.info("CACHE_MODULE", `Attempting to reconnect.`);
-
-					if (options.attempt >= 10) {
-						this.logger.error("CACHE_MODULE", `Stopped trying to reconnect.`);
-
-						this.failed = true;
-						this._lockdown();
-
-						return undefined;
-					}
-
-					return 3000;
-				}
-			});
-
-			this.client.on('error', err => {
-				if (this.state === "INITIALIZING") reject(err);
-				if(this.state === "LOCKDOWN") return;
-
-				this.logger.error("CACHE_MODULE", `Error ${err.message}.`);
-			});
-
-			this.client.on("connect", () => {
-				this.logger.info("CACHE_MODULE", "Connected succesfully.");
-
-				if (this.state === "INITIALIZING") resolve();
-				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
-			});
-		});
-	}
-
-	/**
-	 * Gracefully closes all the Redis client connections
-	 */
-	async quit() {
-		try { await this._validateHook(); } catch { return; }
-
-		if (this.client.connected) {
-			this.client.quit();
-			Object.keys(pubs).forEach((channel) => pubs[channel].quit());
-			Object.keys(subs).forEach((channel) => subs[channel].client.quit());
-		}
-	}
-
-	/**
-	 * 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
-	 */
-	async hset(table, key, value, cb, stringifyJson = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-		// automatically stringify objects and arrays into JSON
-		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
-
-		this.client.hset(table, key, value, err => {
-			if (cb !== undefined) {
-				if (err) return cb(err);
-				cb(null, JSON.parse(value));
-			}
-		});
-	}
-
-	/**
-	 * 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
-	 */
-	async hget(table, key, cb, parseJson = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!key || !table) return typeof cb === 'function' ? cb(null, null) : null;
-		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-
-		this.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);
-		});
-	}
-
-	/**
-	 * 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
-	 */
-	async hdel(table, key, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!key || !table || typeof key !== "string") return cb(null, null);
-		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-
-		this.client.hdel(table, key, (err) => {
-			if (err) return cb(err);
-			else return cb(null);
-		});
-	}
-
-	/**
-	 * 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
-	 */
-	async hgetall(table, cb, parseJson = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!table) return cb(null, null);
-
-		this.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) {} });
-			if (parseJson && !obj) obj = [];
-			cb(null, obj);
-		});
-	}
-
-	/**
-	 * 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
-	 */
-	async pub(channel, value, stringifyJson = true) {
-		try { await this._validateHook(); } catch { return; }
-		/*if (pubs[channel] === undefined) {
-		 pubs[channel] = redis.createClient({ url: this.url });
-		 pubs[channel].on('error', (err) => console.error);
-		 }*/
-
-		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
-
-		//pubs[channel].publish(channel, value);
-		this.client.publish(channel, value);
-	}
-
-	/**
-	 * 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
-	 */
-	async sub(channel, cb, parseJson = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (subs[channel] === undefined) {
-			subs[channel] = { client: redis.createClient({ url: this.url, password: this.password }), cbs: [] };
-			subs[channel].client.on('message', (channel, message) => {
-				if (parseJson) try { message = JSON.parse(message); } catch (e) {}
-				subs[channel].cbs.forEach((cb) => cb(message));
-			});
-			subs[channel].client.subscribe(channel);
-		}
-
-		subs[channel].cbs.push(cb);
-	}
+const pubs = {},
+    subs = {};
+
+class CacheModule extends CoreClass {
+    constructor() {
+        super("cache");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.schemas = {
+                session: require("./schemas/session"),
+                station: require("./schemas/station"),
+                playlist: require("./schemas/playlist"),
+                officialPlaylist: require("./schemas/officialPlaylist"),
+                song: require("./schemas/song"),
+                punishment: require("./schemas/punishment"),
+            };
+
+            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.`);
+
+                    if (options.attempt >= 10) {
+                        this.log("ERROR", `Stopped trying to reconnect.`);
+
+                        this.setStatus("FAILED");
+
+                        // this.failed = true;
+                        // this._lockdown();
+
+                        return undefined;
+                    }
+
+                    return 3000;
+                },
+            });
+
+            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
+     */
+    QUIT(payload) {
+        return new Promise((resolve, reject) => {
+            if (this.client.connected) {
+                this.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
+     *
+     * @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
+     */
+    HSET(payload) {
+        //table, key, value, cb, stringifyJson = true
+        return new Promise((resolve, reject) => {
+            let key = payload.key;
+            let value = payload.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);
+
+            this.client.hset(payload.table, key, value, (err) => {
+                if (err) return reject(new Error(err));
+                else resolve(JSON.parse(value));
+            });
+        });
+    }
+
+    /**
+     * 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
+     */
+    HGET(payload) {
+        //table, key, cb, parseJson = true
+        return new Promise((resolve, reject) => {
+            // if (!key || !table)
+            // return typeof cb === "function" ? cb(null, null) : null;
+            let key = payload.key;
+
+            if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+            this.client.hget(payload.table, key, (err, value) => {
+                if (err) return reject(new Error(err));
+                try {
+                    value = JSON.parse(value);
+                } catch (e) {}
+                resolve(value);
+            });
+        });
+    }
+
+    /**
+     * 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
+     */
+    HDEL(payload) {
+        //table, key, cb
+        return new Promise((resolve, reject) => {
+            // if (!payload.key || !table || typeof key !== "string")
+            // return cb(null, null);
+
+            let key = payload.key;
+
+            if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
+
+            this.client.hdel(payload.table, key, (err) => {
+                if (err) return reject(new Error(err));
+                else return resolve();
+            });
+        });
+    }
+
+    /**
+     * 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
+     */
+    HGETALL(payload) {
+        //table, cb, parseJson = true
+        return new Promise((resolve, reject) => {
+            this.client.hgetall(payload.table, (err, obj) => {
+                if (err) return reject(new Error(err));
+                if (obj)
+                    Object.keys(obj).forEach((key) => {
+                        try {
+                            obj[key] = JSON.parse(obj[key]);
+                        } catch (e) {}
+                    });
+                else if (!obj) obj = [];
+                resolve(obj);
+            });
+        });
+    }
+
+    /**
+     * 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
+     */
+    PUB(payload) {
+        //channel, value, stringifyJson = true
+        return new Promise((resolve, reject) => {
+            /*if (pubs[channel] === undefined) {
+            pubs[channel] = redis.createClient({ url: this.url });
+            pubs[channel].on('error', (err) => console.error);
+            }*/
+
+            let value = payload.value;
+
+            if (["object", "array"].includes(typeof value))
+                value = JSON.stringify(value);
+
+            //pubs[channel].publish(channel, value);
+            this.client.publish(payload.channel, value);
+
+            resolve();
+        });
+    }
+
+    /**
+     * 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
+     */
+    SUB(payload) {
+        //channel, cb, parseJson = true
+        return new Promise((resolve, reject) => {
+            if (subs[payload.channel] === undefined) {
+                subs[payload.channel] = {
+                    client: redis.createClient({
+                        url: this.url,
+                        password: this.password,
+                    }),
+                    cbs: [],
+                };
+                subs[payload.channel].client.on(
+                    "message",
+                    (channel, message) => {
+                        try {
+                            message = JSON.parse(message);
+                        } catch (e) {}
+                        subs[channel].cbs.forEach((cb) => cb(message));
+                    }
+                );
+                subs[payload.channel].client.subscribe(payload.channel);
+            }
+
+            subs[payload.channel].cbs.push(payload.cb);
+
+            resolve();
+        });
+    }
+
+    GET_SCHEMA(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.schemas[payload.schemaName]);
+        });
+    }
 }
+
+module.exports = new CacheModule();

+ 324 - 190
backend/logic/db/index.js

@@ -1,127 +1,199 @@
-'use strict';
+const CoreClass = require("../../core.js");
 
-const coreClass = require("../../core");
-
-const mongoose = require('mongoose');
-const config = require('config');
+const mongoose = require("mongoose");
+const config = require("config");
 
 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]+$/,
-	custom: regex => new RegExp(`^[${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]+$/,
+    custom: (regex) => new RegExp(`^[${regex}]+$`),
 };
 
 const isLength = (string, min, max) => {
-	return !(typeof string !== 'string' || string.length < min || string.length > max);
-}
+    return !(
+        typeof string !== "string" ||
+        string.length < min ||
+        string.length > max
+    );
+};
 
-const bluebird = require('bluebird');
+const bluebird = require("bluebird");
 
 mongoose.Promise = bluebird;
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.schemas = {};
-			this.models = {};
-
-			const mongoUrl = config.get("mongo").url;
-
-			mongoose.connect(mongoUrl, {
-				useNewUrlParser: true,
-				useCreateIndex: true,
-				reconnectInterval: 3000,
-				reconnectTries: 10
-			})
-				.then(() => {
-					this.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`)),
-						punishment: new mongoose.Schema(require(`./schemas/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),
-						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.logger.error("DB_MODULE", err);
-					});
-
-					mongoose.connection.on('disconnected', () => {
-						this.logger.error("DB_MODULE", "Disconnected, going to try to reconnect...");
-						this.setState("RECONNECTING");
-					});
-
-					mongoose.connection.on('reconnected', () => {
-						this.logger.success("DB_MODULE", "Reconnected.");
-						this.setState("INITIALIZED");
-					});
-
-					mongoose.connection.on('reconnectFailed', () => {
-						this.logger.error("DB_MODULE", "Reconnect failed, stopping reconnecting.");
-						this.failed = true;
-						this._lockdown();
-					});
-		
-					// User
-					this.schemas.user.path('username').validate((username) => {
-						return (isLength(username, 2, 32) && regex.custom("a-zA-Z0-9_-").test(username));
-					}, '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.');
-
-					// Station
-					this.schemas.station.path('name').validate((id) => {
-						return (isLength(id, 2, 16) && regex.az09_.test(id));
-					}, 'Invalid station name.');
-		
-					this.schemas.station.path('displayName').validate((displayName) => {
-						return (isLength(displayName, 2, 32) && regex.ascii.test(displayName));
-					}, 'Invalid display name.');
-		
-					this.schemas.station.path('description').validate((description) => {
-						if (!isLength(description, 2, 200)) return false;
-						let characters = description.split("");
-						return characters.filter((character) => {
-							return character.charCodeAt(0) === 21328;
-						}).length === 0;
-					}, 'Invalid display name.');
-		
-					this.schemas.station.path('owner').validate({
-						validator: (owner) => {
-							return new Promise((resolve, reject) => {
-								this.models.station.countDocuments({ owner: owner }, (err, c) => {
-									if (err) reject(new Error("A mongo error happened."));
-									else if (c >= 3) reject(new Error("User already has 3 stations."));
-									else resolve();
-								});
-							});
-						},
-						message: 'User already has 3 stations.'
-					});
-		
-					/*
+class DBModule extends CoreClass {
+    constructor() {
+        super("db");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.schemas = {};
+            this.models = {};
+
+            const mongoUrl = config.get("mongo").url;
+
+            mongoose
+                .connect(mongoUrl, {
+                    useNewUrlParser: true,
+                    useCreateIndex: true,
+                    reconnectInterval: 3000,
+                    reconnectTries: 10,
+                })
+                .then(() => {
+                    this.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`)),
+                        activity: new mongoose.Schema(
+                            require(`./schemas/activity`)
+                        ),
+                        playlist: new mongoose.Schema(
+                            require(`./schemas/playlist`)
+                        ),
+                        news: new mongoose.Schema(require(`./schemas/news`)),
+                        report: new mongoose.Schema(
+                            require(`./schemas/report`)
+                        ),
+                        punishment: new mongoose.Schema(
+                            require(`./schemas/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),
+                        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.failed = true;
+                        // this._lockdown();
+                        this.setStatus("FAILED");
+                    });
+
+                    // User
+                    this.schemas.user.path("username").validate((username) => {
+                        return (
+                            isLength(username, 2, 32) &&
+                            regex.custom("a-zA-Z0-9_-").test(username)
+                        );
+                    }, "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.");
+
+                    // Station
+                    this.schemas.station.path("name").validate((id) => {
+                        return isLength(id, 2, 16) && regex.az09_.test(id);
+                    }, "Invalid station name.");
+
+                    this.schemas.station
+                        .path("displayName")
+                        .validate((displayName) => {
+                            return (
+                                isLength(displayName, 2, 32) &&
+                                regex.ascii.test(displayName)
+                            );
+                        }, "Invalid display name.");
+
+                    this.schemas.station
+                        .path("description")
+                        .validate((description) => {
+                            if (!isLength(description, 2, 200)) return false;
+                            let characters = description.split("");
+                            return (
+                                characters.filter((character) => {
+                                    return character.charCodeAt(0) === 21328;
+                                }).length === 0
+                            );
+                        }, "Invalid display name.");
+
+                    this.schemas.station.path("owner").validate({
+                        validator: (owner) => {
+                            return new Promise((resolve, reject) => {
+                                this.models.station.countDocuments(
+                                    { owner: owner },
+                                    (err, c) => {
+                                        if (err)
+                                            reject(
+                                                new Error(
+                                                    "A mongo error happened."
+                                                )
+                                            );
+                                        else if (c >= 3)
+                                            reject(
+                                                new Error(
+                                                    "User already has 3 stations."
+                                                )
+                                            );
+                                        else resolve();
+                                    }
+                                );
+                            });
+                        },
+                        message: "User already has 3 stations.",
+                    });
+
+                    /*
 					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
 						let totalDuration = 0;
 						queue.forEach((song) => {
@@ -158,81 +230,143 @@ module.exports = class extends coreClass {
 					}, 'The max amount of songs per user is 3, and only 2 in a row is allowed.');
 					*/
 
+                    // Song
+                    let songTitle = (title) => {
+                        return isLength(title, 1, 100);
+                    };
+                    this.schemas.song
+                        .path("title")
+                        .validate(songTitle, "Invalid title.");
+                    this.schemas.queueSong
+                        .path("title")
+                        .validate(songTitle, "Invalid title.");
 
-					// Song
-					let songTitle = (title) => {
-						return isLength(title, 1, 100);
-					};
-					this.schemas.song.path('title').validate(songTitle, 'Invalid title.');
-					this.schemas.queueSong.path('title').validate(songTitle, 'Invalid title.');
-		
-					this.schemas.song.path('artists').validate((artists) => {
-						return !(artists.length < 1 || artists.length > 10);
-					}, 'Invalid artists.');
-					this.schemas.queueSong.path('artists').validate((artists) => {
-						return !(artists.length < 0 || artists.length > 10);
-					}, 'Invalid artists.');
-		
-					let songArtists = (artists) => {
-						return artists.filter((artist) => {
-								return (isLength(artist, 1, 64) && artist !== "NONE");
-							}).length === artists.length;
-					};
-					this.schemas.song.path('artists').validate(songArtists, 'Invalid artists.');
-					this.schemas.queueSong.path('artists').validate(songArtists, 'Invalid artists.');
-		
-					let songGenres = (genres) => {
-						if (genres.length < 1 || genres.length > 16) return false;
-						return genres.filter((genre) => {
-								return (isLength(genre, 1, 32) && regex.ascii.test(genre));
-							}).length === genres.length;
-					};
-					this.schemas.song.path('genres').validate(songGenres, 'Invalid genres.');
-					this.schemas.queueSong.path('genres').validate(songGenres, 'Invalid genres.');
-		
-					let songThumbnail = (thumbnail) => {
-						if (!isLength(thumbnail, 1, 256)) return false;
-						if (config.get("cookie.secure") === true) return thumbnail.startsWith("https://");
-						else return thumbnail.startsWith("http://") || thumbnail.startsWith("https://");
-					};
-					this.schemas.song.path('thumbnail').validate(songThumbnail, 'Invalid thumbnail.');
-					this.schemas.queueSong.path('thumbnail').validate(songThumbnail, 'Invalid thumbnail.');
-
-					// Playlist
-					this.schemas.playlist.path('displayName').validate((displayName) => {
-						return (isLength(displayName, 1, 32) && regex.ascii.test(displayName));
-					}, 'Invalid display name.');
-		
-					this.schemas.playlist.path('createdBy').validate((createdBy) => {
-						this.models.playlist.countDocuments({ createdBy: createdBy }, (err, c) => {
-							return !(err || c >= 10);
-						});
-					}, 'Max 10 playlists per user.');
-		
-					this.schemas.playlist.path('songs').validate((songs) => {
-						return songs.length <= 5000;
-					}, 'Max 5000 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.');
-		
-					// Report
-					this.schemas.report.path('description').validate((description) => {
-						return (!description || (isLength(description, 0, 400) && regex.ascii.test(description)));
-					}, 'Invalid description.');
-
-					resolve();
-				})
-				.catch(err => {
-					this.logger.error("DB_MODULE", err);
-					reject(err);
-				});
-		})
-	}
-
-	passwordValid(password) {
-		return isLength(password, 6, 200);
-	}
+                    this.schemas.song.path("artists").validate((artists) => {
+                        return !(artists.length < 1 || artists.length > 10);
+                    }, "Invalid artists.");
+                    this.schemas.queueSong
+                        .path("artists")
+                        .validate((artists) => {
+                            return !(artists.length < 0 || artists.length > 10);
+                        }, "Invalid artists.");
+
+                    let songArtists = (artists) => {
+                        return (
+                            artists.filter((artist) => {
+                                return (
+                                    isLength(artist, 1, 64) && artist !== "NONE"
+                                );
+                            }).length === artists.length
+                        );
+                    };
+                    this.schemas.song
+                        .path("artists")
+                        .validate(songArtists, "Invalid artists.");
+                    this.schemas.queueSong
+                        .path("artists")
+                        .validate(songArtists, "Invalid artists.");
+
+                    let songGenres = (genres) => {
+                        if (genres.length < 1 || genres.length > 16)
+                            return false;
+                        return (
+                            genres.filter((genre) => {
+                                return (
+                                    isLength(genre, 1, 32) &&
+                                    regex.ascii.test(genre)
+                                );
+                            }).length === genres.length
+                        );
+                    };
+                    this.schemas.song
+                        .path("genres")
+                        .validate(songGenres, "Invalid genres.");
+                    this.schemas.queueSong
+                        .path("genres")
+                        .validate(songGenres, "Invalid genres.");
+
+                    let songThumbnail = (thumbnail) => {
+                        if (!isLength(thumbnail, 1, 256)) return false;
+                        if (config.get("cookie.secure") === true)
+                            return thumbnail.startsWith("https://");
+                        else
+                            return (
+                                thumbnail.startsWith("http://") ||
+                                thumbnail.startsWith("https://")
+                            );
+                    };
+                    this.schemas.song
+                        .path("thumbnail")
+                        .validate(songThumbnail, "Invalid thumbnail.");
+                    this.schemas.queueSong
+                        .path("thumbnail")
+                        .validate(songThumbnail, "Invalid thumbnail.");
+
+                    // Playlist
+                    this.schemas.playlist
+                        .path("displayName")
+                        .validate((displayName) => {
+                            return (
+                                isLength(displayName, 1, 32) &&
+                                regex.ascii.test(displayName)
+                            );
+                        }, "Invalid display name.");
+
+                    this.schemas.playlist
+                        .path("createdBy")
+                        .validate((createdBy) => {
+                            this.models.playlist.countDocuments(
+                                { createdBy: createdBy },
+                                (err, c) => {
+                                    return !(err || c >= 10);
+                                }
+                            );
+                        }, "Max 10 playlists per user.");
+
+                    this.schemas.playlist.path("songs").validate((songs) => {
+                        return songs.length <= 5000;
+                    }, "Max 5000 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.");
+
+                    // Report
+                    this.schemas.report
+                        .path("description")
+                        .validate((description) => {
+                            return (
+                                !description ||
+                                (isLength(description, 0, 400) &&
+                                    regex.ascii.test(description))
+                            );
+                        }, "Invalid description.");
+
+                    resolve();
+                })
+                .catch((err) => {
+                    this.log("ERROR", err);
+                    reject(err);
+                });
+        });
+    }
+
+    GET_MODEL(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.models[payload.modelName]);
+        });
+    }
+
+    GET_SCHEMA(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.schemas[payload.schemaName]);
+        });
+    }
+
+    passwordValid(password) {
+        return isLength(password, 6, 200);
+    }
 }
+
+module.exports = new DBModule();

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

@@ -0,0 +1,16 @@
+module.exports = {
+	createdAt: { type: Date, default: Date.now, required: true },
+	hidden: { type: Boolean, default: false, required: true },
+	userId: { type: String, required: true },
+	activityType: { type: String, enum: [
+		"created_account",
+		"created_station",
+		"deleted_station",
+		"created_playlist",
+		"deleted_playlist",
+		"liked_song",
+		"added_song_to_playlist",
+		"added_songs_to_playlist"
+	], required: true },
+	payload: { type: Array, required: true }
+}

+ 1 - 1
backend/logic/db/schemas/news.js

@@ -6,5 +6,5 @@ module.exports = {
 	improvements: [{ type: String }],
 	upcoming: [{ type: String }],
 	createdBy: { type: String, required: true },
-	createdAt: { type: Number, default: Date.now(), required: true }
+	createdAt: { type: Number, default: Date.now, required: true }
 };

+ 1 - 1
backend/logic/db/schemas/playlist.js

@@ -2,5 +2,5 @@ module.exports = {
 	displayName: { type: String, min: 2, max: 32, required: true },
 	songs: { type: Array },
 	createdBy: { type: String, required: true },
-	createdAt: { type: Date, default: Date.now(), required: true }
+	createdAt: { type: Date, default: Date.now, required: true }
 };

+ 1 - 1
backend/logic/db/schemas/punishment.js

@@ -4,6 +4,6 @@ module.exports = {
 	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 },
+	punishedAt: { type: Date, default: Date.now, required: true },
 	punishedBy: { type: String, required: true }
 };

+ 1 - 1
backend/logic/db/schemas/queueSong.js

@@ -8,6 +8,6 @@ module.exports = {
 	thumbnail: { type: String, required: true },
 	explicit: { type: Boolean, 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 }
 };

+ 1 - 1
backend/logic/db/schemas/report.js

@@ -10,5 +10,5 @@ module.exports = {
 		reasons: Array
 	}],
 	createdBy: { type: String, required: true },
-	createdAt: { type: Date, default: Date.now(), required: true }
+	createdAt: { type: Date, default: Date.now, required: true }
 };

+ 1 - 1
backend/logic/db/schemas/song.js

@@ -12,6 +12,6 @@ module.exports = {
 	requestedBy: { type: String, required: true },
 	requestedAt: { type: Date, required: true },
 	acceptedBy: { type: String, required: true },
-	acceptedAt: { type: Date, default: Date.now(), required: true },
+	acceptedAt: { type: Date, default: Date.now, required: true },
 	discogs: { type: Object }
 };

+ 8 - 1
backend/logic/db/schemas/user.js

@@ -6,6 +6,10 @@ module.exports = {
 		verificationToken: String,
 		address: String
 	},
+	avatar: {
+		type: { type: String, enum: ["gravatar", "initials"] },
+		url: { type: String, required: false }
+	},
 	services: {
 		password: {
 			password: String,
@@ -29,5 +33,8 @@ module.exports = {
 	liked: [{ type: String }],
 	disliked: [{ type: String }],
 	favoriteStations: [{ type: String }],
-	createdAt: { type: Date, default: Date.now() }
+	name: { type: String, default: "" },
+	location: { type: String, default: "" },
+	bio: { type: String, default: "" },
+	createdAt: { type: Date, default: Date.now }
 };

+ 99 - 77
backend/logic/discord.js

@@ -1,91 +1,113 @@
-const coreClass = require("../core");
+const CoreClass = require("../core.js");
 
-const EventEmitter = require('events');
 const Discord = require("discord.js");
 const config = require("config");
 
-const bus = new EventEmitter();
+class DiscordModule extends CoreClass {
+    constructor() {
+        super("discord");
+    }
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.log("INFO", "Discord initialize");
 
-			this.client = new Discord.Client();
-			this.adminAlertChannelId = config.get("apis.discord").loggingChannel;
-			
-			this.client.on("ready", () => {
-				this.logger.info("DISCORD_MODULE", `Logged in as ${this.client.user.tag}!`);
+            this.client = new Discord.Client();
+            this.adminAlertChannelId = config.get(
+                "apis.discord"
+            ).loggingChannel;
 
-				if (this.state === "INITIALIZING") resolve();
-				else {
-					this.logger.info("DISCORD_MODULE", `Discord client reconnected.`);
-					this.setState("INITIALIZED");
-				}
-			});
-		  
-			this.client.on("disconnect", () => {
-				this.logger.info("DISCORD_MODULE", `Discord client disconnected.`);
+            this.client.on("ready", () => {
+                this.log("INFO", `Logged in as ${this.client.user.tag}!`);
 
-				if (this.state === "INITIALIZING") reject();
-				else {
-					this.failed = true;
-					this._lockdown;
-				} 
-			});
+                if (this.getStatus() === "INITIALIZING") {
+                    resolve();
+                } else if (this.getStatus() === "RECONNECTING") {
+                    this.log("INFO", `Discord client reconnected.`);
+                    this.setStatus("READY");
+                }
+            });
 
-			this.client.on("reconnecting", () => {
-				this.logger.info("DISCORD_MODULE", `Discord client reconnecting.`);
-				this.setState("RECONNECTING");
-			});
-		
-			this.client.on("error", err => {
-				this.logger.info("DISCORD_MODULE", `Discord client encountered an error: ${err.message}.`);
-			});
+            this.client.on("disconnect", () => {
+                this.log("INFO", `Discord client disconnected.`);
 
-			this.client.login(config.get("apis.discord").token);
-		});
-	}
+                if (this.getStatus() === "INITIALIZING") reject();
+                else {
+                    this.setStatus("DISCONNECTED");
+                }
+            });
 
-	async sendAdminAlertMessage(message, color, type, critical, extraFields) {
-		try { await this._validateHook(); } catch { return; }
+            this.client.on("reconnecting", () => {
+                this.log("INFO", `Discord client reconnecting.`);
+                this.setStatus("RECONNECTING");
+            });
 
-		const channel = this.client.channels.find(channel => channel.id === this.adminAlertChannelId);
-		if (channel !== null) {
-			let richEmbed = new Discord.RichEmbed();
-			richEmbed.setAuthor(
-				"Musare Logger",
-				`${config.get("domain")}/favicon-194x194.png`,
-				config.get("domain")
-			);
-			richEmbed.setColor(color);
-			richEmbed.setDescription(message);
-			//richEmbed.setFooter("Footer", "https://musare.com/favicon-194x194.png");
-			//richEmbed.setImage("https://musare.com/favicon-194x194.png");
-			//richEmbed.setThumbnail("https://musare.com/favicon-194x194.png");
-			richEmbed.setTimestamp(new Date());
-			richEmbed.setTitle("MUSARE ALERT");
-			richEmbed.setURL(config.get("domain"));
-			richEmbed.addField("Type:", type, true);
-			richEmbed.addField("Critical:", critical ? "True" : "False", true);
-			extraFields.forEach(extraField => {
-				richEmbed.addField(
-					extraField.name,
-					extraField.value,
-					extraField.inline
-				);
-			});
+            this.client.on("error", (err) => {
+                this.log(
+                    "INFO",
+                    `Discord client encountered an error: ${err.message}.`
+                );
+            });
 
-			channel
-			.send(message, { embed: richEmbed })
-			.then(message =>
-				this.logger.success("SEND_ADMIN_ALERT_MESSAGE", `Sent admin alert message: ${message}`)
-			)
-			.catch(() =>
-				this.logger.error("SEND_ADMIN_ALERT_MESSAGE", "Couldn't send admin alert message")
-			);
-		} else {
-			this.logger.error("SEND_ADMIN_ALERT_MESSAGE", "Couldn't send admin alert message, channel was not found.");
-		}
-	}
+            this.client.login(config.get("apis.discord").token);
+        });
+    }
+
+    SEND_ADMIN_ALERT_MESSAGE(payload) {
+        return new Promise((resolve, reject) => {
+            const channel = this.client.channels.find(
+                (channel) => channel.id === this.adminAlertChannelId
+            );
+            if (channel !== null) {
+                let richEmbed = new Discord.RichEmbed();
+                richEmbed.setAuthor(
+                    "Musare Logger",
+                    `${config.get("domain")}/favicon-194x194.png`,
+                    config.get("domain")
+                );
+                richEmbed.setColor(payload.color);
+                richEmbed.setDescription(payload.message);
+                //richEmbed.setFooter("Footer", "https://musare.com/favicon-194x194.png");
+                //richEmbed.setImage("https://musare.com/favicon-194x194.png");
+                //richEmbed.setThumbnail("https://musare.com/favicon-194x194.png");
+                richEmbed.setTimestamp(new Date());
+                richEmbed.setTitle("MUSARE ALERT");
+                richEmbed.setURL(config.get("domain"));
+                richEmbed.addField("Type:", payload.type, true);
+                richEmbed.addField(
+                    "Critical:",
+                    payload.critical ? "True" : "False",
+                    true
+                );
+                payload.extraFields.forEach((extraField) => {
+                    richEmbed.addField(
+                        extraField.name,
+                        extraField.value,
+                        extraField.inline
+                    );
+                });
+
+                channel
+                    .send(payload.message, { embed: richEmbed })
+                    .then((message) =>
+                        resolve({
+                            status: "success",
+                            message: `Successfully sent admin alert message: ${message}`,
+                        })
+                    )
+                    .catch(() =>
+                        reject(new Error("Couldn't send admin alert message"))
+                    );
+            } else {
+                reject(new Error("Channel was not found"));
+            }
+            // if (true) {
+            //     resolve({});
+            // } else {
+            //     reject(new Error("Nothing changed."));
+            // }
+        });
+    }
 }
+
+module.exports = new DiscordModule();

+ 376 - 188
backend/logic/io.js

@@ -1,194 +1,382 @@
-'use strict';
-
-// This file contains all the logic for Socket.IO
-
-const coreClass = require("../core");
+const CoreClass = require("../core.js");
 
 const socketio = require("socket.io");
 const async = require("async");
 const config = require("config");
 
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["app", "db", "cache", "utils"];
-	}
-
-	initialize() {
-		return new Promise(resolve => {
-			this.setStage(1);
-
-			const 	logger		= this.logger,
-					app			= this.moduleManager.modules["app"],
-					cache		= this.moduleManager.modules["cache"],
-					utils		= this.moduleManager.modules["utils"],
-					db			= this.moduleManager.modules["db"],
-					punishments	= this.moduleManager.modules["punishments"];
-			
-			const actions = require('../logic/actions');
-
-			const SIDname = config.get("cookie.SIDname");
-
-			// 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 = socketio(app.server);
-
-			this._io.use(async (socket, next) => {
-				try { await this._validateHook(); } catch { return; }
-
-				let SID;
-
-				socket.ip = socket.request.headers['x-forwarded-for'] || '0.0.0.0';
-
-				async.waterfall([
-					(next) => {
-						utils.parseCookies(
-							socket.request.headers.cookie
-						).then(res => {
-							SID = res[SIDname];
-							next(null);
-						});
-					},
-
-					(next) => {
-						if (!SID) return next('No SID.');
-						next();
-					},
-
-					(next) => {
-						cache.hget('sessions', SID, next);
-					},
-
-					(session, next) => {
-						if (!session) return next('No session found.');
-
-						session.refreshDate = Date.now();
-						
-						socket.session = session;
-						cache.hset('sessions', SID, session, next);
-					},
-
-					(res, next) => {
-						// check if a session's user / IP is banned
-						punishments.getPunishments((err, punishments) => {
-							const isLoggedIn = !!(socket.session && socket.session.refreshDate);
-							const userId = (isLoggedIn) ? socket.session.userId : null;
-
-							let 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();
-						});
-					}
-				], () => {
-					if (!socket.session) socket.session = { socketId: socket.id };
-					else socket.session.socketId = socket.id;
-
-					next();
-				});
-			});
-
-			this._io.on('connection', async socket => {
-				try { await this._validateHook(); } catch { return; }
-
-				let sessionInfo = '';
-				
-				if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
-
-				// if session is banned
-				if (socket.banishment && socket.banishment.banned) {
-					logger.info('IO_BANNED_CONNECTION', `A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`);
-					socket.emit('keep.event:banned', socket.banishment.ban);
-					socket.disconnect(true);
-				} else {
-					logger.info('IO_CONNECTION', `User connected. IP: ${socket.ip}.${sessionInfo}`);
-
-					// catch when the socket has been disconnected
-					socket.on('disconnect', () => {
-						if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
-						logger.info('IO_DISCONNECTION', `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
-					});
-
-					// catch errors on the socket (internal to socket.io)
-					socket.on('error', console.error);
-
-					// 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, async (...args) => {
-								let cb = args[args.length - 1];
-								if (typeof cb !== "function")
-									cb = () => {
-										this.logger.info("IO_MODULE", `There was no callback provided for ${name}.`);
-									}
-								else args.pop();
-
-								try { await this._validateHook(); } catch { return cb({status: 'failure', message: 'Lockdown'}); } 
-
-								// 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);
-				}
-			});
-
-			resolve();
-		});
-	}
-
-	async io () {
-		try { await this._validateHook(); } catch { return; }
-		return this._io;
-	}
+class IOModule extends CoreClass {
+    constructor() {
+        super("io");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.setStage(1);
+
+            const app = this.moduleManager.modules["app"],
+                cache = this.moduleManager.modules["cache"],
+                utils = this.moduleManager.modules["utils"],
+                db = this.moduleManager.modules["db"],
+                punishments = this.moduleManager.modules["punishments"];
+
+            const actions = require("./actions");
+
+            this.setStage(2);
+
+            const SIDname = config.get("cookie.SIDname");
+
+            // 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 = socketio(await app.runJob("SERVER", {}));
+
+            this.setStage(3);
+
+            this._io.use(async (socket, next) => {
+                if (this.getStatus() !== "READY") {
+                    this.log(
+                        "INFO",
+                        "IO_REJECTED_CONNECTION",
+                        `A user tried to connect, but the IO module is currently not ready. IP: ${socket.ip}.${sessionInfo}`
+                    );
+                    return socket.disconnect(true);
+                }
+
+                let SID;
+
+                socket.ip =
+                    socket.request.headers["x-forwarded-for"] || "0.0.0.0";
+
+                async.waterfall(
+                    [
+                        (next) => {
+                            utils
+                                .runJob("PARSE_COOKIES", {
+                                    cookieString: socket.request.headers.cookie,
+                                })
+                                .then((res) => {
+                                    SID = res[SIDname];
+                                    next(null);
+                                });
+                        },
+
+                        (next) => {
+                            if (!SID) return next("No SID.");
+                            next();
+                        },
+
+                        (next) => {
+                            cache
+                                .runJob("HGET", { table: "sessions", key: SID })
+                                .then((session) => {
+                                    next(null, session);
+                                });
+                        },
+
+                        (session, next) => {
+                            if (!session) return next("No session found.");
+
+                            session.refreshDate = Date.now();
+
+                            socket.session = session;
+                            cache
+                                .runJob("HSET", {
+                                    table: "sessions",
+                                    key: SID,
+                                    value: session,
+                                })
+                                .then((session) => {
+                                    next(null, session);
+                                });
+                        },
+
+                        (res, next) => {
+                            // check if a session's user / IP is banned
+                            punishments
+                                .runJob("GET_PUNISHMENTS", {})
+                                .then((punishments) => {
+                                    const isLoggedIn = !!(
+                                        socket.session &&
+                                        socket.session.refreshDate
+                                    );
+                                    const userId = isLoggedIn
+                                        ? socket.session.userId
+                                        : null;
+
+                                    let 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: socket.id };
+                        else socket.session.socketId = socket.id;
+
+                        next();
+                    }
+                );
+            });
+
+            this.setStage(4);
+
+            this._io.on("connection", async (socket) => {
+                if (this.getStatus() !== "READY") {
+                    this.log(
+                        "INFO",
+                        "IO_REJECTED_CONNECTION",
+                        `A user tried to connect, but the IO module is currently not ready. IP: ${socket.ip}.${sessionInfo}`
+                    );
+                    return socket.disconnect(true);
+                }
+
+                let sessionInfo = "";
+
+                if (socket.session.sessionId)
+                    sessionInfo = ` UserID: ${socket.session.userId}.`;
+
+                // if session is banned
+                if (socket.banishment && socket.banishment.banned) {
+                    this.log(
+                        "INFO",
+                        "IO_BANNED_CONNECTION",
+                        `A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`
+                    );
+                    socket.emit("keep.event:banned", socket.banishment.ban);
+                    socket.disconnect(true);
+                } else {
+                    this.log(
+                        "INFO",
+                        "IO_CONNECTION",
+                        `User connected. IP: ${socket.ip}.${sessionInfo}`
+                    );
+
+                    // catch when the socket has been disconnected
+                    socket.on("disconnect", () => {
+                        if (socket.session.sessionId)
+                            sessionInfo = ` UserID: ${socket.session.userId}.`;
+                        this.log(
+                            "INFO",
+                            "IO_DISCONNECTION",
+                            `User disconnected. IP: ${socket.ip}.${sessionInfo}`
+                        );
+                    });
+
+                    socket.use((data, next) => {
+                        if (data.length === 0)
+                            return next(
+                                new Error("Not enough arguments specified.")
+                            );
+                        else if (typeof data[0] !== "string")
+                            return next(
+                                new Error("First argument must be a string.")
+                            );
+                        else {
+                            const namespaceAction = data[0];
+                            if (
+                                !namespaceAction ||
+                                namespaceAction.indexOf(".") === -1 ||
+                                namespaceAction.indexOf(".") !==
+                                    namespaceAction.lastIndexOf(".")
+                            )
+                                return next(
+                                    new Error("Invalid first argument")
+                                );
+                            const namespace = data[0].split(".")[0];
+                            const action = data[0].split(".")[1];
+                            if (!namespace)
+                                return next(new Error("Invalid namespace."));
+                            else if (!action)
+                                return next(new Error("Invalid action."));
+                            else if (!actions[namespace])
+                                return next(new Error("Namespace not found."));
+                            else if (!actions[namespace][action])
+                                return next(new Error("Action not found."));
+                            else return next();
+                        }
+                    });
+
+                    // catch errors on the socket (internal to socket.io)
+                    socket.on("error", console.error);
+
+                    // 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, async (...args) => {
+                                let cb = args[args.length - 1];
+                                if (typeof cb !== "function")
+                                    cb = () => {
+                                        this.this.log(
+                                            "INFO",
+                                            "IO_MODULE",
+                                            `There was no callback provided for ${name}.`
+                                        );
+                                    };
+                                else args.pop();
+
+                                if (this.getStatus() !== "READY") {
+                                    this.log(
+                                        "INFO",
+                                        "IO_REJECTED_ACTION",
+                                        `A user tried to execute an action, but the IO module is currently not ready. Action: ${namespace}.${action}.`
+                                    );
+                                    return;
+                                } else {
+                                    this.log(
+                                        "INFO",
+                                        "IO_ACTION",
+                                        `A user executed an action. Action: ${namespace}.${action}.`
+                                    );
+                                }
+
+                                // load the session from the cache
+                                cache
+                                    .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;
+
+                                        try {
+                                            // 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) => {
+                                                            this.log(
+                                                                "INFO",
+                                                                "IO_ACTION",
+                                                                `Response to action. Action: ${namespace}.${action}. Response status: ${result.status}`
+                                                            );
+                                                            // respond to the socket with our message
+                                                            if (
+                                                                typeof cb ===
+                                                                "function"
+                                                            )
+                                                                return cb(
+                                                                    result
+                                                                );
+                                                        },
+                                                    ])
+                                            );
+                                        } catch (err) {
+                                            this.log(
+                                                "ERROR",
+                                                "IO_ACTION_ERROR",
+                                                `Some type of exception occurred in the action ${namespace}.${action}. Error message: ${err.message}`
+                                            );
+                                            if (typeof cb === "function")
+                                                return cb({
+                                                    status: "error",
+                                                    message:
+                                                        "An error occurred while executing the specified action.",
+                                                });
+                                        }
+                                    })
+                                    .catch((err) => {
+                                        if (typeof cb === "function")
+                                            return cb({
+                                                status: "error",
+                                                message:
+                                                    "An error occurred while obtaining your session",
+                                            });
+                                    });
+                            });
+                        });
+                    });
+
+                    if (socket.session.sessionId) {
+                        cache
+                            .runJob("HGET", {
+                                table: "sessions",
+                                key: socket.session.sessionId,
+                            })
+                            .then((session) => {
+                                if (session && session.userId) {
+                                    db.runJob("GET_MODEL", {
+                                        modelName: "user",
+                                    }).then((userModel) => {
+                                        userModel.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);
+                            })
+                            .catch((err) => {
+                                socket.emit("ready", false);
+                            });
+                    } else socket.emit("ready", false);
+                }
+            });
+
+            this.setStage(5);
+
+            resolve();
+        });
+    }
+
+    IO() {
+        return new Promise((resolve, reject) => {
+            resolve(this._io);
+        });
+    }
 }
+
+module.exports = new IOModule();

+ 0 - 177
backend/logic/logger.js

@@ -1,177 +0,0 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const config = require('config');
-const fs = require('fs');
-
-const twoDigits = (num) => {
-	return (num < 10) ? '0' + num : num;
-};
-
-const getTime = () => {
-	let time = new Date();
-	return {
-		year: time.getFullYear(),
-		month: time.getMonth() + 1,
-		day: time.getDate(),
-		hour: time.getHours(),
-		minute: time.getMinutes(),
-		second: time.getSeconds()
-	}
-};
-
-const getTimeFormatted = () => {
-	let time = getTime();
-	return `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
-}
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-		this.lockdownImmune = true;
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.configDirectory = `${__dirname}/../../log`;
-
-			if (!config.isDocker && !fs.existsSync(`${this.configDirectory}`))
-				fs.mkdirSync(this.configDirectory);
-
-			let time = getTimeFormatted();
-
-			this.logCbs = [];
-
-			this.colors = {
-				Reset: "\x1b[0m",
-				Bright: "\x1b[1m",
-				Dim: "\x1b[2m",
-				Underscore: "\x1b[4m",
-				Blink: "\x1b[5m",
-				Reverse: "\x1b[7m",
-				Hidden: "\x1b[8m",
-
-				FgBlack: "\x1b[30m",
-				FgRed: "\x1b[31m",
-				FgGreen: "\x1b[32m",
-				FgYellow: "\x1b[33m",
-				FgBlue: "\x1b[34m",
-				FgMagenta: "\x1b[35m",
-				FgCyan: "\x1b[36m",
-				FgWhite: "\x1b[37m",
-
-				BgBlack: "\x1b[40m",
-				BgRed: "\x1b[41m",
-				BgGreen: "\x1b[42m",
-				BgYellow: "\x1b[43m",
-				BgBlue: "\x1b[44m",
-				BgMagenta: "\x1b[45m",
-				BgCyan: "\x1b[46m",
-				BgWhite: "\x1b[47m"
-			};
-
-			fs.appendFile(this.configDirectory + '/all.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-			fs.appendFile(this.configDirectory + '/success.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-			fs.appendFile(this.configDirectory + '/error.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-			fs.appendFile(this.configDirectory + '/info.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-			fs.appendFile(this.configDirectory + '/debugStation.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-
-			if (this.moduleManager.fancyConsole) {
-				process.stdout.write(Array(this.reservedLines).fill(`\n`).join(""));
-			}
-
-			resolve();
-		});
-	}
-
-	async success(type, text, display = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} SUCCESS - ${type} - ${text}`;
-
-		this.writeFile('all.log', message);
-		this.writeFile('success.log', message);
-
-		if (display) this.log(this.colors.FgGreen, message);
-	}
-
-	async error(type, text, display = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} ERROR - ${type} - ${text}`;
-
-		this.writeFile('all.log', message);
-		this.writeFile('error.log', message);
-
-		if (display) this.log(this.colors.FgRed, message);
-	}
-
-	async info(type, text, display = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} INFO - ${type} - ${text}`;
-
-		this.writeFile('all.log', message);
-		this.writeFile('info.log', message);
-		if (display) this.log(this.colors.FgCyan, message);
-	}
-
-	async debug(text, display = true) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} DEBUG - ${text}`;
-
-		if (display) this.log(this.colors.FgMagenta, message);
-	}
-
-	async stationIssue(text, display = false) {
-		try { await this._validateHook(); } catch { return; }
-
-		const time = getTimeFormatted();
-		const message = `${time} DEBUG_STATION - ${text}`;
-
-		this.writeFile('debugStation.log', message);
-
-		if (display) this.log(this.colors.FgMagenta, message);
-	}
-
-	log(color, message) {
-		if (this.moduleManager.fancyConsole) {
-			const rows = process.stdout.rows;
-			const columns = process.stdout.columns;
-			const lineNumber = rows - this.reservedLines;
-
-			
-			let lines = 0;
-			
-			message.split("\n").forEach((line) => {
-				lines += Math.floor(line.replace("\t", "    ").length / columns) + 1;
-			});
-
-			if (lines > this.logger.reservedLines)
-				lines = this.logger.reservedLines;
-
-			process.stdout.cursorTo(0, rows - this.logger.reservedLines);
-			process.stdout.clearScreenDown();
-
-			process.stdout.cursorTo(0, lineNumber);
-			process.stdout.write(`${color}${message}${this.colors.Reset}\n`);
-
-			process.stdout.cursorTo(0, process.stdout.rows);
-			process.stdout.write(Array(lines).fill(`\n!`).join(""));
-
-			this.moduleManager.printStatus();
-		} else console.log(`${color}${message}${this.colors.Reset}`);
-	}
-
-	writeFile(fileName, message) {
-		fs.appendFile(`${this.configDirectory}/${fileName}`, `${message}\n`, ()=>{});
-	}
-}

+ 45 - 35
backend/logic/mail/index.js

@@ -1,40 +1,50 @@
-'use strict';
+const CoreClass = require("../../core.js");
 
-const coreClass = require("../../core");
-
-const config = require('config');
+const config = require("config");
 
 let mailgun = null;
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.schemas = {
-				verifyEmail: require('./schemas/verifyEmail'),
-				resetPasswordRequest: require('./schemas/resetPasswordRequest'),
-				passwordRequest: require('./schemas/passwordRequest')
-			};
-
-			this.enabled = config.get('apis.mailgun.enabled');
-
-			if (this.enabled)
-				mailgun = require('mailgun-js')({
-					apiKey: config.get("apis.mailgun.key"),
-					domain: config.get("apis.mailgun.domain")
-				});
-			
-			resolve();
-		});
-	}
-
-	async sendMail(data, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!cb) cb = ()=>{};
-
-		if (this.enabled) mailgun.messages().send(data, cb);
-		else cb();
-	}
+class MailModule extends CoreClass {
+    constructor() {
+        super("mail");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.schemas = {
+                verifyEmail: require("./schemas/verifyEmail"),
+                resetPasswordRequest: require("./schemas/resetPasswordRequest"),
+                passwordRequest: require("./schemas/passwordRequest"),
+            };
+
+            this.enabled = config.get("apis.mailgun.enabled");
+
+            if (this.enabled)
+                mailgun = require("mailgun-js")({
+                    apiKey: config.get("apis.mailgun.key"),
+                    domain: config.get("apis.mailgun.domain"),
+                });
+
+            resolve();
+        });
+    }
+
+    SEND_MAIL(payload) {
+        //data, cb
+        return new Promise((resolve, reject) => {
+            if (this.enabled)
+                mailgun.messages().send(payload.data, () => {
+                    resolve();
+                });
+            else resolve();
+        });
+    }
+
+    GET_SCHEMA(payload) {
+        return new Promise((resolve, reject) => {
+            resolve(this.schemas[payload.schemaName]);
+        });
+    }
 }
+
+module.exports = new MailModule();

+ 17 - 12
backend/logic/mail/schemas/passwordRequest.js

@@ -1,8 +1,8 @@
-const config = require('config');
+const config = require("config");
 
-const moduleManager = require('../../../index');
+// const moduleManager = require('../../../index');
 
-const mail = moduleManager.modules["mail"];
+const mail = require("../index");
 
 /**
  * Sends a request password email
@@ -13,12 +13,11 @@ const mail = moduleManager.modules["mail"];
  * @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 request',
-		html:
-			`
+    let data = {
+        from: "Musare <noreply@musare.com>",
+        to: to,
+        subject: "Password request",
+        html: `
 				Hello there ${username},
 				<br>
 				<br>
@@ -27,7 +26,13 @@ module.exports = function(to, username, code, cb) {
 				<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.sendMail(data, cb);
-};
+    mail.runJob("SEND_MAIL", { data })
+        .then(() => {
+            cb();
+        })
+        .catch(err => {
+            cb(err);
+        });
+};

+ 17 - 12
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -1,8 +1,8 @@
-const config = require('config');
+const config = require("config");
 
-const moduleManager = require('../../../index');
+// const moduleManager = require('../../../index');
 
-const mail = moduleManager.modules["mail"];
+const mail = require("../index");
 
 /**
  * Sends a request password reset email
@@ -13,12 +13,11 @@ const mail = moduleManager.modules["mail"];
  * @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:
-			`
+    let data = {
+        from: "Musare <noreply@musare.com>",
+        to: to,
+        subject: "Password reset request",
+        html: `
 				Hello there ${username},
 				<br>
 				<br>
@@ -27,7 +26,13 @@ module.exports = function(to, username, code, cb) {
 				<br>
 				The reset code is <b>${code}</b>. You can enter this code on the page you requested the password reset. This code will expire in 24 hours.
 			`
-	};
+    };
 
-	mail.sendMail(data, cb);
-};
+    mail.runJob("SEND_MAIL", { data })
+        .then(() => {
+            cb();
+        })
+        .catch(err => {
+            cb(err);
+        });
+};

+ 22 - 13
backend/logic/mail/schemas/verifyEmail.js

@@ -1,8 +1,8 @@
-const config = require('config');
+const config = require("config");
 
-const moduleManager = require('../../../index');
+// const moduleManager = require('../../../index');
 
-const mail = moduleManager.modules["mail"];
+const mail = require("../index");
 
 /**
  * Sends a verify email email
@@ -13,18 +13,27 @@ const mail = moduleManager.modules["mail"];
  * @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:
-			`
+    let data = {
+        from: "Musare <noreply@musare.com>",
+        to: to,
+        subject: "Please verify your email",
+        html: `
 				Hello there ${username},
 				<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);
+        });
+};

+ 243 - 154
backend/logic/notifications.js

@@ -1,159 +1,248 @@
-'use strict';
+const CoreClass = require("../core.js");
 
-const coreClass = require("../core");
-
-const crypto = require('crypto');
-const redis = require('redis');
-const config = require('config');
+const crypto = require("crypto");
+const redis = require("redis");
+const config = require("config");
 
 const subscriptions = [];
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			const url = this.url = config.get("redis").url;
-			const password = this.password = config.get("redis").password;
-
-			this.pub = redis.createClient({
-				url,
-				password,
-				retry_strategy: (options) => {
-					if (this.state === "LOCKDOWN") return;
-					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
-
-					this.logger.info("NOTIFICATIONS_MODULE", `Attempting to reconnect pub.`);
-
-					if (options.attempt >= 10) {
-						this.logger.error("NOTIFICATIONS_MODULE", `Stopped trying to reconnect pub.`);
-
-						this.failed = true;
-						this._lockdown();
-
-						return undefined;
-					}
-
-					return 3000;
-				}
-			});
-			this.sub = redis.createClient({
-				url,
-				password,
-				retry_strategy: (options) => {
-					if (this.state === "LOCKDOWN") return;
-					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
-
-					this.logger.info("NOTIFICATIONS_MODULE", `Attempting to reconnect sub.`);
-
-					if (options.attempt >= 10) {
-						this.logger.error("NOTIFICATIONS_MODULE", `Stopped trying to reconnect sub.`);
-
-						this.failed = true;
-						this._lockdown();
-
-						return undefined;
-					}
-
-					return 3000;
-				}
-			});
-
-			this.sub.on('error', (err) => {
-				if (this.state === "INITIALIZING") reject(err);
-				if(this.state === "LOCKDOWN") return;
-
-				this.logger.error("NOTIFICATIONS_MODULE", `Sub error ${err.message}.`);
-			});
-
-			this.pub.on('error', (err) => {
-				if (this.state === "INITIALIZING") reject(err);
-				if(this.state === "LOCKDOWN") return; 
-
-				this.logger.error("NOTIFICATIONS_MODULE", `Pub error ${err.message}.`);
-			});
-
-			this.sub.on("connect", () => {
-				this.logger.info("NOTIFICATIONS_MODULE", "Sub connected succesfully.");
-
-				if (this.state === "INITIALIZING") resolve();
-				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
-				
-			});
-
-			this.pub.on("connect", () => {
-				this.logger.info("NOTIFICATIONS_MODULE", "Pub connected succesfully.");
-
-				if (this.state === "INITIALIZING") resolve();
-				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
-			});
-
-			this.sub.on('pmessage', (pattern, channel, expiredKey) => {
-				this.logger.stationIssue(`PMESSAGE1 - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`);
-				subscriptions.forEach((sub) => {
-					this.logger.stationIssue(`PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(sub.name !== expiredKey)}`);
-					if (sub.name !== expiredKey) return;
-					sub.cb();
-				});
-			});
-
-			this.sub.psubscribe('__keyevent@0__:expired');
-		});
-	}
-
-	/**
-	 * 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
-	 * 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
-	 */
-	async schedule(name, time, cb, station) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!cb) cb = ()=>{};
-
-		time = Math.round(time);
-		this.logger.stationIssue(`SCHEDULE - Time: ${time}; Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}; StationId: ${station._id}; StationName: ${station.name}`);
-		this.pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
-	}
-
-	/**
-	 * 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
-	 */
-	async subscribe(name, cb, unique = false, station) {
-		try { await this._validateHook(); } catch { return; }
-
-		this.logger.stationIssue(`SUBSCRIBE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}, StationId: ${station._id}; StationName: ${station.name}; Unique: ${unique}; SubscriptionExists: ${!!subscriptions.find((subscription) => subscription.originalName == name)};`);
-		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;
-	}
-
-	/**
-	 * Remove a notification subscription
-	 *
-	 * @param {Object} subscription - the subscription object returned by {@link subscribe}
-	 */
-	async remove(subscription) {
-		try { await this._validateHook(); } catch { return; }
-
-		let index = subscriptions.indexOf(subscription);
-		if (index) subscriptions.splice(index, 1);
-	}
-
-	async unschedule(name) {
-		try { await this._validateHook(); } catch { return; }
-
-		this.logger.stationIssue(`UNSCHEDULE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}`);
-		this.pub.del(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'));
-	}
+class NotificationsModule extends CoreClass {
+    constructor() {
+        super("notifications");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            const url = (this.url = config.get("redis").url);
+            const password = (this.password = config.get("redis").password);
+
+            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.failed = true;
+                        // this._lockdown();
+
+                        return undefined;
+                    }
+
+                    return 3000;
+                },
+            });
+            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.failed = true;
+                        // this._lockdown();
+
+                        return undefined;
+                    }
+
+                    return 3000;
+                },
+            });
+
+            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.");
+
+                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}`
+                );
+                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@0__:expired");
+        });
+    }
+
+    /**
+     * 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
+     * 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
+     */
+    SCHEDULE(payload) {
+        //name, time, cb, station
+        return new Promise((resolve, reject) => {
+            const time = Math.round(payload.time);
+            this.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}`
+            );
+            this.pub.set(
+                crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex"),
+                "",
+                "PX",
+                time,
+                "NX",
+                () => {
+                    resolve();
+                }
+            );
+        });
+    }
+
+    /**
+     * 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
+     */
+    SUBSCRIBE(payload) {
+        //name, cb, unique = false, station
+        return new Promise((resolve, reject) => {
+            this.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: ${!!subscriptions.find(
+                    (subscription) => subscription.originalName === payload.name
+                )};`
+            );
+            if (
+                payload.unique &&
+                !!subscriptions.find(
+                    (subscription) => subscription.originalName === payload.name
+                )
+            )
+                return resolve({
+                    subscription: subscriptions.find(
+                        (subscription) =>
+                            subscription.originalName === payload.name
+                    ),
+                });
+            let subscription = {
+                originalName: payload.name,
+                name: crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex"),
+                cb: payload.cb,
+            };
+            subscriptions.push(subscription);
+            resolve({ subscription });
+        });
+    }
+
+    /**
+     * Remove a notification subscription
+     *
+     * @param {Object} subscription - the subscription object returned by {@link subscribe}
+     */
+    REMOVE(payload) {
+        //subscription
+        return new Promise((resolve, reject) => {
+            let index = subscriptions.indexOf(payload.subscription);
+            if (index) subscriptions.splice(index, 1);
+            resolve();
+        });
+    }
+
+    UNSCHEDULE(payload) {
+        //name
+        return new Promise((resolve, reject) => {
+            this.log(
+                "STATION_ISSUE",
+                `UNSCHEDULE - Name: ${payload.name}; Key: ${crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex")}`
+            );
+            this.pub.del(
+                crypto
+                    .createHash("md5")
+                    .update(`_notification:${payload.name}_`)
+                    .digest("hex")
+            );
+
+            resolve();
+        });
+    }
 }
+
+module.exports = new NotificationsModule();

+ 280 - 166
backend/logic/playlists.js

@@ -1,167 +1,281 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const async = require('async');
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["cache", "db", "utils"];
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.cache = this.moduleManager.modules["cache"];
-			this.db	= this.moduleManager.modules["db"];
-			this.utils	= this.moduleManager.modules["utils"];
-
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hgetall('playlists', next);
-				},
-	
-				(playlists, next) => {
-					this.setStage(3);
-					if (!playlists) return next();
-					let playlistIds = Object.keys(playlists);
-					async.each(playlistIds, (playlistId, next) => {
-						this.db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-							if (err) next(err);
-							else if (!playlist) {
-								this.cache.hdel('playlists', playlistId, next);
-							}
-							else next();
-						});
-					}, next);
-				},
-	
-				(next) => {
-					this.setStage(4);
-					this.db.models.playlist.find({}, next);
-				},
-	
-				(playlists, next) => {
-					this.setStage(5);
-					async.each(playlists, (playlist, next) => {
-						this.cache.hset('playlists', playlist._id, this.cache.schemas.playlist(playlist), next);
-					}, next);
-				}
-			], async (err) => {
-				if (err) {
-					err = await this.utils.getError(err);
-					reject(err);
-				} else {
-					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 {String} playlistId - the id of the playlist we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getPlaylist(playlistId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.cache.hgetall('playlists', next);
-			},
-
-			(playlists, next) => {
-				if (!playlists) return next();
-				let playlistIds = Object.keys(playlists);
-				async.each(playlistIds, (playlistId, next) => {
-					this.db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-						if (err) next(err);
-						else if (!playlist) {
-							this.cache.hdel('playlists', playlistId, next);
-						}
-						else next();
-					});
-				}, next);
-			},
-
-			(next) => {
-				this.cache.hget('playlists', playlistId, next);
-			},
-
-			(playlist, next) => {
-				if (playlist) return next(true, playlist);
-				this.db.models.playlist.findOne({ _id: playlistId }, next);
-			},
-
-			(playlist, next) => {
-				if (playlist) {
-					this.cache.hset('playlists', playlistId, playlist, next);
-				} else next('Playlist not found');
-			},
-
-		], (err, playlist) => {
-			if (err && err !== true) return cb(err);
-			else cb(null, playlist);
-		});
-	}
-
-	/**
-	 * 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
-	 */
-	async updatePlaylist(playlistId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.db.models.playlist.findOne({ _id: playlistId }, next);
-			},
-
-			(playlist, next) => {
-				if (!playlist) {
-					this.cache.hdel('playlists', playlistId);
-					return next('Playlist not found');
-				}
-				this.cache.hset('playlists', playlistId, playlist, next);
-			}
-
-		], (err, playlist) => {
-			if (err && err !== true) return cb(err);
-			cb(null, playlist);
-		});
-	}
-
-	/**
-	 * Deletes playlist from id from Mongo and cache
-	 *
-	 * @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
-	 */
-	async deletePlaylist(playlistId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.playlist.deleteOne({ _id: playlistId }, next);
-			},
-
-			(res, next) => {
-				this.cache.hdel('playlists', playlistId, next);
-			}
-
-		], (err) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null);
-		});
-	}
+const CoreClass = require("../core.js");
+
+const async = require("async");
+
+class ExampleModule extends CoreClass {
+    constructor() {
+        super("playlists");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.setStage(1);
+
+            this.cache = this.moduleManager.modules["cache"];
+            this.db = this.moduleManager.modules["db"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            const playlistModel = await this.db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            const playlistSchema = await this.cache.runJob("GET_SCHEMA", {
+                schemaName: "playlist",
+            });
+
+            this.setStage(2);
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(3);
+                        this.cache
+                            .runJob("HGETALL", { table: "playlists" })
+                            .then((playlists) => next(null, playlists))
+                            .catch(next);
+                    },
+
+                    (playlists, next) => {
+                        this.setStage(4);
+                        if (!playlists) return next();
+                        let playlistIds = Object.keys(playlists);
+                        async.each(
+                            playlistIds,
+                            (playlistId, next) => {
+                                playlistModel.findOne(
+                                    { _id: playlistId },
+                                    (err, playlist) => {
+                                        if (err) next(err);
+                                        else if (!playlist) {
+                                            this.cache
+                                                .runJob("HDEL", {
+                                                    table: "playlists",
+                                                    key: playlistId,
+                                                })
+                                                .then(() => next())
+                                                .catch(next);
+                                        } else next();
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.setStage(5);
+                        playlistModel.find({}, next);
+                    },
+
+                    (playlists, next) => {
+                        this.setStage(6);
+                        async.each(
+                            playlists,
+                            (playlist, next) => {
+                                this.cache
+                                    .runJob("HSET", {
+                                        table: "playlists",
+                                        key: playlist._id,
+                                        value: playlistSchema(playlist),
+                                    })
+                                    .then(() => {
+                                        next();
+                                    })
+                                    .catch(next);
+                            },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        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 {String} playlistId - the id of the playlist we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_PLAYLIST(payload) {
+        //playlistId, cb
+        return new Promise(async (resolve, reject) => {
+            const playlistModel = await this.db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGETALL", { table: "playlists" })
+                            .then((playlists) => next(null, playlists))
+                            .catch(next);
+                    },
+
+                    (playlists, next) => {
+                        if (!playlists) return next();
+                        let playlistIds = Object.keys(playlists);
+                        async.each(
+                            playlistIds,
+                            (playlistId, next) => {
+                                playlistModel.findOne(
+                                    { _id: playlistId },
+                                    (err, playlist) => {
+                                        if (err) next(err);
+                                        else if (!playlist) {
+                                            this.cache
+                                                .runJob("HDEL", {
+                                                    table: "playlists",
+                                                    key: playlistId,
+                                                })
+                                                .then(() => next())
+                                                .catch(next);
+                                        } else next();
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.cache
+                            .runJob("HGET", {
+                                table: "playlists",
+                                key: payload.playlistId,
+                            })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        if (playlist) return next(true, playlist);
+                        playlistModel.findOne(
+                            { _id: payload.playlistId },
+                            next
+                        );
+                    },
+
+                    (playlist, next) => {
+                        if (playlist) {
+                            this.cache
+                                .runJob("HSET", {
+                                    table: "playlists",
+                                    key: payload.playlistId,
+                                    value: playlist,
+                                })
+                                .then((playlist) => next(null, playlist))
+                                .catch(next);
+                        } else next("Playlist not found");
+                    },
+                ],
+                (err, playlist) => {
+                    if (err && err !== true) return reject(new Error(err));
+                    resolve(playlist);
+                }
+            );
+        });
+    }
+
+    /**
+     * 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
+     */
+    UPDATE_PLAYLIST(payload) {
+        //playlistId, cb
+        return new Promise(async (resolve, reject) => {
+            const playlistModel = await this.db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        playlistModel.findOne(
+                            { _id: payload.playlistId },
+                            next
+                        );
+                    },
+
+                    (playlist, next) => {
+                        if (!playlist) {
+                            this.cache.runJob("HDEL", {
+                                table: "playlists",
+                                key: payload.playlistId,
+                            });
+                            return next("Playlist not found");
+                        }
+                        this.cache
+                            .runJob("HSET", {
+                                table: "playlists",
+                                key: payload.playlistId,
+                                value: playlist,
+                            })
+                            .then((playlist) => next(null, playlist))
+                            .catch(next);
+                    },
+                ],
+                (err, playlist) => {
+                    if (err && err !== true) return reject(new Error(err));
+                    resolve(playlist);
+                }
+            );
+        });
+    }
+
+    /**
+     * Deletes playlist from id from Mongo and cache
+     *
+     * @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
+     */
+    DELETE_PLAYLIST(payload) {
+        //playlistId, cb
+        return new Promise(async (resolve, reject) => {
+            const playlistModel = await this.db.runJob("GET_MODEL", {
+                modelName: "playlist",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        playlistModel.deleteOne(
+                            { _id: payload.playlistId },
+                            next
+                        );
+                    },
+
+                    (res, next) => {
+                        this.cache
+                            .runJob("HDEL", {
+                                table: "playlists",
+                                key: payload.playlistId,
+                            })
+                            .then(() => next())
+                            .catch(next);
+                    },
+                ],
+                (err) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve();
+                }
+            );
+        });
+    }
 }
+
+module.exports = new ExampleModule();

+ 313 - 241
backend/logic/punishments.js

@@ -1,243 +1,315 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const async = require('async');
-const mongoose = require('mongoose');
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["cache", "db", "utils"];
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.cache = this.moduleManager.modules['cache'];
-			this.db = this.moduleManager.modules['db'];
-			this.io = this.moduleManager.modules['io'];
-			this.utils = this.moduleManager.modules['utils'];
-
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hgetall('punishments', next);
-				},
-	
-				(punishments, next) => {
-					this.setStage(3);
-					if (!punishments) return next();
-					let punishmentIds = Object.keys(punishments);
-					async.each(punishmentIds, (punishmentId, next) => {
-						this.db.models.punishment.findOne({_id: punishmentId}, (err, punishment) => {
-							if (err) next(err);
-							else if (!punishment) this.cache.hdel('punishments', punishmentId, next);
-							else next();
-						});
-					}, next);
-				},
-	
-				(next) => {
-					this.setStage(4);
-					this.db.models.punishment.find({}, next);
-				},
-	
-				(punishments, next) => {
-					this.setStage(5);
-					async.each(punishments, (punishment, next) => {
-						if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
-						this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), next);
-					}, next);
-				}
-			], async (err) => {
-				if (err) {
-					err = await utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
-
-	/**
-	 * Gets all punishments in the cache that are active, and removes those that have expired
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getPunishments(cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let punishmentsToRemove = [];
-		async.waterfall([
-			(next) => {
-				this.cache.hgetall('punishments', next);
-			},
-
-			(punishmentsObj, next) => {
-				let punishments = [];
-				for (let id in punishmentsObj) {
-					let obj = punishmentsObj[id];
-					obj.punishmentId = id;
-					punishments.push(obj);
-				}
-				punishments = punishments.filter(punishment => {
-					if (punishment.expiresAt < Date.now()) punishmentsToRemove.push(punishment);
-					return punishment.expiresAt > Date.now();
-				});
-				next(null, punishments);
-			},
-
-			(punishments, next) => {
-				async.each(
-					punishmentsToRemove,
-					(punishment, next2) => {
-						this.cache.hdel('punishments', punishment.punishmentId, () => {
-							next2();
-						});
-					},
-					() => {
-						next(null, punishments);
-					}
-				);
-			}
-		], (err, punishments) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, punishments);
-		});
-	}
-
-	/**
-	 * Gets a punishment by id
-	 *
-	 * @param {String} id - the id of the punishment we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getPunishment(id, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				this.cache.hget('punishments', id, next);
-			},
-
-			(punishment, next) => {
-				if (punishment) return next(true, punishment);
-				this.db.models.punishment.findOne({_id: id}, next);
-			},
-
-			(punishment, next) => {
-				if (punishment) {
-					this.cache.hset('punishments', id, punishment, next);
-				} else next('Punishment not found.');
-			},
-
-		], (err, punishment) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, punishment);
-		});
-	}
-
-	/**
-	 * Gets all punishments from a userId
-	 *
-	 * @param {String} userId - the userId of the punishment(s) we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getPunishmentsFromUserId(userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.getPunishments(next);
-			},
-			(punishments, next) => {
-				punishments = punishments.filter((punishment) => {
-					return punishment.type === 'banUserId' && punishment.value === userId;
-				});
-				next(null, punishments);
-			}
-		], (err, punishments) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, punishments);
-		});
-	}
-
-	async addPunishment(type, value, reason, expiresAt, punishedBy, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				const punishment = new this.db.models.punishment({
-					type,
-					value,
-					reason,
-					active: true,
-					expiresAt,
-					punishedAt: Date.now(),
-					punishedBy
-				});
-				punishment.save((err, punishment) => {
-					if (err) return next(err);
-					next(null, punishment);
-				});
-			},
-
-			(punishment, next) => {
-				this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), (err) => {
-					next(err, punishment);
-				});
-			},
-
-			(punishment, next) => {
-				// DISCORD MESSAGE
-				next(null, punishment);
-			}
-		], (err, punishment) => {
-			cb(err, punishment);
-		});
-	}
-
-	async removePunishmentFromCache(punishmentId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				const punishment = new this.db.models.punishment({
-					type,
-					value,
-					reason,
-					active: true,
-					expiresAt,
-					punishedAt: Date.now(),
-					punishedBy
-				});
-				punishment.save((err, punishment) => {
-					console.log(err);
-					if (err) return next(err);
-					next(null, punishment);
-				});
-			},
-
-			(punishment, next) => {
-				this.cache.hset('punishments', punishment._id, punishment, next);
-			},
-
-			(punishment, next) => {
-				// DISCORD MESSAGE
-				next();
-			}
-		], (err) => {
-			cb(err);
-		});
-	}
+const CoreClass = require("../core.js");
+
+const async = require("async");
+const mongoose = require("mongoose");
+
+class PunishmentsModule extends CoreClass {
+    constructor() {
+        super("punishments");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.setStage(1);
+
+            this.cache = this.moduleManager.modules["cache"];
+            this.db = this.moduleManager.modules["db"];
+            this.io = this.moduleManager.modules["io"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            const punishmentModel = await this.db.runJob("GET_MODEL", {
+                modelName: "punishment",
+            });
+
+            const punishmentSchema = await this.cache.runJob("GET_SCHEMA", {
+                schemaName: "punishment",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(2);
+                        this.cache
+                            .runJob("HGETALL", { table: "punishments" })
+                            .then((punishments) => next(null, punishments))
+                            .catch(next);
+                    },
+
+                    (punishments, next) => {
+                        this.setStage(3);
+                        if (!punishments) return next();
+                        let punishmentIds = Object.keys(punishments);
+                        async.each(
+                            punishmentIds,
+                            (punishmentId, next) => {
+                                punishmentModel.findOne(
+                                    { _id: punishmentId },
+                                    (err, punishment) => {
+                                        if (err) next(err);
+                                        else if (!punishment)
+                                            this.cache
+                                                .runJob("HDEL", {
+                                                    table: "punishments",
+                                                    key: punishmentId,
+                                                })
+                                                .then(() => next())
+                                                .catch(next);
+                                        else next();
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.setStage(4);
+                        punishmentModel.find({}, next);
+                    },
+
+                    (punishments, next) => {
+                        this.setStage(5);
+                        async.each(
+                            punishments,
+                            (punishment, next) => {
+                                if (
+                                    punishment.active === false ||
+                                    punishment.expiresAt < Date.now()
+                                )
+                                    return next();
+                                this.cache
+                                    .runJob("HSET", {
+                                        table: "punishments",
+                                        key: punishment._id,
+                                        value: punishmentSchema(
+                                            punishment,
+                                            punishment._id
+                                        ),
+                                    })
+                                    .then(() => next())
+                                    .catch(next);
+                            },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await utils.runJob("GET_ERROR", { error: err });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets all punishments in the cache that are active, and removes those that have expired
+     *
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_PUNISHMENTS() {
+        //cb
+        return new Promise((resolve, reject) => {
+            let punishmentsToRemove = [];
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGETALL", { table: "punishments" })
+                            .then((punishmentsObj) =>
+                                next(null, punishmentsObj)
+                            )
+                            .catch(next);
+                    },
+
+                    (punishmentsObj, next) => {
+                        let punishments = [];
+                        for (let id in punishmentsObj) {
+                            let obj = punishmentsObj[id];
+                            obj.punishmentId = id;
+                            punishments.push(obj);
+                        }
+                        punishments = punishments.filter((punishment) => {
+                            if (punishment.expiresAt < Date.now())
+                                punishmentsToRemove.push(punishment);
+                            return punishment.expiresAt > Date.now();
+                        });
+                        next(null, punishments);
+                    },
+
+                    (punishments, next) => {
+                        async.each(
+                            punishmentsToRemove,
+                            (punishment, next2) => {
+                                this.cache
+                                    .runJob("HDEL", {
+                                        table: "punishments",
+                                        key: punishment.punishmentId,
+                                    })
+                                    .finally(() => next2());
+                            },
+                            () => {
+                                next(null, punishments);
+                            }
+                        );
+                    },
+                ],
+                (err, punishments) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(punishments);
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a punishment by id
+     *
+     * @param {String} id - the id of the punishment we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_PUNISHMENT() {
+        //id, cb
+        return new Promise(async (resolve, reject) => {
+            const punishmentModel = await db.runJob("GET_MODEL", {
+                modelName: "punishment",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!mongoose.Types.ObjectId.isValid(payload.id))
+                            return next("Id is not a valid ObjectId.");
+                        this.cache
+                            .runJob("HGET", {
+                                table: "punishments",
+                                key: payload.id,
+                            })
+                            .then((punishment) => next(null, punishment))
+                            .catch(next);
+                    },
+
+                    (punishment, next) => {
+                        if (punishment) return next(true, punishment);
+                        punishmentModel.findOne({ _id: payload.id }, next);
+                    },
+
+                    (punishment, next) => {
+                        if (punishment) {
+                            this.cache
+                                .runJob("HSET", {
+                                    table: "punishments",
+                                    key: payload.id,
+                                    value: punishment,
+                                })
+                                .then((punishment) => next(null, punishment))
+                                .catch(next);
+                        } else next("Punishment not found.");
+                    },
+                ],
+                (err, punishment) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(punishment);
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets all punishments from a userId
+     *
+     * @param {String} userId - the userId of the punishment(s) we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_PUNISHMENTS_FROM_USER_ID(payload) {
+        //userId, cb
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.runJob("GET_PUNISHMENTS", {})
+                            .then((punishments) => next(null, punishments))
+                            .catch(next);
+                    },
+                    (punishments, next) => {
+                        punishments = punishments.filter((punishment) => {
+                            return (
+                                punishment.type === "banUserId" &&
+                                punishment.value === payload.userId
+                            );
+                        });
+                        next(null, punishments);
+                    },
+                ],
+                (err, punishments) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(punishments);
+                }
+            );
+        });
+    }
+
+    ADD_PUNISHMENT(payload) {
+        //type, value, reason, expiresAt, punishedBy, cb
+        return new Promise(async (resolve, reject) => {
+            const punishmentModel = await db.runJob("GET_MODEL", {
+                modelName: "punishment",
+            });
+
+            const punishmentSchema = await cache.runJob("GET_SCHEMA", {
+                schemaName: "punishment",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        const punishment = new 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);
+                            next(null, punishment);
+                        });
+                    },
+
+                    (punishment, next) => {
+                        this.cache
+                            .runJob("HSET", {
+                                table: "punishments",
+                                key: punishment._id,
+                                value: punishmentSchema(
+                                    punishment,
+                                    punishment._id
+                                ),
+                            })
+                            .then(() => next())
+                            .catch(next);
+                    },
+
+                    (punishment, next) => {
+                        // DISCORD MESSAGE
+                        next(null, punishment);
+                    },
+                ],
+                (err, punishment) => {
+                    if (err) return reject(new Error(err));
+                    resolve(punishment);
+                }
+            );
+        });
+    }
 }
 
+module.exports = new PunishmentsModule();

+ 258 - 175
backend/logic/songs.js

@@ -1,176 +1,259 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const async = require('async');
-const mongoose = require('mongoose');
-
-
-
-
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["utils", "cache", "db"];
-	}
-
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-
-			this.cache = this.moduleManager.modules["cache"];
-			this.db = this.moduleManager.modules["db"];
-			this.io = this.moduleManager.modules["io"];
-			this.utils = this.moduleManager.modules["utils"];
-
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hgetall('songs', next);
-				},
-	
-				(songs, next) => {
-					this.setStage(3);
-					if (!songs) return next();
-					let songIds = Object.keys(songs);
-					async.each(songIds, (songId, next) => {
-						this.db.models.song.findOne({songId}, (err, song) => {
-							if (err) next(err);
-							else if (!song) this.cache.hdel('songs', songId, next);
-							else next();
-						});
-					}, next);
-				},
-	
-				(next) => {
-					this.setStage(4);
-					this.db.models.song.find({}, next);
-				},
-	
-				(songs, next) => {
-					this.setStage(5);
-					async.each(songs, (song, next) => {
-						this.cache.hset('songs', song.songId, this.cache.schemas.song(song), next);
-					}, next);
-				}
-			], async (err) => {
-				if (err) {
-					err = await this.utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
-
-	/**
-	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
-	 *
-	 * @param {String} id - the id of the song we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getSong(id, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				this.cache.hget('songs', id, next);
-			},
-
-			(song, next) => {
-				if (song) return next(true, song);
-				this.db.models.song.findOne({_id: id}, next);
-			},
-
-			(song, next) => {
-				if (song) {
-					this.cache.hset('songs', id, song, next);
-				} else next('Song not found.');
-			},
-
-		], (err, song) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, song);
-		});
-	}
-
-	/**
-	 * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
-	 *
-	 * @param {String} songId - the mongo id of the song we are trying to get
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	async getSongFromId(songId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.db.models.song.findOne({ songId }, next);
-			}
-		], (err, song) => {
-			if (err && err !== true) return cb(err);
-			else return cb(null, song);
-		});
-	}
-
-	/**
-	 * Gets a song from id from Mongo and updates the cache with it
-	 *
-	 * @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
-	 */
-	async updateSong(songId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.song.findOne({_id: songId}, next);
-			},
-
-			(song, next) => {
-				if (!song) {
-					this.cache.hdel('songs', songId);
-					return next('Song not found.');
-				}
-
-				this.cache.hset('songs', songId, song, next);
-			}
-
-		], (err, song) => {
-			if (err && err !== true) return cb(err);
-
-			cb(null, song);
-		});
-	}
-
-	/**
-	 * Deletes song from id from Mongo and cache
-	 *
-	 * @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
-	 */
-	async deleteSong(songId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.song.deleteOne({ songId }, next);
-			},
-
-			(next) => {
-				this.cache.hdel('songs', songId, next);
-			}
-
-		], (err) => {
-			if (err && err !== true) cb(err);
-
-			cb(null);
-		});
-	}
+const CoreClass = require("../core.js");
+
+const async = require("async");
+const mongoose = require("mongoose");
+
+class SongsModule extends CoreClass {
+    constructor() {
+        super("songs");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.setStage(1);
+
+            this.cache = this.moduleManager.modules["cache"];
+            this.db = this.moduleManager.modules["db"];
+            this.io = this.moduleManager.modules["io"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            const songModel = await this.db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+
+            const songSchema = await this.cache.runJob("GET_SCHEMA", {
+                schemaName: "song",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(2);
+                        this.cache
+                            .runJob("HGETALL", { table: "songs" })
+                            .then((songs) => next(null, songs))
+                            .catch(next);
+                    },
+
+                    (songs, next) => {
+                        this.setStage(3);
+                        if (!songs) return next();
+                        let songIds = Object.keys(songs);
+                        async.each(
+                            songIds,
+                            (songId, next) => {
+                                songModel.findOne({ songId }, (err, song) => {
+                                    if (err) next(err);
+                                    else if (!song)
+                                        this.cache
+                                            .runJob("HDEL", {
+                                                table: "songs",
+                                                key: songId,
+                                            })
+                                            .then(() => next())
+                                            .catch(next);
+                                    else next();
+                                });
+                            },
+                            next
+                        );
+                    },
+
+                    (next) => {
+                        this.setStage(4);
+                        songModel.find({}, next);
+                    },
+
+                    (songs, next) => {
+                        this.setStage(5);
+                        async.each(
+                            songs,
+                            (song, next) => {
+                                this.cache
+                                    .runJob("HSET", {
+                                        table: "songs",
+                                        key: song.songId,
+                                        value: songSchema(song),
+                                    })
+                                    .then(() => next())
+                                    .catch(next);
+                            },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+     *
+     * @param {String} id - the id of the song we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_SONG(payload) {
+        //id, cb
+        return new Promise(async (resolve, reject) => {
+            const songModel = await this.db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+
+            async.waterfall(
+                [
+                    (next) => {
+                        if (!mongoose.Types.ObjectId.isValid(payload.id))
+                            return next("Id is not a valid ObjectId.");
+                        this.cache
+                            .runJob("HGET", { table: "songs", key: payload.id })
+                            .then((song) => next(null, song))
+                            .catch(next);
+                    },
+
+                    (song, next) => {
+                        if (song) return next(true, song);
+                        songModel.findOne({ _id: payload.id }, next);
+                    },
+
+                    (song, next) => {
+                        if (song) {
+                            this.cache
+                                .runJob("HSET", {
+                                    table: "songs",
+                                    key: payload.id,
+                                    value: song,
+                                })
+                                .then((song) => next(null, song));
+                        } else next("Song not found.");
+                    },
+                ],
+                (err, song) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve({ song });
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+     *
+     * @param {String} songId - the mongo id of the song we are trying to get
+     * @param {Function} cb - gets called once we're done initializing
+     */
+    GET_SONG_FROM_ID(payload) {
+        //songId, cb
+        return new Promise(async (resolve, reject) => {
+            const songModel = await this.db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        songModel.findOne({ songId: payload.songId }, next);
+                    },
+                ],
+                (err, song) => {
+                    if (err && err !== true) return reject(new Error(err));
+                    resolve({ song });
+                }
+            );
+        });
+    }
+
+    /**
+     * Gets a song from id from Mongo and updates the cache with it
+     *
+     * @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
+     */
+    UPDATE_SONG(payload) {
+        //songId, cb
+        return new Promise(async (resolve, reject) => {
+            const songModel = await this.db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        songModel.findOne({ _id: payload.songId }, next);
+                    },
+
+                    (song, next) => {
+                        if (!song) {
+                            this.cache.runJob("HDEL", {
+                                table: "songs",
+                                key: payload.songId,
+                            });
+                            return next("Song not found.");
+                        }
+
+                        this.cache
+                            .runJob("HSET", {
+                                table: "songs",
+                                key: payload.songId,
+                                value: song,
+                            })
+                            .then((song) => next(null, song))
+                            .catch(next);
+                    },
+                ],
+                (err, song) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve(song);
+                }
+            );
+        });
+    }
+
+    /**
+     * Deletes song from id from Mongo and cache
+     *
+     * @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
+     */
+    DELETE_SONG(payload) {
+        //songId, cb
+        return new Promise(async (resolve, reject) => {
+            const songModel = await this.db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        songModel.deleteOne({ songId: payload.songId }, next);
+                    },
+
+                    (next) => {
+                        this.cache
+                            .runJob("HDEL", {
+                                table: "songs",
+                                key: payload.songId,
+                            })
+                            .then(() => next())
+                            .catch(next);
+                    },
+                ],
+                (err) => {
+                    if (err && err !== true) return reject(new Error(err));
+
+                    resolve();
+                }
+            );
+        });
+    }
 }
+
+module.exports = new SongsModule();

+ 103 - 82
backend/logic/spotify.js

@@ -1,95 +1,116 @@
-const coreClass = require("../core");
+const CoreClass = require("../core.js");
 
-const config = require('config'),
-	async  = require('async');
+const config = require("config"),
+    async = require("async");
 
 let apiResults = {
-	access_token: "",
-	token_type: "",
-	expires_in: 0,
-	expires_at: 0,
-	scope: "",
+    access_token: "",
+    token_type: "",
+    expires_in: 0,
+    expires_at: 0,
+    scope: "",
 };
 
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
+class SpotifyModule extends CoreClass {
+    constructor() {
+        super("spotify");
+    }
 
-		this.dependsOn = ["cache"];
-	}
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.cache = this.moduleManager.modules["cache"];
+            this.utils = this.moduleManager.modules["utils"];
 
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
+            const client = config.get("apis.spotify.client");
+            const secret = config.get("apis.spotify.secret");
 
-			this.cache = this.moduleManager.modules["cache"];
-			this.utils = this.moduleManager.modules["utils"];
+            const OAuth2 = require("oauth").OAuth2;
+            this.SpotifyOauth = new OAuth2(
+                client,
+                secret,
+                "https://accounts.spotify.com/",
+                null,
+                "api/token",
+                null
+            );
 
-			const client = config.get("apis.spotify.client");
-			const secret = config.get("apis.spotify.secret");
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(2);
+                        this.cache
+                            .runJob("HGET", { table: "api", key: "spotify" })
+                            .then((data) => next(null, data))
+                            .catch(next);
+                    },
 
-			const OAuth2 = require('oauth').OAuth2;
-			this.SpotifyOauth = new OAuth2(
-				client,
-				secret, 
-				'https://accounts.spotify.com/', 
-				null,
-				'api/token',
-				null);
+                    (data, next) => {
+                        this.setStage(3);
+                        if (data) apiResults = data;
+                        next();
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
 
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hget("api", "spotify", next, true);
-				},
-	
-				(data, next) => {
-					this.setStage(3);
-					if (data) apiResults = data;
-					next();
-				}
-			], async (err) => {
-				if (err) {
-					err = await this.utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
+    GET_TOKEN(payload) {
+        return new Promise((resolve, reject) => {
+            if (Date.now() > apiResults.expires_at) {
+                this.runJob("REQUEST_TOKEN").then(() => {
+                    resolve(apiResults.access_token);
+                });
+            } else resolve(apiResults.access_token);
+        });
+    }
 
-	async getToken() {
-		try { await this._validateHook(); } catch { return; }
-
-		return new Promise((resolve, reject) => {
-			if (Date.now() > apiResults.expires_at) {
-				this.requestToken(() => {
-					resolve(apiResults.access_token);
-				});
-			} else resolve(apiResults.access_token);
-		});
-	}
-
-	async requestToken(cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.logger.info("SPOTIFY_REQUEST_TOKEN", "Requesting new Spotify token.");
-				this.SpotifyOauth.getOAuthAccessToken(
-					'',
-					{ 'grant_type': 'client_credentials' },
-					next
-				);
-			},
-			(access_token, refresh_token, results, next) => {
-				apiResults = results;
-				apiResults.expires_at = Date.now() + (results.expires_in * 1000);
-				this.cache.hset("api", "spotify", apiResults, next, true);
-			}
-		], () => {
-			cb();
-		});
-	}
+    REQUEST_TOKEN(payload) {
+        //cb
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.log(
+                            "INFO",
+                            "SPOTIFY_REQUEST_TOKEN",
+                            "Requesting new Spotify token."
+                        );
+                        this.SpotifyOauth.getOAuthAccessToken(
+                            "",
+                            { grant_type: "client_credentials" },
+                            next
+                        );
+                    },
+                    (access_token, refresh_token, results, next) => {
+                        apiResults = results;
+                        apiResults.expires_at =
+                            Date.now() + results.expires_in * 1000;
+                        this.cache
+                            .runJob("HSET", {
+                                table: "api",
+                                key: "spotify",
+                                value: apiResults,
+                                stringifyJson: true,
+                            })
+                            .finally(() => next());
+                    },
+                ],
+                () => {
+                    resolve();
+                }
+            );
+        });
+    }
 }
+
+module.exports = new SpotifyModule();

+ 1167 - 533
backend/logic/stations.js

@@ -1,537 +1,1171 @@
-'use strict';
+const CoreClass = require("../core.js");
 
-const coreClass = require("../core");
-
-const async = require('async');
+const async = require("async");
 
 let subscription = null;
 
-module.exports = class extends coreClass {
-	constructor(name, moduleManager) {
-		super(name, moduleManager);
-
-		this.dependsOn = ["cache", "db", "utils"];
-	}
-
-	initialize() {
-		return new Promise(async (resolve, reject) => {
-			this.setStage(1);
-
-			this.cache = this.moduleManager.modules["cache"];
-			this.db = this.moduleManager.modules["db"];
-			this.utils = this.moduleManager.modules["utils"];
-			this.songs = this.moduleManager.modules["songs"];
-			this.notifications = this.moduleManager.modules["notifications"];
-
-			this.defaultSong = {
-				songId: '60ItHLz5WEA',
-				title: 'Faded - Alan Walker',
-				duration: 212,
-				skipDuration: 0,
-				likes: -1,
-				dislikes: -1
-			};
-
-			//TEMP
-			this.cache.sub('station.pause', async (stationId) => {
-				try { await this._validateHook(); } catch { return; }
-
-				this.notifications.remove(`stations.nextSong?id=${stationId}`);
-			});
-
-			this.cache.sub('station.resume', async (stationId) => {
-				try { await this._validateHook(); } catch { return; }
-
-				this.initializeStation(stationId)
-			});
-
-			this.cache.sub('station.queueUpdate', async (stationId) => {
-				try { await this._validateHook(); } catch { return; }
-
-				this.getStation(stationId, (err, station) => {
-					if (!station.currentSong && station.queue.length > 0) {
-						this.initializeStation(stationId);
-					}
-				});
-			});
-
-			this.cache.sub('station.newOfficialPlaylist', async (stationId) => {
-				try { await this._validateHook(); } catch { return; }
-
-				this.cache.hget("officialPlaylists", stationId, (err, playlistObj) => {
-					if (!err && playlistObj) {
-						this.utils.emitToRoom(`station.${stationId}`, "event:newOfficialPlaylist", playlistObj.songs);
-					}
-				})
-			});
-
-
-			async.waterfall([
-				(next) => {
-					this.setStage(2);
-					this.cache.hgetall('stations', next);
-				},
-	
-				(stations, next) => {
-					this.setStage(3);
-					if (!stations) return next();
-					let stationIds = Object.keys(stations);
-					async.each(stationIds, (stationId, next) => {
-						this.db.models.station.findOne({_id: stationId}, (err, station) => {
-							if (err) next(err);
-							else if (!station) {
-								this.cache.hdel('stations', stationId, next);
-							} else next();
-						});
-					}, next);
-				},
-	
-				(next) => {
-					this.setStage(4);
-					this.db.models.station.find({}, next);
-				},
-	
-				(stations, next) => {
-					this.setStage(5);
-					async.each(stations, (station, next2) => {
-						async.waterfall([
-							(next) => {
-								this.cache.hset('stations', station._id, this.cache.schemas.station(station), next);
-							},
-	
-							(station, next) => {
-								this.initializeStation(station._id, () => {
-									next()
-								}, true);
-							}
-						], (err) => {
-							next2(err);
-						});
-					}, next);
-				}
-			], async (err) => {
-				if (err) {
-					err = await this.utils.getError(err);
-					reject(err);
-				} else {
-					resolve();
-				}
-			});
-		});
-	}
-
-	async initializeStation(stationId, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		if (typeof cb !== 'function') cb = ()=>{};
-
-		async.waterfall([
-			(next) => {
-				this.getStation(stationId, next, true);
-			},
-			(station, next) => {
-				if (!station) return next('Station not found.');
-				this.notifications.unschedule(`stations.nextSong?id=${station._id}`);
-				subscription = this.notifications.subscribe(`stations.nextSong?id=${station._id}`, this.skipStation(station._id), true, station);
-				if (station.paused) return next(true, station);
-				next(null, station);
-			},
-			(station, next) => {
-				if (!station.currentSong) {
-					return this.skipStation(station._id)((err, station) => {
-						if (err) return next(err);
-						return next(true, station);
-					}, true);
-				}
-				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) => {
-						next(err, station);
-					}, true);
-				} else {
-					this.notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, null, station);
-					next(null, station);
-				}
-			}
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
-		});
-	}
-
-	async calculateSongForStation(station, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		let songList = [];
-		async.waterfall([
-			(next) => {
-				if (station.genres.length === 0) return next();
-				let genresDone = [];
-				station.genres.forEach((genre) => {
-					this.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);
-									}
-								}
-							});
-						}
-						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);
-				});
-
-				this.utils.shuffle(playlist).then((playlist) => {
-					next(null, playlist);
-				});
-			},
-
-			(playlist, next) => {
-				this.calculateOfficialPlaylistList(station._id, playlist, () => {
-					next(null, playlist);
-				}, true);
-			},
-
-			(playlist, next) => {
-				this.db.models.station.updateOne({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
-					this.updateStation(station._id, () => {
-						next(err, playlist);
-					}, true);
-				});
-			}
-
-		], (err, newPlaylist) => {
-			cb(err, newPlaylist);
-		});
-	}
-
-	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	async getStation(stationId, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				this.cache.hget('stations', stationId, next);
-			},
-
-			(station, next) => {
-				if (station) return next(true, station);
-				this.db.models.station.findOne({ _id: stationId }, next);
-			},
-
-			(station, next) => {
-				if (station) {
-					if (station.type === 'official') {
-						this.calculateOfficialPlaylistList(station._id, station.playlist, () => {});
-					}
-					station = this.cache.schemas.station(station);
-					this.cache.hset('stations', stationId, station);
-					next(true, station);
-				} else next('Station not found');
-			},
-
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
-		});
-	}
-
-	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	async getStationByName(stationName, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.station.findOne({ name: stationName }, next);
-			},
-
-			(station, next) => {
-				if (station) {
-					if (station.type === 'official') {
-						this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
-					}
-					station = this.cache.schemas.station(station);
-					this.cache.hset('stations', station._id, station);
-					next(true, station);
-				} else next('Station not found');
-			},
-
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
-		});
-	}
-
-	async updateStation(stationId, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-
-			(next) => {
-				this.db.models.station.findOne({ _id: stationId }, next);
-			},
-
-			(station, next) => {
-				if (!station) {
-					this.cache.hdel('stations', stationId);
-					return next('Station not found');
-				}
-				this.cache.hset('stations', stationId, station, next);
-			}
-
-		], (err, station) => {
-			if (err && err !== true) return cb(err);
-			cb(null, station);
-		});
-	}
-
-	async calculateOfficialPlaylistList(stationId, songList, cb, bypassValidate = false) {
-		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-
-		let lessInfoPlaylist = [];
-		async.each(songList, (song, next) => {
-			this.songs.getSong(song, (err, song) => {
-				if (!err && song) {
-					let newSong = {
-						songId: song.songId,
-						title: song.title,
-						artists: song.artists,
-						duration: song.duration
-					};
-					lessInfoPlaylist.push(newSong);
-				}
-				next();
-			});
-		}, () => {
-			this.cache.hset("officialPlaylists", stationId, this.cache.schemas.officialPlaylist(stationId, lessInfoPlaylist), () => {
-				this.cache.pub("station.newOfficialPlaylist", stationId);
-				cb();
-			});
-		});
-	}
-
-	skipStation(stationId) {
-		this.logger.info("STATION_SKIP", `Skipping station ${stationId}.`, false);
-		return async (cb, bypassValidate = false) => {
-			if (!bypassValidate) try { await this._validateHook(); } catch { return; }
-			this.logger.stationIssue(`SKIP_STATION_CB - Station ID: ${stationId}.`);
-
-			if (typeof cb !== 'function') cb = ()=>{};
-
-			async.waterfall([
-				(next) => {
-					this.getStation(stationId, next, true);
-				},
-				(station, next) => {
-					if (!station) return next('Station not found.');
-					if (station.type === 'community' && station.partyMode && station.queue.length === 0) return next(null, null, -11, 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
-						return this.db.models.station.updateOne({_id: stationId}, {$pull: {queue: {_id: station.queue[0]._id}}}, (err) => {
-							if (err) return next(err);
-							next(null, station.queue[0], -12, station);
-						});
-					}
-					if (station.type === 'community' && !station.partyMode) {
-						return this.db.models.playlist.findOne({_id: station.privatePlaylist}, (err, playlist) => {
-							if (err) return next(err);
-							if (!playlist) return next(null, null, -13, station);
-							playlist = playlist.songs;
-							if (playlist.length > 0) {
-								let currentSongIndex;
-								if (station.currentSongIndex < playlist.length - 1) currentSongIndex = station.currentSongIndex + 1;
-								else currentSongIndex = 0;
-								let callback = (err, song) => {
-									if (err) return next(err);
-									if (song) return next(null, song, currentSongIndex, station);
-									else {
-										let song = playlist[currentSongIndex];
-										let currentSong = {
-											songId: song.songId,
-											title: song.title,
-											duration: song.duration,
-											likes: -1,
-											dislikes: -1
-										};
-										return next(null, currentSong, currentSongIndex, station);
-									}
-								};
-								if (playlist[currentSongIndex]._id) this.songs.getSong(playlist[currentSongIndex]._id, callback);
-								else this.songs.getSongFromId(playlist[currentSongIndex].songId, callback);
-							} else return next(null, null, -14, station);
-						});
-					}
-					if (station.type === 'official' && station.playlist.length === 0) {
-						return this.calculateSongForStation(station, (err, playlist) => {
-							if (err) return next(err);
-							if (playlist.length === 0) return next(null, this.defaultSong, 0, station);
-							else {
-								this.songs.getSong(playlist[0], (err, song) => {
-									if (err || !song) return next(null, this.defaultSong, 0, station);
-									return next(null, song, 0, station);
-								});
-							}
-						}, true);
-					}
-					if (station.type === 'official' && station.playlist.length > 0) {
-						async.doUntil((next) => {
-							if (station.currentSongIndex < station.playlist.length - 1) {
-								this.songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
-									if (!err) return next(null, song, station.currentSongIndex + 1);
-									else {
-										station.currentSongIndex++;
-										next(null, null, null);
-									}
-								});
-							} else {
-								this.calculateSongForStation(station, (err, newPlaylist) => {
-									if (err) return next(null, this.defaultSong, 0);
-									this.songs.getSong(newPlaylist[0], (err, song) => {
-										if (err || !song) return next(null, this.defaultSong, 0);
-										station.playlist = newPlaylist;
-										next(null, song, 0);
-									});
-								}, true);
-							}
-						}, (song, currentSongIndex, next) => {
-							if (!!song) return next(null, true, currentSongIndex);
-							else return next(null, false);
-						}, (err, song, currentSongIndex) => {
-							return next(err, song, currentSongIndex, station);
-						});
-					}
-				},
-				(song, currentSongIndex, station, next) => {
-					let $set = {};
-					if (song === null) $set.currentSong = null;
-					else if (song.likes === -1 && song.dislikes === -1) {
-						$set.currentSong = {
-							songId: song.songId,
-							title: song.title,
-							duration: song.duration,
-							skipDuration: 0,
-							likes: -1,
-							dislikes: -1
-						};
-					} else {
-						$set.currentSong = {
-							songId: song.songId,
-							title: song.title,
-							artists: song.artists,
-							duration: song.duration,
-							likes: song.likes,
-							dislikes: song.dislikes,
-							skipDuration: song.skipDuration,
-							thumbnail: song.thumbnail
-						};
-					}
-					if (currentSongIndex >= 0) $set.currentSongIndex = currentSongIndex;
-					$set.startedAt = Date.now();
-					$set.timePaused = 0;
-					if (station.paused) $set.pausedAt = Date.now();
-					next(null, $set, station);
-				},
-
-				($set, station, next) => {
-					this.db.models.station.updateOne({_id: station._id}, {$set}, (err) => {
-						this.updateStation(station._id, (err, station) => {
-							if (station.type === 'community' && station.partyMode === true)
-								this.cache.pub('station.queueUpdate', stationId);
-							next(null, station);
-						}, true);
-					});
-				},
-			], async (err, station) => {
-				if (!err) {
-					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
-						station.currentSong.skipVotes = 0;
-					}
-					//TODO Pub/Sub this
-					this.utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
-						currentSong: station.currentSong,
-						startedAt: station.startedAt,
-						paused: station.paused,
-						timePaused: 0
-					});
-
-					if (station.privacy === 'public') this.utils.emitToRoom('home', "event:station.nextSong", station._id, station.currentSong);
-					else {
-						let sockets = await this.utils.getRoomSockets('home');
-						for (let socketId in sockets) {
-							let socket = sockets[socketId];
-							let session = sockets[socketId].session;
-							if (session.sessionId) {
-								this.cache.hget('sessions', session.sessionId, (err, session) => {
-									if (!err && session) {
-										this.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);
-											}
-										});
-									}
-								});
-							}
-						}
-					}
-					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
-						this.utils.socketsJoinSongRoom(await this.utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong.songId}`);
-						if (!station.paused) {
-							this.notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000, null, station);
-						}
-					} else {
-						this.utils.socketsLeaveSongRooms(await this.utils.getRoomSockets(`station.${station._id}`));
-					}
-					cb(null, station);
-				} else {
-					err = await this.utils.getError(err);
-					this.logger.error('SKIP_STATION', `Skipping station "${stationId}" failed. "${err}"`);
-					cb(err);
-				}
-			});
-		}
-	}
-
-	async canUserViewStation(station, userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-		async.waterfall([
-			(next) => {
-				if (station.privacy !== 'private') return next(true);
-				if (!userId) return next("Not allowed");
-				next();
-			},
-			
-			(next) => {
-				this.db.models.user.findOne({_id: userId}, next);
-			},
-			
-			(user, next) => {
-				if (!user) return next("Not allowed");
-				if (user.role === 'admin') return next(true);
-				if (station.type === 'official') return next("Not allowed");
-				if (station.owner === userId) return next(true);
-				next("Not allowed");
-			}
-		], async (errOrResult) => {
-			if (errOrResult === true || errOrResult === "Not allowed") return cb(null, (errOrResult === true) ? true : false);
-			cb(await this.utils.getError(errOrResult));
-		});
-	}
-}
+class StationsModule extends CoreClass {
+    constructor() {
+        super("stations");
+    }
+
+    initialize() {
+        return new Promise(async (resolve, reject) => {
+            this.cache = this.moduleManager.modules["cache"];
+            this.db = this.moduleManager.modules["db"];
+            this.utils = this.moduleManager.modules["utils"];
+            this.songs = this.moduleManager.modules["songs"];
+            this.notifications = this.moduleManager.modules["notifications"];
+
+            this.defaultSong = {
+                songId: "60ItHLz5WEA",
+                title: "Faded - Alan Walker",
+                duration: 212,
+                skipDuration: 0,
+                likes: -1,
+                dislikes: -1,
+            };
+
+            //TEMP
+            this.cache.runJob("SUB", {
+                channel: "station.pause",
+                cb: async (stationId) => {
+                    this.notifications
+                        .runJob("REMOVE", {
+                            subscription: `stations.nextSong?id=${stationId}`,
+                        })
+                        .then();
+                },
+            });
+
+            this.cache.runJob("SUB", {
+                channel: "station.resume",
+                cb: async (stationId) => {
+                    this.runJob("INITIALIZE_STATION", { stationId }).then();
+                },
+            });
+
+            this.cache.runJob("SUB", {
+                channel: "station.queueUpdate",
+                cb: async (stationId) => {
+                    this.runJob("GET_STATION", { stationId }).then(
+                        (station) => {
+                            if (
+                                !station.currentSong &&
+                                station.queue.length > 0
+                            ) {
+                                this.runJob("INITIALIZE_STATION", {
+                                    stationId,
+                                }).then();
+                            }
+                        }
+                    );
+                },
+            });
+
+            this.cache.runJob("SUB", {
+                channel: "station.newOfficialPlaylist",
+                cb: async (stationId) => {
+                    this.cache
+                        .runJob("HGET", {
+                            table: "officialPlaylists",
+                            key: stationId,
+                        })
+                        .then((playlistObj) => {
+                            if (playlistObj) {
+                                this.utils.runJob("EMIT_TO_ROOM", {
+                                    room: `station.${stationId}`,
+                                    args: [
+                                        "event:newOfficialPlaylist",
+                                        playlistObj.songs,
+                                    ],
+                                });
+                            }
+                        });
+                },
+            });
+
+            const stationModel = (this.stationModel = await this.db.runJob(
+                "GET_MODEL",
+                {
+                    modelName: "station",
+                }
+            ));
+
+            const stationSchema = (this.stationSchema = await this.cache.runJob(
+                "GET_SCHEMA",
+                {
+                    schemaName: "station",
+                }
+            ));
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.setStage(2);
+                        this.cache
+                            .runJob("HGETALL", { table: "stations" })
+                            .then((stations) => next(null, stations))
+                            .catch(next);
+                    },
+
+                    (stations, next) => {
+                        this.setStage(3);
+                        if (!stations) return next();
+                        let stationIds = Object.keys(stations);
+                        async.each(
+                            stationIds,
+                            (stationId, next) => {
+                                stationModel.findOne(
+                                    { _id: stationId },
+                                    (err, station) => {
+                                        if (err) next(err);
+                                        else if (!station) {
+                                            this.cache
+                                                .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) => {
+                                            this.cache
+                                                .runJob("HSET", {
+                                                    table: "stations",
+                                                    key: station._id,
+                                                    value: stationSchema(
+                                                        station
+                                                    ),
+                                                })
+                                                .then((station) =>
+                                                    next(null, station)
+                                                )
+                                                .catch(next);
+                                        },
+
+                                        (station, next) => {
+                                            this.runJob(
+                                                "INITIALIZE_STATION",
+                                                {
+                                                    stationId: station._id,
+                                                    bypassQueue: true,
+                                                },
+                                                { bypassQueue: true }
+                                            )
+                                                .then(() => next())
+                                                .catch(next); // bypassQueue is true because otherwise the module will never initialize
+                                        },
+                                    ],
+                                    (err) => {
+                                        next2(err);
+                                    }
+                                );
+                            },
+                            next
+                        );
+                    },
+                ],
+                async (err) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else {
+                        resolve();
+                    }
+                }
+            );
+        });
+    }
+
+    INITIALIZE_STATION(payload) {
+        //stationId, cb, bypassValidate = false
+        return new Promise((resolve, reject) => {
+            // if (typeof cb !== 'function') cb = ()=>{};
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.runJob(
+                            "GET_STATION",
+                            {
+                                stationId: payload.stationId,
+                                bypassQueue: payload.bypassQueue,
+                            },
+                            { bypassQueue: payload.bypassQueue }
+                        )
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                    (station, next) => {
+                        if (!station) return next("Station not found.");
+                        this.notifications
+                            .runJob("UNSCHEDULE", {
+                                subscription: `stations.nextSong?id=${station._id}`,
+                            })
+                            .then()
+                            .catch();
+                        this.notifications
+                            .runJob("SUBSCRIBE", {
+                                subscription: `stations.nextSong?id=${station._id}`,
+                                cb: () =>
+                                    this.runJob("SKIP_STATION", {
+                                        stationId: station._id,
+                                    }),
+                                unique: true,
+                                station,
+                            })
+                            .then()
+                            .catch();
+                        if (station.paused) return next(true, station);
+                        next(null, station);
+                    },
+                    (station, next) => {
+                        if (!station.currentSong) {
+                            return this.runJob(
+                                "SKIP_STATION",
+                                {
+                                    stationId: station._id,
+                                    bypassQueue: payload.bypassQueue,
+                                },
+                                { bypassQueue: payload.bypassQueue }
+                            )
+                                .then((station) => next(true, station))
+                                .catch(next)
+                                .finally(() => {});
+                        }
+                        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.runJob(
+                                "SKIP_STATION",
+                                {
+                                    stationId: station._id,
+                                    bypassQueue: payload.bypassQueue,
+                                },
+                                { bypassQueue: payload.bypassQueue }
+                            )
+                                .then((station) => next(null, station))
+                                .catch(next);
+                        } else {
+                            //name, time, cb, station
+                            this.notifications.runJob("SCHEDULE", {
+                                name: `stations.nextSong?id=${station._id}`,
+                                time: timeLeft,
+                                station,
+                            });
+                            next(null, station);
+                        }
+                    },
+                ],
+                async (err, station) => {
+                    if (err && err !== true) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else resolve(station);
+                }
+            );
+        });
+    }
+
+    CALCULATE_SONG_FOR_STATION(payload) {
+        //station, cb, bypassValidate = false
+        return new Promise(async (resolve, reject) => {
+            const songModel = await this.db.runJob("GET_MODEL", {
+                modelName: "song",
+            });
+            const stationModel = await this.db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+
+            let songList = [];
+            async.waterfall(
+                [
+                    (next) => {
+                        if (payload.station.genres.length === 0) return next();
+                        let genresDone = [];
+                        payload.station.genres.forEach((genre) => {
+                            songModel.find({ genres: genre }, (err, songs) => {
+                                if (!err) {
+                                    songs.forEach((song) => {
+                                        if (songList.indexOf(song._id) === -1) {
+                                            let found = false;
+                                            song.genres.forEach((songGenre) => {
+                                                if (
+                                                    payload.station.blacklistedGenres.indexOf(
+                                                        songGenre
+                                                    ) !== -1
+                                                )
+                                                    found = true;
+                                            });
+                                            if (!found) {
+                                                songList.push(song._id);
+                                            }
+                                        }
+                                    });
+                                }
+                                genresDone.push(genre);
+                                if (
+                                    genresDone.length ===
+                                    payload.station.genres.length
+                                )
+                                    next();
+                            });
+                        });
+                    },
+
+                    (next) => {
+                        let playlist = [];
+                        songList.forEach(function(songId) {
+                            if (payload.station.playlist.indexOf(songId) === -1)
+                                playlist.push(songId);
+                        });
+                        payload.station.playlist.filter((songId) => {
+                            if (songList.indexOf(songId) !== -1)
+                                playlist.push(songId);
+                        });
+
+                        this.utils
+                            .runJob("SHUFFLE", { array: playlist })
+                            .then((result) => next(null, result.array))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        this.runJob(
+                            "CALCULATE_OFFICIAL_PLAYLIST_LIST",
+                            {
+                                stationId: payload.stationId,
+                                songList: playlist,
+                                bypassQueue: payload.bypassQueue,
+                            },
+                            { bypassQueue: payload.bypassQueue }
+                        )
+                            .then(() => next(null, playlist))
+                            .catch(next);
+                    },
+
+                    (playlist, next) => {
+                        stationModel.updateOne(
+                            { _id: payload.station._id },
+                            { $set: { playlist: playlist } },
+                            { runValidators: true },
+                            (err) => {
+                                this.runJob(
+                                    "UPDATE_STATION",
+                                    {
+                                        stationId: payload.station._id,
+                                        bypassQueue: payload.bypassQueue,
+                                    },
+                                    { bypassQueue: payload.bypassQueue }
+                                )
+                                    .then(() => next(null, playlist))
+                                    .catch(next);
+                            }
+                        );
+                    },
+                ],
+                (err, newPlaylist) => {
+                    if (err) return reject(new Error(err));
+                    resolve(newPlaylist);
+                }
+            );
+        });
+    }
+
+    // Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
+    GET_STATION(payload) {
+        //stationId, cb, bypassValidate = false
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGET", {
+                                table: "stations",
+                                key: payload.stationId,
+                            })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+
+                    (station, next) => {
+                        if (station) return next(true, station);
+                        this.stationModel.findOne(
+                            { _id: payload.stationId },
+                            next
+                        );
+                    },
+
+                    (station, next) => {
+                        if (station) {
+                            if (station.type === "official") {
+                                this.runJob(
+                                    "CALCULATE_OFFICIAL_PLAYLIST_LIST",
+                                    {
+                                        stationId: station._id,
+                                        songList: station.playlist,
+                                    }
+                                )
+                                    .then()
+                                    .catch();
+                            }
+                            station = this.stationSchema(station);
+                            this.cache
+                                .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 this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        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.
+    GET_STATION_BY_NAME(payload) {
+        //stationName, cb
+        return new Promise(async (resolve, reject) => {
+            const stationModel = await this.db.runJob("GET_MODEL", {
+                modelName: "station",
+            });
+            async.waterfall(
+                [
+                    (next) => {
+                        stationModel.findOne(
+                            { name: payload.stationName },
+                            next
+                        );
+                    },
+
+                    (station, next) => {
+                        if (station) {
+                            if (station.type === "official") {
+                                this.runJob(
+                                    "CALCULATE_OFFICIAL_PLAYLIST_LIST",
+                                    {
+                                        stationId: station._id,
+                                        songList: station.playlist,
+                                    }
+                                );
+                            }
+                            this.cache
+                                .runJob("GET_SCHEMA", { schemaName: "station" })
+                                .then((stationSchema) => {
+                                    station = stationSchema(station);
+                                    this.cache.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));
+                    resolve(station);
+                }
+            );
+        });
+    }
+
+    UPDATE_STATION(payload) {
+        //stationId, cb, bypassValidate = false
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        this.stationModel.findOne(
+                            { _id: payload.stationId },
+                            next
+                        );
+                    },
+
+                    (station, next) => {
+                        if (!station) {
+                            this.cache
+                                .runJob("HDEL", {
+                                    table: "stations",
+                                    key: payload.stationId,
+                                })
+                                .then()
+                                .catch();
+                            return next("Station not found");
+                        }
+                        this.cache
+                            .runJob("HSET", {
+                                table: "stations",
+                                key: payload.stationId,
+                                value: station,
+                            })
+                            .then((station) => next(null, station))
+                            .catch(next);
+                    },
+                ],
+                async (err, station) => {
+                    if (err && err !== true) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        reject(new Error(err));
+                    } else resolve(station);
+                }
+            );
+        });
+    }
+
+    CALCULATE_OFFICIAL_PLAYLIST_LIST(payload) {
+        //stationId, songList, cb, bypassValidate = false
+        return new Promise(async (resolve, reject) => {
+            const officialPlaylistSchema = await this.cache.runJob(
+                "GET_SCHEMA",
+                {
+                    schemaName: "officialPlaylist",
+                }
+            );
+
+            let lessInfoPlaylist = [];
+            async.each(
+                payload.songList,
+                (song, next) => {
+                    this.songs
+                        .runJob("GET_SONG", { id: song })
+                        .then((response) => {
+                            const song = response.song;
+                            if (song) {
+                                let newSong = {
+                                    songId: song.songId,
+                                    title: song.title,
+                                    artists: song.artists,
+                                    duration: song.duration,
+                                };
+                                lessInfoPlaylist.push(newSong);
+                            }
+                        })
+                        .finally(() => {
+                            next();
+                        });
+                },
+                () => {
+                    this.cache
+                        .runJob("HSET", {
+                            table: "officialPlaylists",
+                            key: payload.stationId,
+                            value: officialPlaylistSchema(
+                                payload.stationId,
+                                lessInfoPlaylist
+                            ),
+                        })
+                        .finally(() => {
+                            this.cache.runJob("PUB", {
+                                channel: "station.newOfficialPlaylist",
+                                value: payload.stationId,
+                            });
+                            resolve();
+                        });
+                }
+            );
+        });
+    }
+
+    SKIP_STATION(payload) {
+        //stationId
+        return new Promise((resolve, reject) => {
+            this.log("INFO", `Skipping station ${payload.stationId}.`);
+
+            this.log(
+                "STATION_ISSUE",
+                `SKIP_STATION_CB - Station ID: ${payload.stationId}.`
+            );
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.runJob(
+                            "GET_STATION",
+                            {
+                                stationId: payload.stationId,
+                                bypassQueue: payload.bypassQueue,
+                            },
+                            { bypassQueue: payload.bypassQueue }
+                        )
+                            .then((station) => {
+                                next(null, station);
+                            })
+                            .catch(() => {});
+                    },
+                    (station, next) => {
+                        if (!station) return next("Station not found.");
+
+                        if (
+                            station.type === "community" &&
+                            station.partyMode &&
+                            station.queue.length === 0
+                        )
+                            return next(null, null, -11, 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, -19, station);
+                            } else {
+                                return this.stationModel.updateOne(
+                                    { _id: payload.stationId },
+                                    {
+                                        $pull: {
+                                            queue: {
+                                                _id: station.queue[0]._id,
+                                            },
+                                        },
+                                    },
+                                    (err) => {
+                                        if (err) return next(err);
+                                        next(
+                                            null,
+                                            station.queue[0],
+                                            -12,
+                                            station
+                                        );
+                                    }
+                                );
+                            }
+                        }
+                        if (
+                            station.type === "community" &&
+                            !station.partyMode
+                        ) {
+                            this.db
+                                .runJob("GET_MODEL", { modelName: "playlist" })
+                                .then((playlistModel) => {
+                                    return playlistModel.findOne(
+                                        { _id: station.privatePlaylist },
+                                        (err, playlist) => {
+                                            if (err) return next(err);
+                                            if (!playlist)
+                                                return next(
+                                                    null,
+                                                    null,
+                                                    -13,
+                                                    station
+                                                );
+                                            playlist = playlist.songs;
+                                            if (playlist.length > 0) {
+                                                let currentSongIndex;
+                                                if (
+                                                    station.currentSongIndex <
+                                                    playlist.length - 1
+                                                )
+                                                    currentSongIndex =
+                                                        station.currentSongIndex +
+                                                        1;
+                                                else currentSongIndex = 0;
+                                                let callback = (err, song) => {
+                                                    if (err) return next(err);
+                                                    if (song)
+                                                        return next(
+                                                            null,
+                                                            song,
+                                                            currentSongIndex,
+                                                            station
+                                                        );
+                                                    else {
+                                                        let song =
+                                                            playlist[
+                                                                currentSongIndex
+                                                            ];
+                                                        let currentSong = {
+                                                            songId: song.songId,
+                                                            title: song.title,
+                                                            duration:
+                                                                song.duration,
+                                                            likes: -1,
+                                                            dislikes: -1,
+                                                        };
+                                                        return next(
+                                                            null,
+                                                            currentSong,
+                                                            currentSongIndex,
+                                                            station
+                                                        );
+                                                    }
+                                                };
+                                                if (
+                                                    playlist[currentSongIndex]
+                                                        ._id
+                                                )
+                                                    this.songs
+                                                        .runJob("GET_SONG", {
+                                                            id:
+                                                                playlist[
+                                                                    currentSongIndex
+                                                                ]._id,
+                                                        })
+                                                        .then((response) =>
+                                                            callback(
+                                                                null,
+                                                                response.song
+                                                            )
+                                                        )
+                                                        .catch(callback);
+                                                else
+                                                    this.songs
+                                                        .runJob(
+                                                            "GET_SONG_FROM_ID",
+                                                            {
+                                                                songId:
+                                                                    playlist[
+                                                                        currentSongIndex
+                                                                    ].songId,
+                                                            }
+                                                        )
+                                                        .then((response) =>
+                                                            callback(
+                                                                null,
+                                                                response.song
+                                                            )
+                                                        )
+                                                        .catch(callback);
+                                            } else
+                                                return next(
+                                                    null,
+                                                    null,
+                                                    -14,
+                                                    station
+                                                );
+                                        }
+                                    );
+                                });
+                        }
+                        if (
+                            station.type === "official" &&
+                            station.playlist.length === 0
+                        ) {
+                            return this.runJob(
+                                "CALCULATE_SONG_FOR_STATION",
+                                { station, bypassQueue: payload.bypassQueue },
+                                { bypassQueue: payload.bypassQueue }
+                            )
+                                .then((playlist) => {
+                                    if (playlist.length === 0)
+                                        return next(
+                                            null,
+                                            this.defaultSong,
+                                            0,
+                                            station
+                                        );
+                                    else {
+                                        this.songs
+                                            .runJob("GET_SONG", {
+                                                id: playlist[0],
+                                            })
+                                            .then((response) => {
+                                                next(
+                                                    null,
+                                                    response.song,
+                                                    0,
+                                                    station
+                                                );
+                                            })
+                                            .catch((err) => {
+                                                return next(
+                                                    null,
+                                                    this.defaultSong,
+                                                    0,
+                                                    station
+                                                );
+                                            });
+                                    }
+                                })
+                                .catch(next);
+                        }
+                        if (
+                            station.type === "official" &&
+                            station.playlist.length > 0
+                        ) {
+                            async.doUntil(
+                                (next) => {
+                                    if (
+                                        station.currentSongIndex <
+                                        station.playlist.length - 1
+                                    ) {
+                                        this.songs
+                                            .runJob("GET_SONG", {
+                                                id:
+                                                    station.playlist[
+                                                        station.currentSongIndex +
+                                                            1
+                                                    ],
+                                            })
+                                            .then((response) => {
+                                                return next(
+                                                    null,
+                                                    response.song,
+                                                    station.currentSongIndex + 1
+                                                );
+                                            })
+                                            .catch((err) => {
+                                                station.currentSongIndex++;
+                                                next(null, null, null);
+                                            });
+                                    } else {
+                                        this.runJob(
+                                            "CALCULATE_SONG_FOR_STATION",
+                                            {
+                                                station,
+                                                bypassQueue:
+                                                    payload.bypassQueue,
+                                            },
+                                            { bypassQueue: payload.bypassQueue }
+                                        )
+                                            .then((newPlaylist) => {
+                                                this.songs.getSong(
+                                                    newPlaylist[0],
+                                                    (err, song) => {
+                                                        if (err || !song)
+                                                            return next(
+                                                                null,
+                                                                this
+                                                                    .defaultSong,
+                                                                0
+                                                            );
+                                                        station.playlist = newPlaylist;
+                                                        next(null, song, 0);
+                                                    }
+                                                );
+                                            })
+                                            .catch((err) => {
+                                                next(null, this.defaultSong, 0);
+                                            });
+                                    }
+                                },
+                                (song, currentSongIndex, next) => {
+                                    if (!!song)
+                                        return next(
+                                            null,
+                                            true,
+                                            currentSongIndex
+                                        );
+                                    else return next(null, false);
+                                },
+                                (err, song, currentSongIndex) => {
+                                    return next(
+                                        err,
+                                        song,
+                                        currentSongIndex,
+                                        station
+                                    );
+                                }
+                            );
+                        }
+                    },
+                    (song, currentSongIndex, station, next) => {
+                        let $set = {};
+                        if (song === null) $set.currentSong = null;
+                        else if (song.likes === -1 && song.dislikes === -1) {
+                            $set.currentSong = {
+                                songId: song.songId,
+                                title: song.title,
+                                duration: song.duration,
+                                skipDuration: 0,
+                                likes: -1,
+                                dislikes: -1,
+                            };
+                        } else {
+                            $set.currentSong = {
+                                songId: song.songId,
+                                title: song.title,
+                                artists: song.artists,
+                                duration: song.duration,
+                                likes: song.likes,
+                                dislikes: song.dislikes,
+                                skipDuration: song.skipDuration,
+                                thumbnail: song.thumbnail,
+                            };
+                        }
+                        if (currentSongIndex >= 0)
+                            $set.currentSongIndex = currentSongIndex;
+                        $set.startedAt = Date.now();
+                        $set.timePaused = 0;
+                        if (station.paused) $set.pausedAt = Date.now();
+                        next(null, $set, station);
+                    },
+
+                    ($set, station, next) => {
+                        this.stationModel.updateOne(
+                            { _id: station._id },
+                            { $set },
+                            (err) => {
+                                this.runJob(
+                                    "UPDATE_STATION",
+                                    {
+                                        stationId: station._id,
+                                        bypassQueue: payload.bypassQueue,
+                                    },
+
+                                    { bypassQueue: payload.bypassQueue }
+                                )
+                                    .then((station) => {
+                                        if (
+                                            station.type === "community" &&
+                                            station.partyMode === true
+                                        )
+                                            this.cache
+                                                .runJob("PUB", {
+                                                    channel:
+                                                        "station.queueUpdate",
+                                                    value: payload.stationId,
+                                                })
+                                                .then()
+                                                .catch();
+                                        next(null, station);
+                                    })
+                                    .catch(next);
+                            }
+                        );
+                    },
+                ],
+                async (err, station) => {
+                    if (err) {
+                        err = await this.utils.runJob("GET_ERROR", {
+                            error: err,
+                        });
+                        this.log(
+                            "ERROR",
+                            `Skipping station "${payload.stationId}" failed. "${err}"`
+                        );
+                        reject(new Error(err));
+                    } else {
+                        if (
+                            station.currentSong !== null &&
+                            station.currentSong.songId !== undefined
+                        ) {
+                            station.currentSong.skipVotes = 0;
+                        }
+                        //TODO Pub/Sub this
+
+                        this.utils
+                            .runJob("EMIT_TO_ROOM", {
+                                room: `station.${station._id}`,
+                                args: [
+                                    "event:songs.next",
+                                    {
+                                        currentSong: station.currentSong,
+                                        startedAt: station.startedAt,
+                                        paused: station.paused,
+                                        timePaused: 0,
+                                    },
+                                ],
+                            })
+                            .then()
+                            .catch();
+
+                        if (station.privacy === "public") {
+                            this.utils
+                                .runJob("EMIT_TO_ROOM", {
+                                    room: "home",
+                                    args: [
+                                        "event:station.nextSong",
+                                        station._id,
+                                        station.currentSong,
+                                    ],
+                                })
+                                .then()
+                                .catch();
+                        } else {
+                            let sockets = await this.utils.runJob(
+                                "GET_ROOM_SOCKETS",
+                                { room: "home" }
+                            );
+                            for (let socketId in sockets) {
+                                let socket = sockets[socketId];
+                                let session = sockets[socketId].session;
+                                if (session.sessionId) {
+                                    this.cache
+                                        .runJob("HGET", {
+                                            table: "sessions",
+                                            key: session.sessionId,
+                                        })
+                                        .then((session) => {
+                                            if (session) {
+                                                this.db
+                                                    .runJob("GET_MODEL", {
+                                                        modelName: "user",
+                                                    })
+                                                    .then((userModel) => {
+                                                        userModel.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
+                                                                        );
+                                                                }
+                                                            }
+                                                        );
+                                                    });
+                                            }
+                                        });
+                                }
+                            }
+                        }
+
+                        if (
+                            station.currentSong !== null &&
+                            station.currentSong.songId !== undefined
+                        ) {
+                            this.utils.runJob("SOCKETS_JOIN_SONG_ROOM", {
+                                sockets: await this.utils.runJob(
+                                    "GET_ROOM_SOCKETS",
+                                    { room: `station.${station._id}` }
+                                ),
+                                room: `song.${station.currentSong.songId}`,
+                            });
+                            if (!station.paused) {
+                                this.notifications.runJob("SCHEDULE", {
+                                    name: `stations.nextSong?id=${station._id}`,
+                                    time: station.currentSong.duration * 1000,
+                                    station,
+                                });
+                            }
+                        } else {
+                            this.utils
+                                .runJob("SOCKETS_LEAVE_SONG_ROOMS", {
+                                    sockets: await this.utils.runJob(
+                                        "GET_ROOM_SOCKETS",
+                                        { room: `station.${station._id}` }
+                                    ),
+                                })
+                                .then()
+                                .catch();
+                        }
+
+                        resolve({ station: station });
+                    }
+                }
+            );
+        });
+    }
+
+    CAN_USER_VIEW_STATION(payload) {
+        // station, userId, cb
+        return new Promise((resolve, reject) => {
+            async.waterfall(
+                [
+                    (next) => {
+                        if (payload.station.privacy !== "private")
+                            return next(true);
+                        if (!payload.userId) return next("Not allowed");
+                        next();
+                    },
+
+                    (next) => {
+                        this.db
+                            .runJob("GET_MODEL", {
+                                modelName: "user",
+                            })
+                            .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);
+                        next("Not allowed");
+                    },
+                ],
+                async (errOrResult) => {
+                    if (errOrResult !== true && errOrResult !== "Not allowed") {
+                        errOrResult = await this.utils.runJob("GET_ERROR", {
+                            error: errOrResult,
+                        });
+                        reject(new Error(errOrResult));
+                    } else {
+                        resolve(errOrResult === true ? true : false);
+                    }
+                }
+            );
+        });
+    }
+}
+
+module.exports = new StationsModule();

+ 318 - 167
backend/logic/tasks.js

@@ -1,173 +1,324 @@
-'use strict';
+const CoreClass = require("../core.js");
 
-const coreClass = require("../core");
+const tasks = {};
 
 const async = require("async");
 const fs = require("fs");
 
-let tasks = {};
-
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-			
-			this.cache = this.moduleManager.modules["cache"];
-			this.stations = this.moduleManager.modules["stations"];
-			this.notifications = this.moduleManager.modules["notifications"];
-			this.utils = this.moduleManager.modules["utils"];
-
-			//this.createTask("testTask", testTask, 5000, true);
-			this.createTask("stationSkipTask", this.checkStationSkipTask, 1000 * 60 * 30);
-			this.createTask("sessionClearTask", this.sessionClearingTask, 1000 * 60 * 60 * 6);
-			this.createTask("logFileSizeCheckTask", this.logFileSizeCheckTask, 1000 * 60 * 60);
-
-			resolve();
-		});
-	}
-
-	async createTask(name, fn, timeout, paused = false) {
-		try { await this._validateHook(); } catch { return; }
-
-		tasks[name] = {
-			name,
-			fn,
-			timeout,
-			lastRan: 0,
-			timer: null
-		};
-		if (!paused) this.handleTask(tasks[name]);
-	}
-
-	async pauseTask(name) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (tasks[name].timer) tasks[name].timer.pause();
-	}
-
-	async resumeTask(name) {
-		try { await this._validateHook(); } catch { return; }
-
-		tasks[name].timer.resume();
-	}
-
-	async handleTask(task) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (task.timer) task.timer.pause();
-		
-		task.fn.apply(this, [
-			() => {
-				task.lastRan = Date.now();
-				task.timer = new this.utils.Timer(() => {
-					this.handleTask(task);
-				}, task.timeout, false);
-			}
-		]);
-	}
-
-	/*testTask(callback) {
-		//Stuff
-		console.log("Starting task");
-		setTimeout(() => {
-			console.log("Callback");
-			callback();
-		}, 10000);
-	}*/
-
-	async checkStationSkipTask(callback) {
-		this.logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
-		async.waterfall([
-			(next) => {
-				this.cache.hgetall('stations', 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();
-					else {
-						this.logger.error("TASK_STATIONS_SKIP_CHECK", `Skipping ${station._id} as it should have skipped already.`);
-						this.stations.initializeStation(station._id);
-						next2();
-					}
-				}, () => {
-					next();
-				});
-			}
-		], () => {
-			callback();
-		});
-	}
-
-	async sessionClearingTask(callback) {
-		this.logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`, false);
-		async.waterfall([
-			(next) => {
-				this.cache.hgetall('sessions', next);
-			},
-			(sessions, next) => {
-				if (!sessions) return next();
-				let keys = Object.keys(sessions);
-				async.each(keys, (sessionId, next2) => {
-					let session = sessions[sessionId];
-					if (session && session.refreshDate && (Date.now() - session.refreshDate) < (60 * 60 * 24 * 30 * 1000)) return next2();
-					if (!session) {
-						this.logger.info("TASK_SESSION_CLEAR", 'Removing an empty session.');
-						this.cache.hdel('sessions', sessionId, () => {
-							next2();
-						});
-					} else if (!session.refreshDate) {
-						session.refreshDate = Date.now();
-						this.cache.hset('sessions', sessionId, session, () => {
-							next2();
-						});
-					} else if ((Date.now() - session.refreshDate) > (60 * 60 * 24 * 30 * 1000)) {
-						this.utils.socketsFromSessionId(session.sessionId, (sockets) => {
-							if (sockets.length > 0) {
-								session.refreshDate = Date.now();
-								this.cache.hset('sessions', sessionId, session, () => {
-									next2()
-								});
-							} else {
-								this.logger.info("TASK_SESSION_CLEAR", `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`);
-								this.cache.hdel('sessions', session.sessionId, () => {
-									next2();
-								});
-							}
-						});
-					} else {
-						this.logger.error("TASK_SESSION_CLEAR", "This should never log.");
-						next2();
-					}
-				}, () => {
-					next();
-				});
-			}
-		], () => {
-			callback();
-		});
-	}
-
-	async logFileSizeCheckTask(callback) {
-		this.logger.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) => {
-				const stats = fs.statSync(`${__dirname}/../../log/${fileName}`);
-				const mb = stats.size / 1000000;
-				if (mb > 25) return next(true);
-				else next();
-			},
-			(err) => {
-				if (err === true) {
-					this.logger.error("LOGGER_FILE_SIZE_WARNING", "************************************WARNING*************************************");
-					this.logger.error("LOGGER_FILE_SIZE_WARNING", "***************ONE OR MORE LOG FILES APPEAR TO BE MORE THAN 25MB****************");
-					this.logger.error("LOGGER_FILE_SIZE_WARNING", "****MAKE SURE TO REGULARLY CLEAR UP THE LOG FILES, MANUALLY OR AUTOMATICALLY****");
-					this.logger.error("LOGGER_FILE_SIZE_WARNING", "********************************************************************************");
-				}
-				callback();
-			}
-		);
-	}
+const Timer = require("../classes/Timer.class");
+
+class TasksModule extends CoreClass {
+    constructor() {
+        super("tasks");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            // return reject(new Error("Not fully migrated yet."));
+
+            this.cache = this.moduleManager.modules["cache"];
+            this.stations = this.moduleManager.modules["stations"];
+            this.notifications = this.moduleManager.modules["notifications"];
+            this.utils = this.moduleManager.modules["utils"];
+
+            //this.createTask("testTask", testTask, 5000, true);
+
+            this.runJob("CREATE_TASK", {
+                name: "stationSkipTask",
+                fn: this.checkStationSkipTask,
+                timeout: 1000 * 60 * 30,
+            });
+
+            this.runJob("CREATE_TASK", {
+                name: "sessionClearTask",
+                fn: this.sessionClearingTask,
+                timeout: 1000 * 60 * 60 * 6,
+            });
+
+            this.runJob("CREATE_TASK", {
+                name: "logFileSizeCheckTask",
+                fn: this.logFileSizeCheckTask,
+                timeout: 1000 * 60 * 60,
+            });
+
+            resolve();
+        });
+    }
+
+    CREATE_TASK(payload) {
+        return new Promise((resolve, reject) => {
+            tasks[payload.name] = {
+                name: payload.name,
+                fn: payload.fn,
+                timeout: payload.timeout,
+                lastRan: 0,
+                timer: null,
+            };
+
+            if (!payload.paused) {
+                this.runJob("RUN_TASK", { name: payload.name })
+                    .then(() => resolve())
+                    .catch((err) => reject(err));
+            } else resolve();
+        });
+    }
+
+    PAUSE_TASK(payload) {
+        return new Promise((resolve, reject) => {
+            if (tasks[payload.name].timer) tasks[name].timer.pause();
+            resolve();
+        });
+    }
+
+    RESUME_TASK(payload) {
+        return new Promise((resolve, reject) => {
+            tasks[payload.name].timer.resume();
+            resolve();
+        });
+    }
+
+    RUN_TASK(payload) {
+        return new Promise((resolve, reject) => {
+            const task = tasks[payload.name];
+            if (task.timer) task.timer.pause();
+
+            task.fn.apply(this).then(() => {
+                task.lastRan = Date.now();
+                task.timer = new Timer(
+                    () => {
+                        this.runJob("RUN_TASK", { name: payload.name });
+                    },
+                    task.timeout,
+                    false
+                );
+
+                resolve();
+            });
+        });
+    }
+
+    checkStationSkipTask(callback) {
+        return new Promise((resolve, reject) => {
+            this.log(
+                "INFO",
+                "TASK_STATIONS_SKIP_CHECK",
+                `Checking for stations to be skipped.`,
+                false
+            );
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .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();
+                                else {
+                                    this.log(
+                                        "ERROR",
+                                        "TASK_STATIONS_SKIP_CHECK",
+                                        `Skipping ${station._id} as it should have skipped already.`
+                                    );
+                                    this.stations
+                                        .runJob("INITIALIZE_STATION", {
+                                            stationId: station._id,
+                                        })
+                                        .then(() => {
+                                            next2();
+                                        });
+                                }
+                            },
+                            () => {
+                                next();
+                            }
+                        );
+                    },
+                ],
+                () => {
+                    resolve();
+                }
+            );
+        });
+    }
+
+    sessionClearingTask() {
+        return new Promise((resolve, reject) => {
+            this.log(
+                "INFO",
+                "TASK_SESSION_CLEAR",
+                `Checking for sessions to be cleared.`
+            );
+
+            async.waterfall(
+                [
+                    (next) => {
+                        this.cache
+                            .runJob("HGETALL", {
+                                table: "sessions",
+                            })
+                            .then((sessions) => {
+                                next(null, sessions);
+                            })
+                            .catch(next);
+                    },
+                    (sessions, next) => {
+                        if (!sessions) return next();
+                        let keys = Object.keys(sessions);
+                        async.each(
+                            keys,
+                            (sessionId, next2) => {
+                                let session = sessions[sessionId];
+                                if (
+                                    session &&
+                                    session.refreshDate &&
+                                    Date.now() - session.refreshDate <
+                                        60 * 60 * 24 * 30 * 1000
+                                )
+                                    return next2();
+                                if (!session) {
+                                    this.log(
+                                        "INFO",
+                                        "TASK_SESSION_CLEAR",
+                                        "Removing an empty session."
+                                    );
+                                    this.cache
+                                        .runJob("HDEL", {
+                                            table: "sessions",
+                                            key: sessionId,
+                                        })
+                                        .finally(() => next2());
+                                } else if (!session.refreshDate) {
+                                    session.refreshDate = Date.now();
+                                    this.cache
+                                        .runJob("HSET", {
+                                            table: "sessions",
+                                            key: sessionId,
+                                            value: session,
+                                        })
+                                        .finally(() => next2());
+                                } else if (
+                                    Date.now() - session.refreshDate >
+                                    60 * 60 * 24 * 30 * 1000
+                                ) {
+                                    this.utils
+                                        .runJob("SOCKETS_FROM_SESSION_ID", {
+                                            sessionId: session.sessionId,
+                                        })
+                                        .then((response) => {
+                                            if (response.sockets.length > 0) {
+                                                session.refreshDate = Date.now();
+                                                this.cache
+                                                    .runJob("HSET", {
+                                                        table: "sessions",
+                                                        key: sessionId,
+                                                        value: session,
+                                                    })
+                                                    .finally(() => next2());
+                                            } else {
+                                                this.log(
+                                                    "INFO",
+                                                    "TASK_SESSION_CLEAR",
+                                                    `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`
+                                                );
+                                                this.cache
+                                                    .runJob("HDEL", {
+                                                        table: "sessions",
+                                                        key: session.sessionId,
+                                                    })
+                                                    .finally(() => next2());
+                                            }
+                                        });
+                                } else {
+                                    this.log(
+                                        "ERROR",
+                                        "TASK_SESSION_CLEAR",
+                                        "This should never log."
+                                    );
+                                    next2();
+                                }
+                            },
+                            () => {
+                                next();
+                            }
+                        );
+                    },
+                ],
+                () => {
+                    resolve();
+                }
+            );
+        });
+    }
+
+    logFileSizeCheckTask() {
+        return new Promise((resolve, reject) => {
+            this.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) => {
+                    const stats = fs.statSync(
+                        `${__dirname}/../../log/${fileName}`
+                    );
+                    const mb = stats.size / 1000000;
+                    if (mb > 25) return next(true);
+                    else next();
+                },
+                (err) => {
+                    if (err === true) {
+                        this.log(
+                            "ERROR",
+                            "LOGGER_FILE_SIZE_WARNING",
+                            "************************************WARNING*************************************"
+                        );
+                        this.log(
+                            "ERROR",
+                            "LOGGER_FILE_SIZE_WARNING",
+                            "***************ONE OR MORE LOG FILES APPEAR TO BE MORE THAN 25MB****************"
+                        );
+                        this.log(
+                            "ERROR",
+                            "LOGGER_FILE_SIZE_WARNING",
+                            "****MAKE SURE TO REGULARLY CLEAR UP THE LOG FILES, MANUALLY OR AUTOMATICALLY****"
+                        );
+                        this.log(
+                            "ERROR",
+                            "LOGGER_FILE_SIZE_WARNING",
+                            "********************************************************************************"
+                        );
+                    }
+                    resolve();
+                }
+            );
+        });
+    }
 }
+
+module.exports = new TasksModule();

+ 762 - 559
backend/logic/utils.js

@@ -1,565 +1,768 @@
-'use strict';
-
-const coreClass = require("../core");
-
-const config  = require('config'),
-	  async	  = require('async'),
-	  request = require('request');
-
-class Timer {
-	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();
-		}
-	}
-
-	pause() {
-		clearTimeout(this.timerId);
-		this.remaining -= Date.now() - this.start;
-		this.timePaused = Date.now();
-		this.paused = true;
-	}
-
-	ifNotPaused() {
-		if (!this.paused) {
-			this.resume();
-		}
-	}
-
-	resume() {
-		this.start = Date.now();
-		clearTimeout(this.timerId);
-		this.timerId = setTimeout(this.callback, this.remaining);
-		this.timeWhenPaused = Date.now() - this.timePaused;
-		this.paused = false;
-	}
-
-	resetTimeWhenPaused() {
-		this.timeWhenPaused = 0;
-	}
-
-	getTimePaused() {
-		if (!this.paused) {
-			return this.timeWhenPaused;
-		} else {
-			return Date.now() - this.timePaused;
-		}
-	}
-} 
+const CoreClass = require("../core.js");
+
+const config = require("config");
+const async = require("async");
+const request = require("request");
+const crypto = require("crypto");
 
 let youtubeRequestCallbacks = [];
 let youtubeRequestsPending = 0;
 let youtubeRequestsActive = false;
 
-module.exports = class extends coreClass {
-	initialize() {
-		return new Promise((resolve, reject) => {
-			this.setStage(1);
-			
-			this.io = this.moduleManager.modules["io"];
-			this.db = this.moduleManager.modules["db"];
-			this.spotify = this.moduleManager.modules["spotify"];
-			this.cache = this.moduleManager.modules["cache"];
-
-			this.Timer = Timer;
-
-			resolve();
-		});
-	}
-
-	async parseCookies(cookieString) {
-		try { await this._validateHook(); } catch { return; }
-		let cookies = {};
-		if (cookieString) cookieString.split("; ").map((cookie) => {
-			(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
-		});
-		return cookies;
-	}
-
-	async cookiesToString(cookies) {
-		try { await this._validateHook(); } catch { return; }
-		let newCookie = [];
-		for (let prop in cookie) {
-			newCookie.push(prop + "=" + cookie[prop]);
-		}
-		return newCookie.join("; ");
-	}
-
-	async removeCookie(cookieString, cookieName) {
-		try { await this._validateHook(); } catch { return; }
-		var cookies = this.parseCookies(cookieString);
-		delete cookies[cookieName];
-		return this.toString(cookies);
-	}
-
-	async htmlEntities(str) {
-		try { await this._validateHook(); } catch { return; }
-		return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
-	}
-
-	async generateRandomString(len) {
-		try { await this._validateHook(); } catch { return; }
-		let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
-		let result = [];
-		for (let i = 0; i < len; i++) {
-			result.push(chars[await this.getRandomNumber(0, chars.length - 1)]);
-		}
-		return result.join("");
-	}
-
-	async getSocketFromId(socketId) {
-		try { await this._validateHook(); } catch { return; }
-		return globals.io.sockets.sockets[socketId];
-	}
-
-	async getRandomNumber(min, max) {
-		try { await this._validateHook(); } catch { return; }
-		return Math.floor(Math.random() * (max - min + 1)) + min
-	}
-
-	async convertTime(duration) {
-		try { await this._validateHook(); } catch { return; }
-		let a = duration.match(/\d+/g);
-	
-		if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
-			a = [0, a[0], 0];
-		}
-	
-		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
-			a = [a[0], 0, a[1]];
-		}
-		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
-			a = [a[0], 0, 0];
-		}
-	
-		duration = 0;
-	
-		if (a.length == 3) {
-			duration = duration + parseInt(a[0]) * 3600;
-			duration = duration + parseInt(a[1]) * 60;
-			duration = duration + parseInt(a[2]);
-		}
-	
-		if (a.length == 2) {
-			duration = duration + parseInt(a[0]) * 60;
-			duration = duration + parseInt(a[1]);
-		}
-	
-		if (a.length == 1) {
-			duration = duration + parseInt(a[0]);
-		}
-	
-		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);
-	}
-
-	async guid () {
-		try { await this._validateHook(); } catch { return; }
-		return [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('');
-	}
-
-	async socketFromSession(socketId) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		if (ns) {
-			return ns.connected[socketId];
-		}
-	}
-
-	async socketsFromSessionId(sessionId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		let sockets = [];
-		if (ns) {
-			async.each(Object.keys(ns.connected), (id, next) => {
-				let session = ns.connected[id].session;
-				if (session.sessionId === sessionId) sockets.push(session.sessionId);
-				next();
-			}, () => {
-				cb(sockets);
-			});
-		}
-	}
-
-	async socketsFromUser(userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		let sockets = [];
-		if (ns) {
-			async.each(Object.keys(ns.connected), (id, next) => {
-				let session = ns.connected[id].session;
-				this.cache.hget('sessions', session.sessionId, (err, session) => {
-					if (!err && session && session.userId === userId) sockets.push(ns.connected[id]);
-					next();
-				});
-			}, () => {
-				cb(sockets);
-			});
-		}
-	}
-
-	async socketsFromIP(ip, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		let sockets = [];
-		if (ns) {
-			async.each(Object.keys(ns.connected), (id, next) => {
-				let session = ns.connected[id].session;
-				this.cache.hget('sessions', session.sessionId, (err, session) => {
-					if (!err && session && ns.connected[id].ip === ip) sockets.push(ns.connected[id]);
-					next();
-				});
-			}, () => {
-				cb(sockets);
-			});
-		}
-	}
-
-	async socketsFromUserWithoutCache(userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let ns = io.of("/");
-		let sockets = [];
-		if (ns) {
-			async.each(Object.keys(ns.connected), (id, next) => {
-				let session = ns.connected[id].session;
-				if (session.userId === userId) sockets.push(ns.connected[id]);
-				next();
-			}, () => {
-				cb(sockets);
-			});
-		}
-	}
-
-	async socketLeaveRooms(socketid) {
-		try { await this._validateHook(); } catch { return; }
-
-		let socket = await this.socketFromSession(socketid);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			socket.leave(room);
-		}
-	}
-
-	async socketJoinRoom(socketId, room) {
-		try { await this._validateHook(); } catch { return; }
-
-		let socket = await this.socketFromSession(socketId);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			socket.leave(room);
-		}
-		socket.join(room);
-	}
-
-	async socketJoinSongRoom(socketId, room) {
-		try { await this._validateHook(); } catch { return; }
-
-		let socket = await this.socketFromSession(socketId);
-		let rooms = socket.rooms;
-		for (let room in rooms) {
-			if (room.indexOf('song.') !== -1) socket.leave(rooms);
-		}
-		socket.join(room);
-	}
-
-	async socketsJoinSongRoom(sockets, room) {
-		try { await this._validateHook(); } catch { return; }
-
-		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);
-			}
-			socket.join(room);
-		}
-	}
-
-	async socketsLeaveSongRooms(sockets) {
-		try { await this._validateHook(); } catch { return; }
-
-		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);
-			}
-		}
-	}
-
-	async emitToRoom(room, ...args) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let sockets = io.sockets.sockets;
-		for (let id in sockets) {
-			let socket = sockets[id];
-			if (socket.rooms[room]) {
-				socket.emit.apply(socket, args);
-			}
-		}
-	}
-
-	async getRoomSockets(room) {
-		try { await this._validateHook(); } catch { return; }
-
-		let io = await this.io.io();
-		let sockets = io.sockets.sockets;
-		let roomSockets = [];
-		for (let id in sockets) {
-			let socket = sockets[id];
-			if (socket.rooms[room]) roomSockets.push(socket);
-		}
-		return roomSockets;
-	}
-
-	async getSongFromYouTube(songId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		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 = {
-					songId: body.items[0].id,
-					title: body.items[0].snippet.title,
-					duration
-				};
-				cb(song);
-			});
-		}, songId});
-
-		if (!youtubeRequestsActive) {
-			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
-		}
-	}
-
-	async getPlaylistFromYouTube(url, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		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, []);
-	}
-
-	async getSongFromSpotify(song, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!config.get("apis.spotify.enabled")) return cb("Spotify is not enabled", null);
-
-		const spotifyParams = [
-			`q=${encodeURIComponent(song.title)}`,
-			`type=track`
-		].join('&');
-
-		const token = await this.spotify.getToken();
-		const options = {
-			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
-			headers: {
-				Authorization: `Bearer ${token}`
-			}
-		};
-
-		request(options, (err, res, body) => {
-			if (err) console.error(err);
-			body = JSON.parse(body);
-			if (body.error) console.error(body.error);
-
-			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;
-					}
-				}
-			}
-
-			cb(null, song);
-		});
-	}
-
-	async getSongsFromSpotify(title, artist, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		if (!config.get("apis.spotify.enabled")) return cb([]);
-
-		const spotifyParams = [
-			`q=${encodeURIComponent(title)}`,
-			`type=track`
-		].join('&');
-		
-		const token = await this.spotify.getToken();
-		const options = {
-			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
-			headers: {
-				Authorization: `Bearer ${token}`
-			}
-		};
-
-		request(options, (err, res, body) => {
-			if (err) return console.error(err);
-			body = JSON.parse(body);
-			if (body.error) return console.error(body.error);
-
-			let songs = [];
-
-			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 localArtist = item.artists[k];
-						if (artist.toLowerCase() === localArtist.name.toLowerCase()) hasArtist = true;
-					}
-					if (hasArtist && (title.indexOf(item.name) !== -1 || item.name.indexOf(title) !== -1)) {
-						let song = {};
-						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;
-						songs.push(song);
-					}
-				}
-			}
-
-			cb(songs);
-		});
-	}
-
-	async shuffle(array) {
-		try { await this._validateHook(); } catch { return; }
-
-		let currentIndex = array.length, temporaryValue, randomIndex;
-
-		// While there remain elements to shuffle...
-		while (0 !== currentIndex) {
-
-			// 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;
-		}
-
-		return array;
-	}
-
-	async getError(err) {
-		try { await this._validateHook(); } catch { return; }
-
-		let error = 'An error occurred.';
-		if (typeof err === "string") error = err;
-		else if (err.message) {
-			if (err.message !== 'Validation failed') error = err.message;
-			else error = err.errors[Object.keys(err.errors)].message;
-		}
-		return error;
-	}
+class UtilsModule extends CoreClass {
+    constructor() {
+        super("utils");
+    }
+
+    initialize() {
+        return new Promise((resolve, reject) => {
+            this.io = this.moduleManager.modules["io"];
+            this.db = this.moduleManager.modules["db"];
+            this.spotify = this.moduleManager.modules["spotify"];
+            this.cache = this.moduleManager.modules["cache"];
+
+            resolve();
+        });
+    }
+
+    PARSE_COOKIES(payload) {
+        //cookieString
+        return new Promise((resolve, reject) => {
+            let cookies = {};
+            payload.cookieString.split("; ").map((cookie) => {
+                cookies[
+                    cookie.substring(0, cookie.indexOf("="))
+                ] = cookie.substring(cookie.indexOf("=") + 1, cookie.length);
+            });
+            resolve(cookies);
+        });
+    }
+
+    // COOKIES_TO_STRING() {//cookies
+    // 	return new Promise((resolve, reject) => {
+    //         let newCookie = [];
+    //         for (let prop in cookie) {
+    //             newCookie.push(prop + "=" + cookie[prop]);
+    //         }
+    //         return newCookie.join("; ");
+    //     });
+    // }
+
+    REMOVE_COOKIE(payload) {
+        //cookieString, cookieName
+        return new Promise(async (resolve, reject) => {
+            var cookies = await this.runJob("PARSE_COOKIES", {
+                cookieString: payload.cookieString,
+            });
+            delete cookies[payload.cookieName];
+            resolve(this.toString(cookies));
+        });
+    }
+
+    HTML_ENTITIES(payload) {
+        //str
+        return new Promise((resolve, reject) => {
+            resolve(
+                String(payload.str)
+                    .replace(/&/g, "&amp;")
+                    .replace(/</g, "&lt;")
+                    .replace(/>/g, "&gt;")
+                    .replace(/"/g, "&quot;")
+            );
+        });
+    }
+
+    GENERATE_RANDOM_STRING(payload) {
+        //length
+        return new Promise(async (resolve, reject) => {
+            let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split(
+                ""
+            );
+            let result = [];
+            for (let i = 0; i < payload.length; i++) {
+                result.push(
+                    chars[
+                        await this.runJob("GET_RANDOM_NUMBER", {
+                            min: 0,
+                            max: chars.length - 1,
+                        })
+                    ]
+                );
+            }
+            resolve(result.join(""));
+        });
+    }
+
+    GET_SOCKET_FROM_ID(payload) {
+        //socketId
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            resolve(io.sockets.sockets[payload.socketId]);
+        });
+    }
+
+    GET_RANDOM_NUMBER(payload) {
+        //min, max
+        return new Promise((resolve, reject) => {
+            resolve(
+                Math.floor(Math.random() * (payload.max - payload.min + 1)) +
+                    payload.min
+            );
+        });
+    }
+
+    CONVERT_TIME(payload) {
+        //duration
+        return new Promise((resolve, reject) => {
+            let duration = payload.duration;
+            let a = duration.match(/\d+/g);
+
+            if (
+                duration.indexOf("M") >= 0 &&
+                duration.indexOf("H") == -1 &&
+                duration.indexOf("S") == -1
+            ) {
+                a = [0, a[0], 0];
+            }
+
+            if (duration.indexOf("H") >= 0 && duration.indexOf("M") == -1) {
+                a = [a[0], 0, a[1]];
+            }
+            if (
+                duration.indexOf("H") >= 0 &&
+                duration.indexOf("M") == -1 &&
+                duration.indexOf("S") == -1
+            ) {
+                a = [a[0], 0, 0];
+            }
+
+            duration = 0;
+
+            if (a.length == 3) {
+                duration = duration + parseInt(a[0]) * 3600;
+                duration = duration + parseInt(a[1]) * 60;
+                duration = duration + parseInt(a[2]);
+            }
+
+            if (a.length == 2) {
+                duration = duration + parseInt(a[0]) * 60;
+                duration = duration + parseInt(a[1]);
+            }
+
+            if (a.length == 1) {
+                duration = duration + parseInt(a[0]);
+            }
+
+            let hours = Math.floor(duration / 3600);
+            let minutes = Math.floor((duration % 3600) / 60);
+            let seconds = Math.floor((duration % 3600) % 60);
+
+            resolve(
+                (hours < 10 ? "0" + hours + ":" : hours + ":") +
+                    (minutes < 10 ? "0" + minutes + ":" : minutes + ":") +
+                    (seconds < 10 ? "0" + seconds : seconds)
+            );
+        });
+    }
+
+    GUID(payload) {
+        return new Promise((resolve, reject) => {
+            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("")
+            );
+        });
+    }
+
+    SOCKET_FROM_SESSION(payload) {
+        //socketId
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            if (ns) {
+                resolve(ns.connected[payload.socketId]);
+            }
+        });
+    }
+
+    SOCKETS_FROM_SESSION_ID(payload) {
+        //sessionId, cb
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            let sockets = [];
+            if (ns) {
+                async.each(
+                    Object.keys(ns.connected),
+                    (id, next) => {
+                        let session = ns.connected[id].session;
+                        if (session.sessionId === payload.sessionId)
+                            sockets.push(session.sessionId);
+                        next();
+                    },
+                    () => {
+                        resolve({ sockets });
+                    }
+                );
+            }
+        });
+    }
+
+    SOCKETS_FROM_USER(payload) {
+        //userId, cb
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            let sockets = [];
+            if (ns) {
+                async.each(
+                    Object.keys(ns.connected),
+                    (id, next) => {
+                        let session = ns.connected[id].session;
+                        this.cache
+                            .runJob("HGET", {
+                                table: "sessions",
+                                key: session.sessionId,
+                            })
+                            .then((session) => {
+                                if (
+                                    session &&
+                                    session.userId === payload.userId
+                                )
+                                    sockets.push(ns.connected[id]);
+                                next();
+                            })
+                            .catch(() => {
+                                next();
+                            });
+                    },
+                    () => {
+                        resolve({ sockets });
+                    }
+                );
+            }
+        });
+    }
+
+    SOCKETS_FROM_IP(payload) {
+        //ip, cb
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            let sockets = [];
+            if (ns) {
+                async.each(
+                    Object.keys(ns.connected),
+                    (id, next) => {
+                        let session = ns.connected[id].session;
+                        this.cache
+                            .runJob("HGET", {
+                                table: "sessions",
+                                key: session.sessionId,
+                            })
+                            .then((session) => {
+                                if (
+                                    session &&
+                                    ns.connected[id].ip === payload.ip
+                                )
+                                    sockets.push(ns.connected[id]);
+                                next();
+                            })
+                            .catch((err) => {
+                                next();
+                            });
+                    },
+                    () => {
+                        resolve({ sockets });
+                    }
+                );
+            }
+        });
+    }
+
+    SOCKETS_FROM_USER_WITHOUT_CACHE(payload) {
+        //userId, cb
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let ns = io.of("/");
+            let sockets = [];
+            if (ns) {
+                async.each(
+                    Object.keys(ns.connected),
+                    (id, next) => {
+                        let session = ns.connected[id].session;
+                        if (session.userId === payload.userId)
+                            sockets.push(ns.connected[id]);
+                        next();
+                    },
+                    () => {
+                        resolve({ sockets });
+                    }
+                );
+            }
+        });
+    }
+
+    SOCKET_LEAVE_ROOMS(payload) {
+        //socketId
+        return new Promise(async (resolve, reject) => {
+            let socket = await this.runJob("SOCKET_FROM_SESSION", {
+                socketId: payload.socketId,
+            });
+            let rooms = socket.rooms;
+            for (let room in rooms) {
+                socket.leave(room);
+            }
+
+            resolve();
+        });
+    }
+
+    SOCKET_JOIN_ROOM(payload) {
+        //socketId, room
+        return new Promise(async (resolve, reject) => {
+            let socket = await this.runJob("SOCKET_FROM_SESSION", {
+                socketId: payload.socketId,
+            });
+            let rooms = socket.rooms;
+            for (let room in rooms) {
+                socket.leave(room);
+            }
+            socket.join(payload.room);
+            resolve();
+        });
+    }
+
+    SOCKET_JOIN_SONG_ROOM(payload) {
+        //socketId, room
+        return new Promise(async (resolve, reject) => {
+            let socket = await this.runJob("SOCKET_FROM_SESSION", {
+                socketId: payload.socketId,
+            });
+            let rooms = socket.rooms;
+            for (let room in rooms) {
+                if (room.indexOf("song.") !== -1) socket.leave(rooms);
+            }
+            socket.join(payload.room);
+            resolve();
+        });
+    }
+
+    SOCKETS_JOIN_SONG_ROOM(payload) {
+        //sockets, room
+        return new Promise((resolve, reject) => {
+            for (let id in payload.sockets) {
+                let socket = payload.sockets[id];
+                let rooms = socket.rooms;
+                for (let room in rooms) {
+                    if (room.indexOf("song.") !== -1) socket.leave(room);
+                }
+                socket.join(payload.room);
+            }
+            resolve();
+        });
+    }
+
+    SOCKETS_LEAVE_SONG_ROOMS(payload) {
+        //sockets
+        return new Promise((resolve, reject) => {
+            for (let id in payload.sockets) {
+                let socket = payload.sockets[id];
+                let rooms = socket.rooms;
+                for (let room in rooms) {
+                    if (room.indexOf("song.") !== -1) socket.leave(room);
+                }
+            }
+            resolve();
+        });
+    }
+
+    EMIT_TO_ROOM(payload) {
+        //room, ...args
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let sockets = io.sockets.sockets;
+            for (let id in sockets) {
+                let socket = sockets[id];
+                if (socket.rooms[payload.room]) {
+                    socket.emit.apply(socket, payload.args);
+                }
+            }
+            resolve();
+        });
+    }
+
+    GET_ROOM_SOCKETS(payload) {
+        //room
+        return new Promise(async (resolve, reject) => {
+            let io = await this.io.runJob("IO", {});
+            let sockets = io.sockets.sockets;
+            let roomSockets = [];
+            for (let id in sockets) {
+                let socket = sockets[id];
+                if (socket.rooms[payload.room]) roomSockets.push(socket);
+            }
+            resolve(roomSockets);
+        });
+    }
+
+    GET_SONG_FROM_YOUTUBE(payload) {
+        //songId, cb
+        return new Promise((resolve, reject) => {
+            youtubeRequestCallbacks.push({
+                cb: (test) => {
+                    youtubeRequestsActive = true;
+                    const youtubeParams = [
+                        "part=snippet,contentDetails,statistics,status",
+                        `id=${encodeURIComponent(payload.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 = {
+                                songId: body.items[0].id,
+                                title: body.items[0].snippet.title,
+                                duration,
+                            };
+                            resolve({ song });
+                        }
+                    );
+                },
+                songId: payload.songId,
+            });
+
+            if (!youtubeRequestsActive) {
+                youtubeRequestCallbacks[0].cb(
+                    youtubeRequestCallbacks[0].songId
+                );
+            }
+        });
+    }
+
+    FILTER_MUSIC_VIDEOS_YOUTUBE(payload) {
+        //videoIds, cb
+        return new Promise((resolve, reject) => {
+            function getNextPage(cb2) {
+                let localVideoIds = payload.videoIds.splice(0, 50);
+
+                const youtubeParams = [
+                    "part=topicDetails",
+                    `id=${encodeURIComponent(localVideoIds.join(","))}`,
+                    `maxResults=50`,
+                    `key=${config.get("apis.youtube.key")}`,
+                ].join("&");
+
+                request(
+                    `https://www.googleapis.com/youtube/v3/videos?${youtubeParams}`,
+                    async (err, res, body) => {
+                        if (err) {
+                            console.error(err);
+                            return next("Failed to find playlist from YouTube");
+                        }
+
+                        body = JSON.parse(body);
+
+                        let songIds = [];
+                        body.items.forEach((item) => {
+                            const songId = item.id;
+                            if (!item.topicDetails) return;
+                            else if (
+                                item.topicDetails.relevantTopicIds.indexOf(
+                                    "/m/04rlf"
+                                ) !== -1
+                            ) {
+                                songIds.push(songId);
+                            }
+                        });
+
+                        if (payload.videoIds.length > 0) {
+                            getNextPage((newSongIds) => {
+                                cb2(songIds.concat(newSongIds));
+                            });
+                        } else cb2(songIds);
+                    }
+                );
+            }
+
+            if (payload.videoIds.length === 0) resolve({ songIds: [] });
+            else
+                getNextPage((songIds) => {
+                    resolve({ songIds });
+                });
+        });
+    }
+
+    GET_PLAYLIST_FROM_YOUTUBE(payload) {
+        //url, musicOnly, cb
+        return new Promise((resolve, reject) => {
+            let local = this;
+
+            let name = "list".replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
+            var regex = new RegExp("[\\?&]" + name + "=([^&#]*)");
+            let playlistId = regex.exec(payload.url)[1];
+
+            function getPage(pageToken, songs) {
+                let nextPageToken = pageToken ? `pageToken=${pageToken}` : "";
+                const youtubeParams = [
+                    "part=contentDetails",
+                    `playlistId=${encodeURIComponent(playlistId)}`,
+                    `maxResults=50`,
+                    `key=${config.get("apis.youtube.key")}`,
+                    nextPageToken,
+                ].join("&");
+
+                request(
+                    `https://www.googleapis.com/youtube/v3/playlistItems?${youtubeParams}`,
+                    async (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 {
+                            songs = songs.map(
+                                (song) => song.contentDetails.videoId
+                            );
+                            if (!payload.musicOnly) resolve({ songs });
+                            else {
+                                local
+                                    .runJob("FILTER_MUSIC_VIDEOS_YOUTUBE", {
+                                        videoIds: songs.slice(),
+                                    })
+                                    .then((filteredSongs) => {
+                                        resolve({ filteredSongs, songs });
+                                    });
+                            }
+                        }
+                    }
+                );
+            }
+            getPage(null, []);
+        });
+    }
+
+    GET_SONG_FROM_SPOTIFY(payload) {
+        //song, cb
+        return new Promise(async (resolve, reject) => {
+            if (!config.get("apis.spotify.enabled"))
+                return reject(new Error("Spotify is not enabled."));
+
+            const song = Object.assign({}, payload.song);
+
+            const spotifyParams = [
+                `q=${encodeURIComponent(payload.song.title)}`,
+                `type=track`,
+            ].join("&");
+
+            const token = await this.spotify.runJob("GET_TOKEN", {});
+            const options = {
+                url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+                headers: {
+                    Authorization: `Bearer ${token}`,
+                },
+            };
+
+            request(options, (err, res, body) => {
+                if (err) console.error(err);
+                body = JSON.parse(body);
+                if (body.error) console.error(body.error);
+
+                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;
+                        }
+                    }
+                }
+
+                resolve({ song });
+            });
+        });
+    }
+
+    GET_SONGS_FROM_SPOTIFY(payload) {
+        //title, artist, cb
+        return new Promise(async (resolve, reject) => {
+            if (!config.get("apis.spotify.enabled"))
+                return reject(new Error("Spotify is not enabled."));
+
+            const spotifyParams = [
+                `q=${encodeURIComponent(payload.title)}`,
+                `type=track`,
+            ].join("&");
+
+            const token = await this.spotify.runJob("GET_TOKEN", {});
+            const options = {
+                url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+                headers: {
+                    Authorization: `Bearer ${token}`,
+                },
+            };
+
+            request(options, (err, res, body) => {
+                if (err) return console.error(err);
+                body = JSON.parse(body);
+                if (body.error) return console.error(body.error);
+
+                let songs = [];
+
+                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 localArtist = item.artists[k];
+                            if (
+                                payload.artist.toLowerCase() ===
+                                localArtist.name.toLowerCase()
+                            )
+                                hasArtist = true;
+                        }
+                        if (
+                            hasArtist &&
+                            (payload.title.indexOf(item.name) !== -1 ||
+                                item.name.indexOf(payload.title) !== -1)
+                        ) {
+                            let song = {};
+                            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;
+                            songs.push(song);
+                        }
+                    }
+                }
+
+                resolve({ songs });
+            });
+        });
+    }
+
+    SHUFFLE(payload) {
+        //array
+        return new Promise((resolve, reject) => {
+            const array = payload.array.slice();
+
+            let currentIndex = payload.array.length,
+                temporaryValue,
+                randomIndex;
+
+            // While there remain elements to shuffle...
+            while (0 !== currentIndex) {
+                // 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;
+            }
+
+            resolve({ array });
+        });
+    }
+
+    GET_ERROR(payload) {
+        //err
+        return new Promise((resolve, reject) => {
+            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);
+        });
+    }
+
+    CREATE_GRAVATAR(payload) {
+        //email
+        return new Promise((resolve, reject) => {
+            const hash = crypto
+                .createHash("md5")
+                .update(payload.email)
+                .digest("hex");
+
+            resolve(`https://www.gravatar.com/avatar/${hash}`);
+        });
+    }
+
+    DEBUG(payload) {
+        return new Promise((resolve, reject) => {
+            resolve();
+        });
+    }
 }
+
+module.exports = new UtilsModule();

+ 4372 - 0
backend/package-lock.json

@@ -0,0 +1,4372 @@
+{
+  "name": "musare-backend",
+  "version": "2.1.0",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "@snyk/cli-interface": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/@snyk/cli-interface/-/cli-interface-2.6.0.tgz",
+      "integrity": "sha512-jtk0gf80v4mFyDqaQNokD8GOPMTXpIUL35ewg6jtmuZw41xt56WF9kqCjiiViSRRRYA0RK+RuiVfmJA5pxvMUQ==",
+      "requires": {
+        "@snyk/graphlib": "2.1.9-patch",
+        "tslib": "^1.9.3"
+      }
+    },
+    "@snyk/cocoapods-lockfile-parser": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@snyk/cocoapods-lockfile-parser/-/cocoapods-lockfile-parser-3.2.0.tgz",
+      "integrity": "sha512-DyFqZudOlGXHBOVneLnQnyQ97xZLq+PTF9PhWOmrEzH/tKcLyXhdW/WmDPVNJVyNvogyRZ4cXIj487xy/EeZEw==",
+      "requires": {
+        "@snyk/dep-graph": "1.18.2",
+        "@snyk/ruby-semver": "^2.0.4",
+        "@types/js-yaml": "^3.12.1",
+        "core-js": "^3.2.0",
+        "js-yaml": "^3.13.1",
+        "source-map-support": "^0.5.7",
+        "tslib": "^1.10.0"
+      }
+    },
+    "@snyk/composer-lockfile-parser": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/@snyk/composer-lockfile-parser/-/composer-lockfile-parser-1.4.0.tgz",
+      "integrity": "sha512-ga4YTRjJUuP0Ufr+t1IucwVjEFAv66JSBB/zVHP2zy/jmfA3l3ZjlGQSjsRC6Me9P2Z0esQ83AYNZvmIf9pq2w==",
+      "requires": {
+        "@snyk/lodash": "^4.17.15-patch"
+      }
+    },
+    "@snyk/configstore": {
+      "version": "3.2.0-rc1",
+      "resolved": "https://registry.npmjs.org/@snyk/configstore/-/configstore-3.2.0-rc1.tgz",
+      "integrity": "sha512-CV3QggFY8BY3u8PdSSlUGLibqbqCG1zJRmGM2DhnhcxQDRRPTGTP//l7vJphOVsUP1Oe23+UQsj7KRWpRUZiqg==",
+      "requires": {
+        "dot-prop": "^5.2.0",
+        "graceful-fs": "^4.1.2",
+        "make-dir": "^1.0.0",
+        "unique-string": "^1.0.0",
+        "write-file-atomic": "^2.0.0",
+        "xdg-basedir": "^3.0.0"
+      }
+    },
+    "@snyk/dep-graph": {
+      "version": "1.18.2",
+      "resolved": "https://registry.npmjs.org/@snyk/dep-graph/-/dep-graph-1.18.2.tgz",
+      "integrity": "sha512-v7tIiCH4LmYOSc0xGHKSxSZ2PEDv8zDlYU7ZKSH+1Hk8Qvj3YYEFvtV1iFBHUEQFUen4kQA6lWxlwF8chsNw+w==",
+      "requires": {
+        "@snyk/graphlib": "2.1.9-patch",
+        "@snyk/lodash": "4.17.15-patch",
+        "object-hash": "^1.3.1",
+        "prettier": "^1.19.1",
+        "semver": "^6.0.0",
+        "source-map-support": "^0.5.11",
+        "tslib": "^1.10.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
+        }
+      }
+    },
+    "@snyk/gemfile": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@snyk/gemfile/-/gemfile-1.2.0.tgz",
+      "integrity": "sha512-nI7ELxukf7pT4/VraL4iabtNNMz8mUo7EXlqCFld8O5z6mIMLX9llps24iPpaIZOwArkY3FWA+4t+ixyvtTSIA=="
+    },
+    "@snyk/graphlib": {
+      "version": "2.1.9-patch",
+      "resolved": "https://registry.npmjs.org/@snyk/graphlib/-/graphlib-2.1.9-patch.tgz",
+      "integrity": "sha512-uFO/pNMm3pN15QB+hVMU7uaQXhsBNwEA8lOET/VDcdOzLptODhXzkJqSHqt0tZlpdAz6/6Uaj8jY00UvPFgFMA==",
+      "requires": {
+        "@snyk/lodash": "4.17.15-patch"
+      }
+    },
+    "@snyk/inquirer": {
+      "version": "6.2.2-patch",
+      "resolved": "https://registry.npmjs.org/@snyk/inquirer/-/inquirer-6.2.2-patch.tgz",
+      "integrity": "sha512-IUq5bHRL0vtVKtfvd4GOccAIaLYHbcertug2UVZzk5+yY6R/CxfYsnFUTho1h4BdkfNdin2tPjE/5jRF4SKSrw==",
+      "requires": {
+        "@snyk/lodash": "4.17.15-patch",
+        "ansi-escapes": "^3.2.0",
+        "chalk": "^2.4.2",
+        "cli-cursor": "^2.1.0",
+        "cli-width": "^2.0.0",
+        "external-editor": "^3.0.3",
+        "figures": "^2.0.0",
+        "mute-stream": "0.0.7",
+        "run-async": "^2.2.0",
+        "rxjs": "^6.4.0",
+        "string-width": "^2.1.0",
+        "strip-ansi": "^5.0.0",
+        "through": "^2.3.6"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+        },
+        "is-fullwidth-code-point": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+        },
+        "string-width": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+          "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+          "requires": {
+            "is-fullwidth-code-point": "^2.0.0",
+            "strip-ansi": "^4.0.0"
+          },
+          "dependencies": {
+            "strip-ansi": {
+              "version": "4.0.0",
+              "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+              "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+              "requires": {
+                "ansi-regex": "^3.0.0"
+              }
+            }
+          }
+        },
+        "strip-ansi": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+          "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+          "requires": {
+            "ansi-regex": "^4.1.0"
+          },
+          "dependencies": {
+            "ansi-regex": {
+              "version": "4.1.0",
+              "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+              "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
+            }
+          }
+        }
+      }
+    },
+    "@snyk/java-call-graph-builder": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/@snyk/java-call-graph-builder/-/java-call-graph-builder-1.8.0.tgz",
+      "integrity": "sha512-dD7hVdEKMMU9CP0jQLm6Q1+l6506rjW0dqQflJ3QOVohNzptYJtTv9pHKzgRu5+q/fgEc35oYi02A0WIQwSvpw==",
+      "requires": {
+        "@snyk/graphlib": "2.1.9-patch",
+        "@snyk/lodash": "4.17.15-patch",
+        "ci-info": "^2.0.0",
+        "debug": "^4.1.1",
+        "glob": "^7.1.6",
+        "jszip": "^3.2.2",
+        "needle": "^2.3.3",
+        "progress": "^2.0.3",
+        "snyk-config": "^3.0.0",
+        "source-map-support": "^0.5.7",
+        "temp-dir": "^2.0.0",
+        "tslib": "^1.9.3"
+      },
+      "dependencies": {
+        "ci-info": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
+          "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="
+        },
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "needle": {
+          "version": "2.4.1",
+          "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.1.tgz",
+          "integrity": "sha512-x/gi6ijr4B7fwl6WYL9FwlCvRQKGlUNvnceho8wxkwXqN8jvVmmmATTmZPRRG7b/yC1eode26C2HO9jl78Du9g==",
+          "requires": {
+            "debug": "^3.2.6",
+            "iconv-lite": "^0.4.4",
+            "sax": "^1.2.4"
+          },
+          "dependencies": {
+            "debug": {
+              "version": "3.2.6",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
+              "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+              "requires": {
+                "ms": "^2.1.1"
+              }
+            }
+          }
+        }
+      }
+    },
+    "@snyk/lodash": {
+      "version": "4.17.15-patch",
+      "resolved": "https://registry.npmjs.org/@snyk/lodash/-/lodash-4.17.15-patch.tgz",
+      "integrity": "sha512-e4+t34bGyjjRnwXwI14hqye9J/nRbG9iwaqTgXWHskm5qC+iK0UrjgYdWXiHJCf3Plbpr+1rpW+4LPzZnCGMhQ=="
+    },
+    "@snyk/rpm-parser": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@snyk/rpm-parser/-/rpm-parser-1.1.0.tgz",
+      "integrity": "sha512-+DyCagvnpyBjwYTxaPMQGLW4rkpKAw1Jrh8YbZCg7Ix172InBxdve/0zud18Lu2H6xWtDDdMvRDdfl82wlTBvA==",
+      "requires": {
+        "event-loop-spinner": "1.1.0",
+        "typescript": "3.8.3"
+      }
+    },
+    "@snyk/ruby-semver": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@snyk/ruby-semver/-/ruby-semver-2.2.0.tgz",
+      "integrity": "sha512-FqUayoVjcyCsQFYPm3DcaCKdFR4xmapUkCGY+bcNBs3jqCUw687PoP9CPQ1Jvtaw5YpfBNl/62jyntsWCeciuA==",
+      "requires": {
+        "@snyk/lodash": "4.17.15-patch"
+      }
+    },
+    "@snyk/snyk-cocoapods-plugin": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@snyk/snyk-cocoapods-plugin/-/snyk-cocoapods-plugin-2.2.0.tgz",
+      "integrity": "sha512-Ux7hXKawbk30niGBToGkKqHyKzhT3E7sCl0FNkPkHaaGZwPwhFCDyNFxBd4uGgWiQ+kT+RjtH5ahna+bSP69Yg==",
+      "requires": {
+        "@snyk/cli-interface": "1.5.0",
+        "@snyk/cocoapods-lockfile-parser": "3.2.0",
+        "@snyk/dep-graph": "^1.18.2",
+        "source-map-support": "^0.5.7",
+        "tslib": "^1.10.0"
+      },
+      "dependencies": {
+        "@snyk/cli-interface": {
+          "version": "1.5.0",
+          "resolved": "https://registry.npmjs.org/@snyk/cli-interface/-/cli-interface-1.5.0.tgz",
+          "integrity": "sha512-+Qo+IO3YOXWgazlo+CKxOuWFLQQdaNCJ9cSfhFQd687/FuesaIxWdInaAdfpsLScq0c6M1ieZslXgiZELSzxbg==",
+          "requires": {
+            "tslib": "^1.9.3"
+          }
+        }
+      }
+    },
+    "@snyk/update-notifier": {
+      "version": "2.5.1-rc2",
+      "resolved": "https://registry.npmjs.org/@snyk/update-notifier/-/update-notifier-2.5.1-rc2.tgz",
+      "integrity": "sha512-dlled3mfpnAt3cQb5hxkFiqfPCj4Yk0xV8Yl5P8PeVv1pUmO7vI4Ka4Mjs4r6CYM5f9kZhviFPQQcWOIDlMRcw==",
+      "requires": {
+        "@snyk/configstore": "3.2.0-rc1",
+        "boxen": "^1.3.0",
+        "chalk": "^2.3.2",
+        "import-lazy": "^2.1.0",
+        "is-ci": "^1.0.10",
+        "is-installed-globally": "^0.1.0",
+        "is-npm": "^1.0.0",
+        "latest-version": "^3.1.0",
+        "semver-diff": "^2.0.0",
+        "xdg-basedir": "^3.0.0"
+      }
+    },
+    "@types/agent-base": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@types/agent-base/-/agent-base-4.2.0.tgz",
+      "integrity": "sha512-8mrhPstU+ZX0Ugya8tl5DsDZ1I5ZwQzbL/8PA0z8Gj0k9nql7nkaMzmPVLj+l/nixWaliXi+EBiLA8bptw3z7Q==",
+      "requires": {
+        "@types/events": "*",
+        "@types/node": "*"
+      }
+    },
+    "@types/debug": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.5.tgz",
+      "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ=="
+    },
+    "@types/events": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
+      "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
+    },
+    "@types/js-yaml": {
+      "version": "3.12.3",
+      "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-3.12.3.tgz",
+      "integrity": "sha512-otRe77JNNWzoVGLKw8TCspKswRoQToys4tuL6XYVBFxjgeM0RUrx7m3jkaTdxILxeGry3zM8mGYkGXMeQ02guA=="
+    },
+    "@types/node": {
+      "version": "13.13.4",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-13.13.4.tgz",
+      "integrity": "sha512-x26ur3dSXgv5AwKS0lNfbjpCakGIduWU1DU91Zz58ONRWrIKGunmZBNv4P7N+e27sJkiGDsw/3fT4AtsqQBrBA=="
+    },
+    "@types/semver": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/@types/semver/-/semver-5.5.0.tgz",
+      "integrity": "sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ=="
+    },
+    "@types/xml2js": {
+      "version": "0.4.5",
+      "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.5.tgz",
+      "integrity": "sha512-yohU3zMn0fkhlape1nxXG2bLEGZRc1FeqF80RoHaYXJN7uibaauXfhzhOJr1Xh36sn+/tx21QAOf07b/xYVk1w==",
+      "requires": {
+        "@types/node": "*"
+      }
+    },
+    "@yarnpkg/lockfile": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
+      "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="
+    },
+    "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"
+      }
+    },
+    "after": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
+      "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8="
+    },
+    "agent-base": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
+      "integrity": "sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==",
+      "requires": {
+        "es6-promisify": "^5.0.0"
+      }
+    },
+    "ajv": {
+      "version": "6.12.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.0.tgz",
+      "integrity": "sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw==",
+      "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-align": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-2.0.0.tgz",
+      "integrity": "sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=",
+      "requires": {
+        "string-width": "^2.0.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+        },
+        "is-fullwidth-code-point": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+        },
+        "string-width": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+          "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+          "requires": {
+            "is-fullwidth-code-point": "^2.0.0",
+            "strip-ansi": "^4.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+          "requires": {
+            "ansi-regex": "^3.0.0"
+          }
+        }
+      }
+    },
+    "ansi-escapes": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz",
+      "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ=="
+    },
+    "ansi-regex": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+      "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+    },
+    "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==",
+      "requires": {
+        "color-convert": "^1.9.0"
+      }
+    },
+    "ansicolors": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz",
+      "integrity": "sha1-ZlWX3oap/+Oqm/vmyuXG6kJrSXk="
+    },
+    "aproba": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz",
+      "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw=="
+    },
+    "archy": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
+      "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA="
+    },
+    "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==",
+      "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="
+    },
+    "arraybuffer.slice": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz",
+      "integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog=="
+    },
+    "asap": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+      "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
+    },
+    "asn1": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
+      "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
+      "requires": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
+    "assert-plus": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+      "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
+    },
+    "ast-types": {
+      "version": "0.13.2",
+      "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.2.tgz",
+      "integrity": "sha512-uWMHxJxtfj/1oZClOxDEV1sQ1HCDkA4MG8Gr69KKeBjEVH0R84WlejZ0y2DcwyBlpAEMltmVYkVgqfLFb2oyiA=="
+    },
+    "async": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.1.0.tgz",
+      "integrity": "sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ=="
+    },
+    "async-limiter": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz",
+      "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ=="
+    },
+    "asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
+    },
+    "aws-sign2": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
+      "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg="
+    },
+    "aws4": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz",
+      "integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug=="
+    },
+    "backo2": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
+      "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
+    },
+    "balanced-match": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
+    },
+    "base64-arraybuffer": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz",
+      "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg="
+    },
+    "base64-js": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
+      "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
+    },
+    "base64id": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
+      "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="
+    },
+    "bcrypt": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-3.0.8.tgz",
+      "integrity": "sha512-jKV6RvLhI36TQnPDvUFqBEnGX9c8dRRygKxCZu7E+MgLfKZbmmXL8a7/SFFOyHoPNX9nV81cKRC5tbQfvEQtpw==",
+      "requires": {
+        "nan": "2.14.0",
+        "node-pre-gyp": "0.14.0"
+      }
+    },
+    "bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
+      "requires": {
+        "tweetnacl": "^0.14.3"
+      },
+      "dependencies": {
+        "tweetnacl": {
+          "version": "0.14.5",
+          "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+          "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
+        }
+      }
+    },
+    "better-assert": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz",
+      "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=",
+      "requires": {
+        "callsite": "1.0.0"
+      }
+    },
+    "bl": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.0.tgz",
+      "integrity": "sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==",
+      "requires": {
+        "readable-stream": "^2.3.5",
+        "safe-buffer": "^5.1.1"
+      }
+    },
+    "blob": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.5.tgz",
+      "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig=="
+    },
+    "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"
+      },
+      "dependencies": {
+        "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"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "boxen": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/boxen/-/boxen-1.3.0.tgz",
+      "integrity": "sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==",
+      "requires": {
+        "ansi-align": "^2.0.0",
+        "camelcase": "^4.0.0",
+        "chalk": "^2.0.1",
+        "cli-boxes": "^1.0.0",
+        "string-width": "^2.0.0",
+        "term-size": "^1.2.0",
+        "widest-line": "^2.0.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+        },
+        "is-fullwidth-code-point": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+        },
+        "string-width": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+          "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+          "requires": {
+            "is-fullwidth-code-point": "^2.0.0",
+            "strip-ansi": "^4.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+          "requires": {
+            "ansi-regex": "^3.0.0"
+          }
+        }
+      }
+    },
+    "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": "1.1.4",
+      "resolved": "https://registry.npmjs.org/bson/-/bson-1.1.4.tgz",
+      "integrity": "sha512-S/yKGU1syOMzO86+dGpg2qGoDL0zvzcb262G+gqEy6TgP6rt6z6qxSFX/8X6vLC91P7G7C3nLs0+bvDzmvBA3Q=="
+    },
+    "buffer": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
+      "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
+      "requires": {
+        "base64-js": "^1.0.2",
+        "ieee754": "^1.1.4"
+      }
+    },
+    "buffer-from": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
+      "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A=="
+    },
+    "bytes": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
+      "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
+    },
+    "callsite": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
+      "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA="
+    },
+    "camelcase": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+      "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0="
+    },
+    "capture-stack-trace": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz",
+      "integrity": "sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw=="
+    },
+    "caseless": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+      "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
+    },
+    "chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "requires": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      }
+    },
+    "chardet": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
+      "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
+    },
+    "chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
+    },
+    "ci-info": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz",
+      "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A=="
+    },
+    "cli-boxes": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz",
+      "integrity": "sha1-T6kXw+WclKAEzWH47lCdplFocUM="
+    },
+    "cli-cursor": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
+      "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=",
+      "requires": {
+        "restore-cursor": "^2.0.0"
+      }
+    },
+    "cli-spinner": {
+      "version": "0.2.10",
+      "resolved": "https://registry.npmjs.org/cli-spinner/-/cli-spinner-0.2.10.tgz",
+      "integrity": "sha512-U0sSQ+JJvSLi1pAYuJykwiA8Dsr15uHEy85iCJ6A+0DjVxivr3d+N2Wjvodeg89uP5K6TswFkKBfAD7B3YSn/Q=="
+    },
+    "cli-width": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz",
+      "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw=="
+    },
+    "cliui": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz",
+      "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=",
+      "requires": {
+        "string-width": "^1.0.1",
+        "strip-ansi": "^3.0.1",
+        "wrap-ansi": "^2.0.0"
+      },
+      "dependencies": {
+        "wrap-ansi": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
+          "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=",
+          "requires": {
+            "string-width": "^1.0.1",
+            "strip-ansi": "^3.0.1"
+          }
+        }
+      }
+    },
+    "co": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+      "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ="
+    },
+    "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": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "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="
+    },
+    "combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "requires": {
+        "delayed-stream": "~1.0.0"
+      }
+    },
+    "component-bind": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
+      "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E="
+    },
+    "component-emitter": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
+      "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY="
+    },
+    "component-inherit": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
+      "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM="
+    },
+    "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.1",
+      "resolved": "https://registry.npmjs.org/config/-/config-3.3.1.tgz",
+      "integrity": "sha512-+2/KaaaAzdwUBE3jgZON11L1ggLLhpf2FsGrfqYFHZW22ySGv/HqYIXrBwKKvn+XZh1UBUjHwAcrfsSkSygT+Q==",
+      "requires": {
+        "json5": "^2.1.1"
+      }
+    },
+    "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.3.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
+      "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
+    },
+    "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"
+      },
+      "dependencies": {
+        "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-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
+    },
+    "core-js": {
+      "version": "3.6.5",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
+      "integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA=="
+    },
+    "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"
+      }
+    },
+    "create-error-class": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/create-error-class/-/create-error-class-3.0.2.tgz",
+      "integrity": "sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=",
+      "requires": {
+        "capture-stack-trace": "^1.0.0"
+      }
+    },
+    "cross-spawn": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
+      "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
+      "requires": {
+        "lru-cache": "^4.0.1",
+        "shebang-command": "^1.2.0",
+        "which": "^1.2.9"
+      },
+      "dependencies": {
+        "lru-cache": {
+          "version": "4.1.5",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
+          "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
+          "requires": {
+            "pseudomap": "^1.0.2",
+            "yallist": "^2.1.2"
+          }
+        },
+        "yallist": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+          "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
+        }
+      }
+    },
+    "crypto-random-string": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz",
+      "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4="
+    },
+    "dashdash": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+      "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+      "requires": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "data-uri-to-buffer": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz",
+      "integrity": "sha512-vKQ9DTQPN1FLYiiEEOQ6IBGFqvjCa5rSK3cWMy/Nespm5d/x3dGFT9UBZnkLxCwua/IXBi2TYnwTEpsOvhC4UQ=="
+    },
+    "debug": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
+      "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+      "requires": {
+        "ms": "^2.1.1"
+      }
+    },
+    "decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
+    },
+    "deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="
+    },
+    "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="
+    },
+    "degenerator": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-1.0.4.tgz",
+      "integrity": "sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=",
+      "requires": {
+        "ast-types": "0.x.x",
+        "escodegen": "1.x.x",
+        "esprima": "3.x.x"
+      }
+    },
+    "delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
+    },
+    "delegates": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+      "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
+    },
+    "denque": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz",
+      "integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ=="
+    },
+    "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="
+    },
+    "diff": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+      "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="
+    },
+    "discord.js": {
+      "version": "11.6.4",
+      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-11.6.4.tgz",
+      "integrity": "sha512-cK6rH1PuGjSjpmEQbnpuTxq1Yv8B89SotyKUFcr4RhnsiZnfBfDOev7DD7v5vhtEyyj51NuMWFoRJzgy/m08Uw==",
+      "requires": {
+        "long": "^4.0.0",
+        "prism-media": "^0.0.4",
+        "snekfetch": "^3.6.4",
+        "tweetnacl": "^1.0.0",
+        "ws": "^6.0.0"
+      }
+    },
+    "dockerfile-ast": {
+      "version": "0.0.19",
+      "resolved": "https://registry.npmjs.org/dockerfile-ast/-/dockerfile-ast-0.0.19.tgz",
+      "integrity": "sha512-iDRNFeAB2j4rh/Ecc2gh3fjciVifCMsszfCfHlYF5Wv8yybjZLiRDZUBt/pS3xrAz8uWT8fCHLq4pOQMmwCDwA==",
+      "requires": {
+        "vscode-languageserver-types": "^3.5.0"
+      }
+    },
+    "dot-prop": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz",
+      "integrity": "sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==",
+      "requires": {
+        "is-obj": "^2.0.0"
+      }
+    },
+    "dotnet-deps-parser": {
+      "version": "4.10.0",
+      "resolved": "https://registry.npmjs.org/dotnet-deps-parser/-/dotnet-deps-parser-4.10.0.tgz",
+      "integrity": "sha512-dEO1oTvreaDCtcvhRdOmmAMubyC+MWqVr1c/1Wvasi+NW4NZeB67qGh1taqowUFh+aCXtPw3SP2eExn6aNkhwA==",
+      "requires": {
+        "@snyk/lodash": "4.17.15-patch",
+        "@types/xml2js": "0.4.5",
+        "source-map-support": "^0.5.7",
+        "tslib": "^1.10.0",
+        "xml2js": "0.4.23"
+      }
+    },
+    "double-ended-queue": {
+      "version": "2.1.0-0",
+      "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz",
+      "integrity": "sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw="
+    },
+    "duplexer3": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
+      "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI="
+    },
+    "ecc-jsbn": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+      "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+      "requires": {
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.1.0"
+      }
+    },
+    "ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
+    },
+    "email-validator": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/email-validator/-/email-validator-2.0.4.tgz",
+      "integrity": "sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ=="
+    },
+    "emoji-regex": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
+      "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA=="
+    },
+    "encodeurl": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
+      "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
+    },
+    "end-of-stream": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+      "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+      "requires": {
+        "once": "^1.4.0"
+      }
+    },
+    "engine.io": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-3.4.0.tgz",
+      "integrity": "sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w==",
+      "requires": {
+        "accepts": "~1.3.4",
+        "base64id": "2.0.0",
+        "cookie": "0.3.1",
+        "debug": "~4.1.0",
+        "engine.io-parser": "~2.2.0",
+        "ws": "^7.1.2"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "ws": {
+          "version": "7.2.1",
+          "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.1.tgz",
+          "integrity": "sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A=="
+        }
+      }
+    },
+    "engine.io-client": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-3.4.0.tgz",
+      "integrity": "sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==",
+      "requires": {
+        "component-emitter": "1.2.1",
+        "component-inherit": "0.0.3",
+        "debug": "~4.1.0",
+        "engine.io-parser": "~2.2.0",
+        "has-cors": "1.1.0",
+        "indexof": "0.0.1",
+        "parseqs": "0.0.5",
+        "parseuri": "0.0.5",
+        "ws": "~6.1.0",
+        "xmlhttprequest-ssl": "~1.5.4",
+        "yeast": "0.1.2"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "ws": {
+          "version": "6.1.4",
+          "resolved": "https://registry.npmjs.org/ws/-/ws-6.1.4.tgz",
+          "integrity": "sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==",
+          "requires": {
+            "async-limiter": "~1.0.0"
+          }
+        }
+      }
+    },
+    "engine.io-parser": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-2.2.0.tgz",
+      "integrity": "sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==",
+      "requires": {
+        "after": "0.8.2",
+        "arraybuffer.slice": "~0.0.7",
+        "base64-arraybuffer": "0.1.5",
+        "blob": "0.0.5",
+        "has-binary2": "~1.0.2"
+      }
+    },
+    "es6-promise": {
+      "version": "4.2.8",
+      "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
+      "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
+    },
+    "es6-promisify": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz",
+      "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=",
+      "requires": {
+        "es6-promise": "^4.0.3"
+      }
+    },
+    "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="
+    },
+    "escodegen": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.1.tgz",
+      "integrity": "sha512-Bmt7NcRySdIfNPfU2ZoXDrrXsG9ZjvDxcAlMfDUgRBjLOWTuIACXPBFJH7Z+cLb40JeQco5toikyc9t9P8E9SQ==",
+      "requires": {
+        "esprima": "^4.0.1",
+        "estraverse": "^4.2.0",
+        "esutils": "^2.0.2",
+        "optionator": "^0.8.1",
+        "source-map": "~0.6.1"
+      },
+      "dependencies": {
+        "esprima": {
+          "version": "4.0.1",
+          "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+          "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
+        }
+      }
+    },
+    "esprima": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz",
+      "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM="
+    },
+    "estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw=="
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
+    },
+    "etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
+    },
+    "event-loop-spinner": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/event-loop-spinner/-/event-loop-spinner-1.1.0.tgz",
+      "integrity": "sha512-YVFs6dPpZIgH665kKckDktEVvSBccSYJmoZUfhNUdv5d3Xv+Q+SKF4Xis1jolq9aBzuW1ZZhQh/m/zU/TPdDhw==",
+      "requires": {
+        "tslib": "^1.10.0"
+      }
+    },
+    "execa": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz",
+      "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=",
+      "requires": {
+        "cross-spawn": "^5.0.1",
+        "get-stream": "^3.0.0",
+        "is-stream": "^1.1.0",
+        "npm-run-path": "^2.0.0",
+        "p-finally": "^1.0.0",
+        "signal-exit": "^3.0.0",
+        "strip-eof": "^1.0.0"
+      }
+    },
+    "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"
+      },
+      "dependencies": {
+        "cookie": {
+          "version": "0.4.0",
+          "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
+          "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
+        },
+        "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"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
+    },
+    "external-editor": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
+      "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
+      "requires": {
+        "chardet": "^0.7.0",
+        "iconv-lite": "^0.4.24",
+        "tmp": "^0.0.33"
+      }
+    },
+    "extsprintf": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+      "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
+    },
+    "fast-deep-equal": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
+      "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA=="
+    },
+    "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=="
+    },
+    "fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc="
+    },
+    "figures": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
+      "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=",
+      "requires": {
+        "escape-string-regexp": "^1.0.5"
+      }
+    },
+    "file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
+    },
+    "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"
+      },
+      "dependencies": {
+        "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"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "forever-agent": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+      "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
+    },
+    "form-data": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz",
+      "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==",
+      "requires": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.6",
+        "mime-types": "^2.1.12"
+      }
+    },
+    "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-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
+    },
+    "fs-minipass": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.7.tgz",
+      "integrity": "sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==",
+      "requires": {
+        "minipass": "^2.6.0"
+      }
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+    },
+    "ftp": {
+      "version": "0.3.10",
+      "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz",
+      "integrity": "sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=",
+      "requires": {
+        "readable-stream": "1.1.x",
+        "xregexp": "2.0.0"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "0.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+          "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
+        },
+        "readable-stream": {
+          "version": "1.1.14",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+          "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.1",
+            "isarray": "0.0.1",
+            "string_decoder": "~0.10.x"
+          }
+        },
+        "string_decoder": {
+          "version": "0.10.31",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+          "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+        }
+      }
+    },
+    "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"
+      }
+    },
+    "get-stream": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz",
+      "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ="
+    },
+    "get-uri": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-2.0.4.tgz",
+      "integrity": "sha512-v7LT/s8kVjs+Tx0ykk1I+H/rbpzkHvuIq87LmeXptcf5sNWm9uQiwjNAt94SJPA1zOlCntmnOlJvVWKmzsxG8Q==",
+      "requires": {
+        "data-uri-to-buffer": "1",
+        "debug": "2",
+        "extend": "~3.0.2",
+        "file-uri-to-path": "1",
+        "ftp": "~0.3.10",
+        "readable-stream": "2"
+      },
+      "dependencies": {
+        "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"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "getpass": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+      "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+      "requires": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "git-up": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/git-up/-/git-up-4.0.1.tgz",
+      "integrity": "sha512-LFTZZrBlrCrGCG07/dm1aCjjpL1z9L3+5aEeI9SBhAqSc+kiA9Or1bgZhQFNppJX6h/f5McrvJt1mQXTFm6Qrw==",
+      "requires": {
+        "is-ssh": "^1.3.0",
+        "parse-url": "^5.0.0"
+      }
+    },
+    "git-url-parse": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-11.1.2.tgz",
+      "integrity": "sha512-gZeLVGY8QVKMIkckncX+iCq2/L8PlwncvDFKiWkBn9EtCfYDbliRTTp6qzyQ1VMdITUfq7293zDzfpjdiGASSQ==",
+      "requires": {
+        "git-up": "^4.0.0"
+      }
+    },
+    "glob": {
+      "version": "7.1.6",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+      "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+      "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"
+      }
+    },
+    "global-dirs": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz",
+      "integrity": "sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=",
+      "requires": {
+        "ini": "^1.3.4"
+      }
+    },
+    "got": {
+      "version": "6.7.1",
+      "resolved": "https://registry.npmjs.org/got/-/got-6.7.1.tgz",
+      "integrity": "sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=",
+      "requires": {
+        "create-error-class": "^3.0.0",
+        "duplexer3": "^0.1.4",
+        "get-stream": "^3.0.0",
+        "is-redirect": "^1.0.0",
+        "is-retry-allowed": "^1.0.0",
+        "is-stream": "^1.0.0",
+        "lowercase-keys": "^1.0.0",
+        "safe-buffer": "^5.0.1",
+        "timed-out": "^4.0.0",
+        "unzip-response": "^2.0.1",
+        "url-parse-lax": "^1.0.0"
+      }
+    },
+    "graceful-fs": {
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
+      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw=="
+    },
+    "har-schema": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
+      "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI="
+    },
+    "har-validator": {
+      "version": "5.1.3",
+      "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz",
+      "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==",
+      "requires": {
+        "ajv": "^6.5.5",
+        "har-schema": "^2.0.0"
+      }
+    },
+    "has-binary2": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has-binary2/-/has-binary2-1.0.3.tgz",
+      "integrity": "sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==",
+      "requires": {
+        "isarray": "2.0.1"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+          "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4="
+        }
+      }
+    },
+    "has-cors": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
+      "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk="
+    },
+    "has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
+    },
+    "has-unicode": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+      "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
+    },
+    "hosted-git-info": {
+      "version": "2.8.8",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz",
+      "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg=="
+    },
+    "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"
+      },
+      "dependencies": {
+        "inherits": {
+          "version": "2.0.3",
+          "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+          "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+        }
+      }
+    },
+    "http-proxy-agent": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz",
+      "integrity": "sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==",
+      "requires": {
+        "agent-base": "4",
+        "debug": "3.1.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "http-signature": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
+      "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
+      "requires": {
+        "assert-plus": "^1.0.0",
+        "jsprim": "^1.2.2",
+        "sshpk": "^1.7.0"
+      }
+    },
+    "https-proxy-agent": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz",
+      "integrity": "sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==",
+      "requires": {
+        "agent-base": "^4.3.0",
+        "debug": "^3.1.0"
+      }
+    },
+    "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.1.13",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
+      "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
+    },
+    "ignore-walk": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.3.tgz",
+      "integrity": "sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==",
+      "requires": {
+        "minimatch": "^3.0.4"
+      }
+    },
+    "immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps="
+    },
+    "import-lazy": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz",
+      "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM="
+    },
+    "imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o="
+    },
+    "indexof": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
+      "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10="
+    },
+    "inflection": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.12.0.tgz",
+      "integrity": "sha1-ogCTVlbW9fa8TcdQLhrstwMihBY="
+    },
+    "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.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "ini": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz",
+      "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw=="
+    },
+    "invert-kv": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz",
+      "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY="
+    },
+    "ip": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
+      "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo="
+    },
+    "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-ci": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz",
+      "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==",
+      "requires": {
+        "ci-info": "^1.5.0"
+      }
+    },
+    "is-docker": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.0.0.tgz",
+      "integrity": "sha512-pJEdRugimx4fBMra5z2/5iRdZ63OhYV0vr0Dwm5+xtW4D1FvRkB8hamMIhnWfyJeDdyr/aa7BDyNbtG38VxgoQ=="
+    },
+    "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-installed-globally": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz",
+      "integrity": "sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=",
+      "requires": {
+        "global-dirs": "^0.1.0",
+        "is-path-inside": "^1.0.0"
+      }
+    },
+    "is-npm": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-1.0.0.tgz",
+      "integrity": "sha1-8vtjpl5JBbQGyGBydloaTceTufQ="
+    },
+    "is-obj": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
+      "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="
+    },
+    "is-path-inside": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz",
+      "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=",
+      "requires": {
+        "path-is-inside": "^1.0.1"
+      }
+    },
+    "is-redirect": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-redirect/-/is-redirect-1.0.0.tgz",
+      "integrity": "sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ="
+    },
+    "is-retry-allowed": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz",
+      "integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg=="
+    },
+    "is-ssh": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.3.1.tgz",
+      "integrity": "sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg==",
+      "requires": {
+        "protocols": "^1.1.0"
+      }
+    },
+    "is-stream": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+      "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
+    },
+    "is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
+    },
+    "is-wsl": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.1.1.tgz",
+      "integrity": "sha512-umZHcSrwlDHo2TGMXv0DZ8dIUGunZ2Iv68YZnrmCiBPkZ4aaOhtv7pXJKeki9k3qJ3RJr0cDyitcl5wEH3AYog=="
+    },
+    "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="
+    },
+    "isstream": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+      "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
+    },
+    "js-yaml": {
+      "version": "3.13.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz",
+      "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==",
+      "requires": {
+        "argparse": "^1.0.7",
+        "esprima": "^4.0.0"
+      },
+      "dependencies": {
+        "esprima": {
+          "version": "4.0.1",
+          "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+          "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
+        }
+      }
+    },
+    "jsbn": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+      "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM="
+    },
+    "json-schema": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
+      "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
+    },
+    "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=="
+    },
+    "json-stringify-safe": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+      "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
+    },
+    "json5": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
+      "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
+      "requires": {
+        "minimist": "^1.2.5"
+      },
+      "dependencies": {
+        "minimist": {
+          "version": "1.2.5",
+          "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+          "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+        }
+      }
+    },
+    "jsprim": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
+      "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
+      "requires": {
+        "assert-plus": "1.0.0",
+        "extsprintf": "1.3.0",
+        "json-schema": "0.2.3",
+        "verror": "1.10.0"
+      }
+    },
+    "jszip": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.4.0.tgz",
+      "integrity": "sha512-gZAOYuPl4EhPTXT0GjhI3o+ZAz3su6EhLrKUoAivcKqyqC7laS5JEv4XWZND9BgcDcF83vI85yGbDmDR6UhrIg==",
+      "requires": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "set-immediate-shim": "~1.0.1"
+      }
+    },
+    "kareem": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.3.1.tgz",
+      "integrity": "sha512-l3hLhffs9zqoDe8zjmb/mAN4B8VT3L56EUvKNqLFVs9YlFA+zx7ke1DO8STAdDyYNkeSo1nKmjuvQeI12So8Xw=="
+    },
+    "latest-version": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz",
+      "integrity": "sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=",
+      "requires": {
+        "package-json": "^4.0.0"
+      }
+    },
+    "lcid": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz",
+      "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=",
+      "requires": {
+        "invert-kv": "^1.0.0"
+      }
+    },
+    "levn": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
+      "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+      "requires": {
+        "prelude-ls": "~1.1.2",
+        "type-check": "~0.3.2"
+      }
+    },
+    "lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "requires": {
+        "immediate": "~3.0.5"
+      }
+    },
+    "lodash": {
+      "version": "4.17.15",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
+      "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
+    },
+    "lodash.assign": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
+      "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc="
+    },
+    "lodash.assignin": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
+      "integrity": "sha1-uo31+4QesKPoBEIysOJjqNxqKKI="
+    },
+    "lodash.clone": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz",
+      "integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y="
+    },
+    "lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
+    },
+    "lodash.flatten": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
+      "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8="
+    },
+    "lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk="
+    },
+    "lodash.set": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
+      "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM="
+    },
+    "long": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+      "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="
+    },
+    "lowercase-keys": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
+      "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA=="
+    },
+    "lru-cache": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+      "requires": {
+        "yallist": "^3.0.2"
+      }
+    },
+    "macos-release": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz",
+      "integrity": "sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA=="
+    },
+    "mailgun-js": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/mailgun-js/-/mailgun-js-0.22.0.tgz",
+      "integrity": "sha512-a2alg5nuTZA9Psa1pSEIEsbxr1Zrmqx4VkgGCQ30xVh0kIH7Bu57AYILo+0v8QLSdXtCyLaS+KVmdCrQo0uWFA==",
+      "requires": {
+        "async": "^2.6.1",
+        "debug": "^4.1.0",
+        "form-data": "^2.3.3",
+        "inflection": "~1.12.0",
+        "is-stream": "^1.1.0",
+        "path-proxy": "~1.0.0",
+        "promisify-call": "^2.0.2",
+        "proxy-agent": "^3.0.3",
+        "tsscmp": "^1.0.6"
+      },
+      "dependencies": {
+        "async": {
+          "version": "2.6.3",
+          "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz",
+          "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==",
+          "requires": {
+            "lodash": "^4.17.14"
+          }
+        },
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "make-dir": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
+      "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==",
+      "requires": {
+        "pify": "^3.0.0"
+      }
+    },
+    "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.43.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz",
+      "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ=="
+    },
+    "mime-types": {
+      "version": "2.1.26",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz",
+      "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==",
+      "requires": {
+        "mime-db": "1.43.0"
+      }
+    },
+    "mimic-fn": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
+      "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="
+    },
+    "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": "2.9.0",
+      "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz",
+      "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==",
+      "requires": {
+        "safe-buffer": "^5.1.2",
+        "yallist": "^3.0.0"
+      }
+    },
+    "minizlib": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz",
+      "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==",
+      "requires": {
+        "minipass": "^2.9.0"
+      }
+    },
+    "mkdirp": {
+      "version": "0.5.5",
+      "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
+      "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
+      "requires": {
+        "minimist": "^1.2.5"
+      }
+    },
+    "moment": {
+      "version": "2.24.0",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
+      "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
+    },
+    "mongodb": {
+      "version": "3.5.6",
+      "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.5.6.tgz",
+      "integrity": "sha512-sh3q3GLDLT4QmoDLamxtAECwC3RGjq+oNuK1ENV8+tnipIavss6sMYt77hpygqlMOCt0Sla5cl7H4SKCVBCGEg==",
+      "requires": {
+        "bl": "^2.2.0",
+        "bson": "^1.1.4",
+        "denque": "^1.4.1",
+        "require_optional": "^1.0.1",
+        "safe-buffer": "^5.1.2",
+        "saslprep": "^1.0.0"
+      }
+    },
+    "mongoose": {
+      "version": "5.9.10",
+      "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-5.9.10.tgz",
+      "integrity": "sha512-w1HNukfJzzDLfcI1f79h2Wj4ogVbf+X8hRkyFgqlcjK7OnDlAgahjDMIsT+mCS9jKojrMhjSsZIs9FiRPkLqMg==",
+      "requires": {
+        "bson": "^1.1.4",
+        "kareem": "2.3.1",
+        "mongodb": "3.5.6",
+        "mongoose-legacy-pluralize": "1.0.2",
+        "mpath": "0.7.0",
+        "mquery": "3.2.2",
+        "ms": "2.1.2",
+        "regexp-clone": "1.0.0",
+        "safe-buffer": "5.1.2",
+        "sift": "7.0.1",
+        "sliced": "1.0.1"
+      }
+    },
+    "mongoose-legacy-pluralize": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz",
+      "integrity": "sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ=="
+    },
+    "mpath": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.7.0.tgz",
+      "integrity": "sha512-Aiq04hILxhz1L+f7sjGyn7IxYzWm1zLNNXcfhDtx04kZ2Gk7uvFdgZ8ts1cWa/6d0TQmag2yR8zSGZUmp0tFNg=="
+    },
+    "mquery": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/mquery/-/mquery-3.2.2.tgz",
+      "integrity": "sha512-XB52992COp0KP230I3qloVUbkLUxJIu328HBP2t2EsxSFtf4W1HPSOBWOXf1bqxK4Xbb66lfMJ+Bpfd9/yZE1Q==",
+      "requires": {
+        "bluebird": "3.5.1",
+        "debug": "3.1.0",
+        "regexp-clone": "^1.0.0",
+        "safe-buffer": "5.1.2",
+        "sliced": "1.0.1"
+      },
+      "dependencies": {
+        "bluebird": {
+          "version": "3.5.1",
+          "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz",
+          "integrity": "sha512-MKiLiV+I1AA596t9w1sQJ8jkiSr5+ZKi0WKrYGUn6d1Fx+Ij4tIj+m2WMQSGczs5jZVxV339chE8iwk6F64wjA=="
+        },
+        "debug": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+        }
+      }
+    },
+    "ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "mute-stream": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
+      "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
+    },
+    "nan": {
+      "version": "2.14.0",
+      "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
+      "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
+    },
+    "nconf": {
+      "version": "0.10.0",
+      "resolved": "https://registry.npmjs.org/nconf/-/nconf-0.10.0.tgz",
+      "integrity": "sha512-fKiXMQrpP7CYWJQzKkPPx9hPgmq+YLDyxcG9N8RpiE9FoCkCbzD0NyW0YhE3xn3Aupe7nnDeIx4PFzYehpHT9Q==",
+      "requires": {
+        "async": "^1.4.0",
+        "ini": "^1.3.0",
+        "secure-keys": "^1.0.0",
+        "yargs": "^3.19.0"
+      },
+      "dependencies": {
+        "async": {
+          "version": "1.5.2",
+          "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
+          "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo="
+        }
+      }
+    },
+    "needle": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.2.tgz",
+      "integrity": "sha512-DUzITvPVDUy6vczKKYTnWc/pBZ0EnjMJnQ3y+Jo5zfKFimJs7S3HFCxCRZYB9FUZcrzUQr3WsmvZgddMEIZv6w==",
+      "requires": {
+        "debug": "^3.2.6",
+        "iconv-lite": "^0.4.4",
+        "sax": "^1.2.4"
+      }
+    },
+    "negotiator": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
+      "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
+    },
+    "netmask": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz",
+      "integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU="
+    },
+    "nice-try": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
+      "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ=="
+    },
+    "node-pre-gyp": {
+      "version": "0.14.0",
+      "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz",
+      "integrity": "sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA==",
+      "requires": {
+        "detect-libc": "^1.0.2",
+        "mkdirp": "^0.5.1",
+        "needle": "^2.2.1",
+        "nopt": "^4.0.1",
+        "npm-packlist": "^1.1.6",
+        "npmlog": "^4.0.2",
+        "rc": "^1.2.7",
+        "rimraf": "^2.6.1",
+        "semver": "^5.3.0",
+        "tar": "^4.4.2"
+      }
+    },
+    "nopt": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz",
+      "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=",
+      "requires": {
+        "abbrev": "1",
+        "osenv": "^0.1.4"
+      }
+    },
+    "normalize-url": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz",
+      "integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg=="
+    },
+    "npm-bundled": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.1.tgz",
+      "integrity": "sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==",
+      "requires": {
+        "npm-normalize-package-bin": "^1.0.1"
+      }
+    },
+    "npm-normalize-package-bin": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz",
+      "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA=="
+    },
+    "npm-packlist": {
+      "version": "1.4.8",
+      "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.8.tgz",
+      "integrity": "sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==",
+      "requires": {
+        "ignore-walk": "^3.0.1",
+        "npm-bundled": "^1.0.1",
+        "npm-normalize-package-bin": "^1.0.1"
+      }
+    },
+    "npm-run-path": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
+      "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=",
+      "requires": {
+        "path-key": "^2.0.0"
+      }
+    },
+    "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="
+    },
+    "oauth-sign": {
+      "version": "0.9.0",
+      "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
+      "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="
+    },
+    "object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+    },
+    "object-component": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz",
+      "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE="
+    },
+    "object-hash": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.1.tgz",
+      "integrity": "sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA=="
+    },
+    "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"
+      }
+    },
+    "onetime": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz",
+      "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=",
+      "requires": {
+        "mimic-fn": "^1.0.0"
+      }
+    },
+    "open": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/open/-/open-7.0.3.tgz",
+      "integrity": "sha512-sP2ru2v0P290WFfv49Ap8MF6PkzGNnGlAwHweB4WR4mr5d2d0woiCluUeJ218w7/+PmoBy9JmYgD5A4mLcWOFA==",
+      "requires": {
+        "is-docker": "^2.0.0",
+        "is-wsl": "^2.1.1"
+      }
+    },
+    "optionator": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
+      "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
+      "requires": {
+        "deep-is": "~0.1.3",
+        "fast-levenshtein": "~2.0.6",
+        "levn": "~0.3.0",
+        "prelude-ls": "~1.1.2",
+        "type-check": "~0.3.2",
+        "word-wrap": "~1.2.3"
+      }
+    },
+    "os-homedir": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
+      "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M="
+    },
+    "os-locale": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz",
+      "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=",
+      "requires": {
+        "lcid": "^1.0.0"
+      }
+    },
+    "os-name": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz",
+      "integrity": "sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==",
+      "requires": {
+        "macos-release": "^2.2.0",
+        "windows-release": "^3.1.0"
+      }
+    },
+    "os-tmpdir": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+      "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
+    },
+    "osenv": {
+      "version": "0.1.5",
+      "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz",
+      "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==",
+      "requires": {
+        "os-homedir": "^1.0.0",
+        "os-tmpdir": "^1.0.0"
+      }
+    },
+    "p-finally": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+      "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
+    },
+    "p-map": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+      "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="
+    },
+    "pac-proxy-agent": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-3.0.1.tgz",
+      "integrity": "sha512-44DUg21G/liUZ48dJpUSjZnFfZro/0K5JTyFYLBcmh9+T6Ooi4/i4efwUiEy0+4oQusCBqWdhv16XohIj1GqnQ==",
+      "requires": {
+        "agent-base": "^4.2.0",
+        "debug": "^4.1.1",
+        "get-uri": "^2.0.0",
+        "http-proxy-agent": "^2.1.0",
+        "https-proxy-agent": "^3.0.0",
+        "pac-resolver": "^3.0.0",
+        "raw-body": "^2.2.0",
+        "socks-proxy-agent": "^4.0.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "pac-resolver": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-3.0.0.tgz",
+      "integrity": "sha512-tcc38bsjuE3XZ5+4vP96OfhOugrX+JcnpUbhfuc4LuXBLQhoTthOstZeoQJBDnQUDYzYmdImKsbz0xSl1/9qeA==",
+      "requires": {
+        "co": "^4.6.0",
+        "degenerator": "^1.0.4",
+        "ip": "^1.1.5",
+        "netmask": "^1.0.6",
+        "thunkify": "^2.1.2"
+      }
+    },
+    "package-json": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/package-json/-/package-json-4.0.1.tgz",
+      "integrity": "sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=",
+      "requires": {
+        "got": "^6.7.1",
+        "registry-auth-token": "^3.0.1",
+        "registry-url": "^3.0.3",
+        "semver": "^5.1.0"
+      }
+    },
+    "pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+    },
+    "parse-path": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-4.0.1.tgz",
+      "integrity": "sha512-d7yhga0Oc+PwNXDvQ0Jv1BuWkLVPXcAoQ/WREgd6vNNoKYaW52KI+RdOFjI63wjkmps9yUE8VS4veP+AgpQ/hA==",
+      "requires": {
+        "is-ssh": "^1.3.0",
+        "protocols": "^1.4.0"
+      }
+    },
+    "parse-url": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-5.0.1.tgz",
+      "integrity": "sha512-flNUPP27r3vJpROi0/R3/2efgKkyXqnXwyP1KQ2U0SfFRgdizOdWfvrrvJg1LuOoxs7GQhmxJlq23IpQ/BkByg==",
+      "requires": {
+        "is-ssh": "^1.3.0",
+        "normalize-url": "^3.3.0",
+        "parse-path": "^4.0.0",
+        "protocols": "^1.4.0"
+      }
+    },
+    "parseqs": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
+      "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=",
+      "requires": {
+        "better-assert": "~1.0.0"
+      }
+    },
+    "parseuri": {
+      "version": "0.0.5",
+      "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz",
+      "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=",
+      "requires": {
+        "better-assert": "~1.0.0"
+      }
+    },
+    "parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
+    },
+    "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-is-inside": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
+      "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM="
+    },
+    "path-key": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+      "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A="
+    },
+    "path-proxy": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/path-proxy/-/path-proxy-1.0.0.tgz",
+      "integrity": "sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=",
+      "requires": {
+        "inflection": "~1.3.0"
+      },
+      "dependencies": {
+        "inflection": {
+          "version": "1.3.8",
+          "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.3.8.tgz",
+          "integrity": "sha1-y9Fg2p91sUw8xjV41POWeEvzAU4="
+        }
+      }
+    },
+    "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="
+    },
+    "performance-now": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+      "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns="
+    },
+    "pify": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+      "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY="
+    },
+    "prelude-ls": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
+      "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ="
+    },
+    "prepend-http": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
+      "integrity": "sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw="
+    },
+    "prettier": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
+      "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew=="
+    },
+    "prism-media": {
+      "version": "0.0.4",
+      "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-0.0.4.tgz",
+      "integrity": "sha512-dG2w7WtovUa4SiYTdWn9H8Bd4JNdei2djtkP/Bk9fXq81j5Q15ZPHYSwhUVvBRbp5zMkGtu0Yk62HuMcly0pRw=="
+    },
+    "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=="
+    },
+    "promise": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
+      "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
+      "requires": {
+        "asap": "~2.0.3"
+      }
+    },
+    "promisify-call": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/promisify-call/-/promisify-call-2.0.4.tgz",
+      "integrity": "sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=",
+      "requires": {
+        "with-callback": "^1.0.2"
+      }
+    },
+    "protocols": {
+      "version": "1.4.7",
+      "resolved": "https://registry.npmjs.org/protocols/-/protocols-1.4.7.tgz",
+      "integrity": "sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg=="
+    },
+    "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"
+      }
+    },
+    "proxy-agent": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-3.1.1.tgz",
+      "integrity": "sha512-WudaR0eTsDx33O3EJE16PjBRZWcX8GqCEeERw1W3hZJgH/F2a46g7jty6UGty6NeJ4CKQy8ds2CJPMiyeqaTvw==",
+      "requires": {
+        "agent-base": "^4.2.0",
+        "debug": "4",
+        "http-proxy-agent": "^2.1.0",
+        "https-proxy-agent": "^3.0.0",
+        "lru-cache": "^5.1.1",
+        "pac-proxy-agent": "^3.0.1",
+        "proxy-from-env": "^1.0.0",
+        "socks-proxy-agent": "^4.0.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "proxy-from-env": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz",
+      "integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4="
+    },
+    "pseudomap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+      "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM="
+    },
+    "psl": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.7.0.tgz",
+      "integrity": "sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ=="
+    },
+    "pump": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+      "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+      "requires": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.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"
+      }
+    },
+    "rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "requires": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "dependencies": {
+        "minimist": {
+          "version": "1.2.5",
+          "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+          "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
+        }
+      }
+    },
+    "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": "2.8.0",
+      "resolved": "https://registry.npmjs.org/redis/-/redis-2.8.0.tgz",
+      "integrity": "sha512-M1OkonEQwtRmZv4tEWF2VgpG0JWJ8Fv1PhlgT5+B+uNq2cA3Rt1Yt/ryoR+vQNOQcIEgdCdfH0jr3bDpihAw1A==",
+      "requires": {
+        "double-ended-queue": "^2.1.0-0",
+        "redis-commands": "^1.2.0",
+        "redis-parser": "^2.6.0"
+      }
+    },
+    "redis-commands": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.5.0.tgz",
+      "integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg=="
+    },
+    "redis-parser": {
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz",
+      "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs="
+    },
+    "regexp-clone": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz",
+      "integrity": "sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw=="
+    },
+    "registry-auth-token": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.4.0.tgz",
+      "integrity": "sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==",
+      "requires": {
+        "rc": "^1.1.6",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "registry-url": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz",
+      "integrity": "sha1-PU74cPc93h138M+aOBQyRE4XSUI=",
+      "requires": {
+        "rc": "^1.0.1"
+      }
+    },
+    "request": {
+      "version": "2.88.2",
+      "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz",
+      "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
+      "requires": {
+        "aws-sign2": "~0.7.0",
+        "aws4": "^1.8.0",
+        "caseless": "~0.12.0",
+        "combined-stream": "~1.0.6",
+        "extend": "~3.0.2",
+        "forever-agent": "~0.6.1",
+        "form-data": "~2.3.2",
+        "har-validator": "~5.1.3",
+        "http-signature": "~1.2.0",
+        "is-typedarray": "~1.0.0",
+        "isstream": "~0.1.2",
+        "json-stringify-safe": "~5.0.1",
+        "mime-types": "~2.1.19",
+        "oauth-sign": "~0.9.0",
+        "performance-now": "^2.1.0",
+        "qs": "~6.5.2",
+        "safe-buffer": "^5.1.2",
+        "tough-cookie": "~2.5.0",
+        "tunnel-agent": "^0.6.0",
+        "uuid": "^3.3.2"
+      },
+      "dependencies": {
+        "form-data": {
+          "version": "2.3.3",
+          "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+          "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+          "requires": {
+            "asynckit": "^0.4.0",
+            "combined-stream": "^1.0.6",
+            "mime-types": "^2.1.12"
+          }
+        },
+        "qs": {
+          "version": "6.5.2",
+          "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+          "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA=="
+        }
+      }
+    },
+    "require_optional": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.1.tgz",
+      "integrity": "sha512-qhM/y57enGWHAe3v/NcwML6a3/vfESLe/sGM2dII+gEO0BpKRUkWZow/tyloNqJyN6kXSl3RyyM8Ll5D/sJP8g==",
+      "requires": {
+        "resolve-from": "^2.0.0",
+        "semver": "^5.1.0"
+      }
+    },
+    "resolve-from": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
+      "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
+    },
+    "restore-cursor": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz",
+      "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=",
+      "requires": {
+        "onetime": "^2.0.0",
+        "signal-exit": "^3.0.2"
+      }
+    },
+    "rimraf": {
+      "version": "2.7.1",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+      "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "run-async": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
+      "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="
+    },
+    "rxjs": {
+      "version": "6.5.5",
+      "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz",
+      "integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==",
+      "requires": {
+        "tslib": "^1.9.0"
+      }
+    },
+    "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"
+      }
+    },
+    "sax": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+      "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
+    },
+    "secure-keys": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/secure-keys/-/secure-keys-1.0.0.tgz",
+      "integrity": "sha1-8MgtmKOxOah3aogIBQuCRDEIf8o="
+    },
+    "semver": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="
+    },
+    "semver-diff": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-2.1.0.tgz",
+      "integrity": "sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=",
+      "requires": {
+        "semver": "^5.0.3"
+      }
+    },
+    "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": {
+        "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"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
+          }
+        },
+        "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="
+    },
+    "set-immediate-shim": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz",
+      "integrity": "sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E="
+    },
+    "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": "1.2.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+      "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
+      "requires": {
+        "shebang-regex": "^1.0.0"
+      }
+    },
+    "shebang-regex": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+      "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM="
+    },
+    "sift": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/sift/-/sift-7.0.1.tgz",
+      "integrity": "sha512-oqD7PMJ+uO6jV9EQCl0LrRw1OwsiPsiFQR5AR30heR+4Dl7jBBbDLnNvWiak20tzZlSE1H7RB30SX/1j/YYT7g=="
+    },
+    "signal-exit": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+      "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
+    },
+    "sliced": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz",
+      "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E="
+    },
+    "smart-buffer": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.1.0.tgz",
+      "integrity": "sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw=="
+    },
+    "snekfetch": {
+      "version": "3.6.4",
+      "resolved": "https://registry.npmjs.org/snekfetch/-/snekfetch-3.6.4.tgz",
+      "integrity": "sha512-NjxjITIj04Ffqid5lqr7XdgwM7X61c/Dns073Ly170bPQHLm6jkmelye/eglS++1nfTWktpP6Y2bFXjdPlQqdw=="
+    },
+    "snyk": {
+      "version": "1.316.1",
+      "resolved": "https://registry.npmjs.org/snyk/-/snyk-1.316.1.tgz",
+      "integrity": "sha512-z+LWT14QoOyOsqCcWGkTNlIBpCQmv7E+kMX3r43/vZeU1F9E6Ll+KVcu1simXZixZaOb97IHHIf4rxxYGw65MA==",
+      "requires": {
+        "@snyk/cli-interface": "2.6.0",
+        "@snyk/configstore": "^3.2.0-rc1",
+        "@snyk/dep-graph": "1.18.2",
+        "@snyk/gemfile": "1.2.0",
+        "@snyk/graphlib": "2.1.9-patch",
+        "@snyk/inquirer": "6.2.2-patch",
+        "@snyk/lodash": "^4.17.15-patch",
+        "@snyk/ruby-semver": "2.2.0",
+        "@snyk/snyk-cocoapods-plugin": "2.2.0",
+        "@snyk/update-notifier": "^2.5.1-rc2",
+        "@types/agent-base": "^4.2.0",
+        "abbrev": "^1.1.1",
+        "ansi-escapes": "3.2.0",
+        "chalk": "^2.4.2",
+        "cli-spinner": "0.2.10",
+        "debug": "^3.1.0",
+        "diff": "^4.0.1",
+        "git-url-parse": "11.1.2",
+        "glob": "^7.1.3",
+        "needle": "^2.2.4",
+        "open": "^7.0.3",
+        "os-name": "^3.0.0",
+        "proxy-agent": "^3.1.1",
+        "proxy-from-env": "^1.0.0",
+        "semver": "^6.0.0",
+        "snyk-config": "3.1.0",
+        "snyk-docker-plugin": "3.1.0",
+        "snyk-go-plugin": "1.14.0",
+        "snyk-gradle-plugin": "3.2.5",
+        "snyk-module": "1.9.1",
+        "snyk-mvn-plugin": "2.15.0",
+        "snyk-nodejs-lockfile-parser": "1.22.0",
+        "snyk-nuget-plugin": "1.17.0",
+        "snyk-php-plugin": "1.9.0",
+        "snyk-policy": "1.13.5",
+        "snyk-python-plugin": "1.17.0",
+        "snyk-resolve": "1.0.1",
+        "snyk-resolve-deps": "4.4.0",
+        "snyk-sbt-plugin": "2.11.0",
+        "snyk-tree": "^1.0.0",
+        "snyk-try-require": "1.3.1",
+        "source-map-support": "^0.5.11",
+        "strip-ansi": "^5.2.0",
+        "tempfile": "^2.0.0",
+        "then-fs": "^2.0.0",
+        "uuid": "^3.3.2",
+        "wrap-ansi": "^5.1.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+          "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
+        },
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
+        },
+        "strip-ansi": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+          "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+          "requires": {
+            "ansi-regex": "^4.1.0"
+          }
+        }
+      }
+    },
+    "snyk-config": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/snyk-config/-/snyk-config-3.1.0.tgz",
+      "integrity": "sha512-3UlyogA67/9WOssJ7s4d7gqWQRWyO/LbgdBBNMhhmFDKa7eTUSW+A782CVHgyDSJZ2kNANcMWwMiOL+h3p6zQg==",
+      "requires": {
+        "@snyk/lodash": "4.17.15-patch",
+        "debug": "^4.1.1",
+        "nconf": "^0.10.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "snyk-docker-plugin": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/snyk-docker-plugin/-/snyk-docker-plugin-3.1.0.tgz",
+      "integrity": "sha512-ggGTiiCuwLYGdlGW/UBuUXJ7omliH0EnbpLfdlTBoRKvmvgoUo1l4Menk18R1ZVXgcXTwwGK9jmuUpPH+X0VNw==",
+      "requires": {
+        "@snyk/rpm-parser": "^1.1.0",
+        "debug": "^4.1.1",
+        "dockerfile-ast": "0.0.19",
+        "event-loop-spinner": "^1.1.0",
+        "semver": "^6.1.0",
+        "tar-stream": "^2.1.0",
+        "tslib": "^1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
+        }
+      }
+    },
+    "snyk-go-parser": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/snyk-go-parser/-/snyk-go-parser-1.4.0.tgz",
+      "integrity": "sha512-zcLA8u/WreycCjFKBblYfxszg7Fmnemuu9Ug/CE/jqF0yBXsI5DCWMteUvFkoa8DRntfGTlgf98TRl2aTSc2MQ==",
+      "requires": {
+        "toml": "^3.0.0",
+        "tslib": "^1.10.0"
+      }
+    },
+    "snyk-go-plugin": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/snyk-go-plugin/-/snyk-go-plugin-1.14.0.tgz",
+      "integrity": "sha512-9L+76De8F6yXWb+O3DA8QUi7+eDF2mOzCOveEPUJGkqWIDmurIiFcVxHJoj0EStjcxb3dX367KKlDlfFx+HiyA==",
+      "requires": {
+        "@snyk/graphlib": "2.1.9-patch",
+        "debug": "^4.1.1",
+        "snyk-go-parser": "1.4.0",
+        "tmp": "0.1.0",
+        "tslib": "^1.10.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "tmp": {
+          "version": "0.1.0",
+          "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
+          "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
+          "requires": {
+            "rimraf": "^2.6.3"
+          }
+        }
+      }
+    },
+    "snyk-gradle-plugin": {
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/snyk-gradle-plugin/-/snyk-gradle-plugin-3.2.5.tgz",
+      "integrity": "sha512-XxPi/B16dGkV1USoyFbpn6LlSJ9SUC6Y6z/4lWuF4spLnKtWwpEb1bwTdBFsxnkUfqzIRtPr0+wcxxXvv9Rvcw==",
+      "requires": {
+        "@snyk/cli-interface": "2.3.0",
+        "@types/debug": "^4.1.4",
+        "chalk": "^2.4.2",
+        "debug": "^4.1.1",
+        "tmp": "0.0.33",
+        "tslib": "^1.9.3"
+      },
+      "dependencies": {
+        "@snyk/cli-interface": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/@snyk/cli-interface/-/cli-interface-2.3.0.tgz",
+          "integrity": "sha512-ecbylK5Ol2ySb/WbfPj0s0GuLQR+KWKFzUgVaoNHaSoN6371qRWwf2uVr+hPUP4gXqCai21Ug/RDArfOhlPwrQ==",
+          "requires": {
+            "tslib": "^1.9.3"
+          }
+        },
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "snyk-module": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/snyk-module/-/snyk-module-1.9.1.tgz",
+      "integrity": "sha512-A+CCyBSa4IKok5uEhqT+hV/35RO6APFNLqk9DRRHg7xW2/j//nPX8wTSZUPF8QeRNEk/sX+6df7M1y6PBHGSHA==",
+      "requires": {
+        "debug": "^3.1.0",
+        "hosted-git-info": "^2.7.1"
+      }
+    },
+    "snyk-mvn-plugin": {
+      "version": "2.15.0",
+      "resolved": "https://registry.npmjs.org/snyk-mvn-plugin/-/snyk-mvn-plugin-2.15.0.tgz",
+      "integrity": "sha512-24HWz27Hc5sw+iHtxtQFy0kltjyFZXJ3vfsPA0TTZAL0tOJXInIuZpWD6njC0Y3/sn9CH5kS2KM8GAM7FyKVig==",
+      "requires": {
+        "@snyk/cli-interface": "2.5.0",
+        "@snyk/java-call-graph-builder": "1.8.0",
+        "debug": "^4.1.1",
+        "needle": "^2.4.0",
+        "tmp": "^0.1.0",
+        "tslib": "1.11.1"
+      },
+      "dependencies": {
+        "@snyk/cli-interface": {
+          "version": "2.5.0",
+          "resolved": "https://registry.npmjs.org/@snyk/cli-interface/-/cli-interface-2.5.0.tgz",
+          "integrity": "sha512-XMc2SCFH4RBSncZgoPb+BBlNq0NYpEpCzptKi69qyMpBy0VsRqIQqddedaazMCU1xEpXTytq6KMYpzUafZzp5Q==",
+          "requires": {
+            "tslib": "^1.9.3"
+          }
+        },
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "needle": {
+          "version": "2.4.1",
+          "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.1.tgz",
+          "integrity": "sha512-x/gi6ijr4B7fwl6WYL9FwlCvRQKGlUNvnceho8wxkwXqN8jvVmmmATTmZPRRG7b/yC1eode26C2HO9jl78Du9g==",
+          "requires": {
+            "debug": "^3.2.6",
+            "iconv-lite": "^0.4.4",
+            "sax": "^1.2.4"
+          },
+          "dependencies": {
+            "debug": {
+              "version": "3.2.6",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
+              "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+              "requires": {
+                "ms": "^2.1.1"
+              }
+            }
+          }
+        },
+        "tmp": {
+          "version": "0.1.0",
+          "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
+          "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
+          "requires": {
+            "rimraf": "^2.6.3"
+          }
+        }
+      }
+    },
+    "snyk-nodejs-lockfile-parser": {
+      "version": "1.22.0",
+      "resolved": "https://registry.npmjs.org/snyk-nodejs-lockfile-parser/-/snyk-nodejs-lockfile-parser-1.22.0.tgz",
+      "integrity": "sha512-l6jLoJxqcIIkQopSdQuAstXdMw5AIgLu+uGc5CYpHyw8fYqOwna8rawwofNeGuwJAAv4nEiNiexeYaR88OCq6Q==",
+      "requires": {
+        "@snyk/graphlib": "2.1.9-patch",
+        "@snyk/lodash": "^4.17.15-patch",
+        "@yarnpkg/lockfile": "^1.0.2",
+        "event-loop-spinner": "^1.1.0",
+        "p-map": "2.1.0",
+        "snyk-config": "^3.0.0",
+        "source-map-support": "^0.5.7",
+        "tslib": "^1.9.3",
+        "uuid": "^3.3.2"
+      }
+    },
+    "snyk-nuget-plugin": {
+      "version": "1.17.0",
+      "resolved": "https://registry.npmjs.org/snyk-nuget-plugin/-/snyk-nuget-plugin-1.17.0.tgz",
+      "integrity": "sha512-t7iZ87LBhCK6P2/mJsQh7Dmk3J9zd+IHL4yoSK95Iyk/gP8r++DZijoRHEXy8BlS+eOtSAj1vgCYvv2eAmG28w==",
+      "requires": {
+        "@snyk/lodash": "4.17.15-patch",
+        "debug": "^3.1.0",
+        "dotnet-deps-parser": "4.10.0",
+        "jszip": "3.1.5",
+        "snyk-paket-parser": "1.6.0",
+        "tslib": "^1.9.3",
+        "xml2js": "^0.4.17"
+      },
+      "dependencies": {
+        "core-js": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.3.0.tgz",
+          "integrity": "sha1-+rg/uwstjchfpjbEudNMdUIMbWU="
+        },
+        "es6-promise": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.0.2.tgz",
+          "integrity": "sha1-AQ1YWEI6XxGJeWZfRkhqlcbuK7Y="
+        },
+        "jszip": {
+          "version": "3.1.5",
+          "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.1.5.tgz",
+          "integrity": "sha512-5W8NUaFRFRqTOL7ZDDrx5qWHJyBXy6velVudIzQUSoqAAYqzSh2Z7/m0Rf1QbmQJccegD0r+YZxBjzqoBiEeJQ==",
+          "requires": {
+            "core-js": "~2.3.0",
+            "es6-promise": "~3.0.2",
+            "lie": "~3.1.0",
+            "pako": "~1.0.2",
+            "readable-stream": "~2.0.6"
+          }
+        },
+        "lie": {
+          "version": "3.1.1",
+          "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
+          "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=",
+          "requires": {
+            "immediate": "~3.0.5"
+          }
+        },
+        "process-nextick-args": {
+          "version": "1.0.7",
+          "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
+          "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
+        },
+        "readable-stream": {
+          "version": "2.0.6",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz",
+          "integrity": "sha1-j5A0HmilPMySh4jaz80Rs265t44=",
+          "requires": {
+            "core-util-is": "~1.0.0",
+            "inherits": "~2.0.1",
+            "isarray": "~1.0.0",
+            "process-nextick-args": "~1.0.6",
+            "string_decoder": "~0.10.x",
+            "util-deprecate": "~1.0.1"
+          }
+        },
+        "string_decoder": {
+          "version": "0.10.31",
+          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+          "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
+        }
+      }
+    },
+    "snyk-paket-parser": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/snyk-paket-parser/-/snyk-paket-parser-1.6.0.tgz",
+      "integrity": "sha512-6htFynjBe/nakclEHUZ1A3j5Eu32/0pNve5Qm4MFn3YQmJgj7UcAO8hdyK3QfzEY29/kAv/rkJQg+SKshn+N9Q==",
+      "requires": {
+        "tslib": "^1.9.3"
+      }
+    },
+    "snyk-php-plugin": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/snyk-php-plugin/-/snyk-php-plugin-1.9.0.tgz",
+      "integrity": "sha512-uORrEoC47dw0ITZYu5vKqQtmXnbbQs+ZkWeo5bRHGdf10W8e4rNr1S1R4bReiLrSbSisYhVHeFMkdOAiLIPJVQ==",
+      "requires": {
+        "@snyk/cli-interface": "2.3.2",
+        "@snyk/composer-lockfile-parser": "1.4.0",
+        "tslib": "1.11.1"
+      },
+      "dependencies": {
+        "@snyk/cli-interface": {
+          "version": "2.3.2",
+          "resolved": "https://registry.npmjs.org/@snyk/cli-interface/-/cli-interface-2.3.2.tgz",
+          "integrity": "sha512-jmZyxVHqzYU1GfdnWCGdd68WY/lAzpPVyqalHazPj4tFJehrSfEFc82RMTYAMgXEJuvFRFIwhsvXh3sWUhIQmg==",
+          "requires": {
+            "tslib": "^1.9.3"
+          }
+        }
+      }
+    },
+    "snyk-policy": {
+      "version": "1.13.5",
+      "resolved": "https://registry.npmjs.org/snyk-policy/-/snyk-policy-1.13.5.tgz",
+      "integrity": "sha512-KI6GHt+Oj4fYKiCp7duhseUj5YhyL/zJOrrJg0u6r59Ux9w8gmkUYT92FHW27ihwuT6IPzdGNEuy06Yv2C9WaQ==",
+      "requires": {
+        "debug": "^3.1.0",
+        "email-validator": "^2.0.4",
+        "js-yaml": "^3.13.1",
+        "lodash.clonedeep": "^4.5.0",
+        "semver": "^6.0.0",
+        "snyk-module": "^1.9.1",
+        "snyk-resolve": "^1.0.1",
+        "snyk-try-require": "^1.3.1",
+        "then-fs": "^2.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=="
+        }
+      }
+    },
+    "snyk-python-plugin": {
+      "version": "1.17.0",
+      "resolved": "https://registry.npmjs.org/snyk-python-plugin/-/snyk-python-plugin-1.17.0.tgz",
+      "integrity": "sha512-EKdVOUlvhiVpXA5TeW8vyxYVqbITAfT+2AbL2ZRiiUNLP5ae+WiNYaPy7aB5HAS9IKBKih+IH8Ag65Xu1IYSYA==",
+      "requires": {
+        "@snyk/cli-interface": "^2.0.3",
+        "tmp": "0.0.33"
+      }
+    },
+    "snyk-resolve": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/snyk-resolve/-/snyk-resolve-1.0.1.tgz",
+      "integrity": "sha512-7+i+LLhtBo1Pkth01xv+RYJU8a67zmJ8WFFPvSxyCjdlKIcsps4hPQFebhz+0gC5rMemlaeIV6cqwqUf9PEDpw==",
+      "requires": {
+        "debug": "^3.1.0",
+        "then-fs": "^2.0.0"
+      }
+    },
+    "snyk-resolve-deps": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/snyk-resolve-deps/-/snyk-resolve-deps-4.4.0.tgz",
+      "integrity": "sha512-aFPtN8WLqIk4E1ulMyzvV5reY1Iksz+3oPnUVib1jKdyTHymmOIYF7z8QZ4UUr52UsgmrD9EA/dq7jpytwFoOQ==",
+      "requires": {
+        "@types/node": "^6.14.4",
+        "@types/semver": "^5.5.0",
+        "ansicolors": "^0.3.2",
+        "debug": "^3.2.5",
+        "lodash.assign": "^4.2.0",
+        "lodash.assignin": "^4.2.0",
+        "lodash.clone": "^4.5.0",
+        "lodash.flatten": "^4.4.0",
+        "lodash.get": "^4.4.2",
+        "lodash.set": "^4.3.2",
+        "lru-cache": "^4.0.0",
+        "semver": "^5.5.1",
+        "snyk-module": "^1.6.0",
+        "snyk-resolve": "^1.0.0",
+        "snyk-tree": "^1.0.0",
+        "snyk-try-require": "^1.1.1",
+        "then-fs": "^2.0.0"
+      },
+      "dependencies": {
+        "@types/node": {
+          "version": "6.14.10",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-6.14.10.tgz",
+          "integrity": "sha512-pF4HjZGSog75kGq7B1InK/wt/N08BuPATo+7HRfv7gZUzccebwv/fmWVGs/j6LvSiLWpCuGGhql51M/wcQsNzA=="
+        },
+        "lru-cache": {
+          "version": "4.1.5",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
+          "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
+          "requires": {
+            "pseudomap": "^1.0.2",
+            "yallist": "^2.1.2"
+          }
+        },
+        "yallist": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+          "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
+        }
+      }
+    },
+    "snyk-sbt-plugin": {
+      "version": "2.11.0",
+      "resolved": "https://registry.npmjs.org/snyk-sbt-plugin/-/snyk-sbt-plugin-2.11.0.tgz",
+      "integrity": "sha512-wUqHLAa3MzV6sVO+05MnV+lwc+T6o87FZZaY+43tQPytBI2Wq23O3j4POREM4fa2iFfiQJoEYD6c7xmhiEUsSA==",
+      "requires": {
+        "debug": "^4.1.1",
+        "semver": "^6.1.2",
+        "tmp": "^0.1.0",
+        "tree-kill": "^1.2.2",
+        "tslib": "^1.10.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
+        },
+        "tmp": {
+          "version": "0.1.0",
+          "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.1.0.tgz",
+          "integrity": "sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==",
+          "requires": {
+            "rimraf": "^2.6.3"
+          }
+        }
+      }
+    },
+    "snyk-tree": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/snyk-tree/-/snyk-tree-1.0.0.tgz",
+      "integrity": "sha1-D7cxdtvzLngvGRAClBYESPkRHMg=",
+      "requires": {
+        "archy": "^1.0.0"
+      }
+    },
+    "snyk-try-require": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/snyk-try-require/-/snyk-try-require-1.3.1.tgz",
+      "integrity": "sha1-bgJvkuZK9/zM6h7lPVJIQeQYohI=",
+      "requires": {
+        "debug": "^3.1.0",
+        "lodash.clonedeep": "^4.3.0",
+        "lru-cache": "^4.0.0",
+        "then-fs": "^2.0.0"
+      },
+      "dependencies": {
+        "lru-cache": {
+          "version": "4.1.5",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
+          "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
+          "requires": {
+            "pseudomap": "^1.0.2",
+            "yallist": "^2.1.2"
+          }
+        },
+        "yallist": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+          "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
+        }
+      }
+    },
+    "socket.io": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-2.3.0.tgz",
+      "integrity": "sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==",
+      "requires": {
+        "debug": "~4.1.0",
+        "engine.io": "~3.4.0",
+        "has-binary2": "~1.0.2",
+        "socket.io-adapter": "~1.1.0",
+        "socket.io-client": "2.3.0",
+        "socket.io-parser": "~3.4.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "socket.io-adapter": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz",
+      "integrity": "sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g=="
+    },
+    "socket.io-client": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-2.3.0.tgz",
+      "integrity": "sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==",
+      "requires": {
+        "backo2": "1.0.2",
+        "base64-arraybuffer": "0.1.5",
+        "component-bind": "1.0.0",
+        "component-emitter": "1.2.1",
+        "debug": "~4.1.0",
+        "engine.io-client": "~3.4.0",
+        "has-binary2": "~1.0.2",
+        "has-cors": "1.1.0",
+        "indexof": "0.0.1",
+        "object-component": "0.0.3",
+        "parseqs": "0.0.5",
+        "parseuri": "0.0.5",
+        "socket.io-parser": "~3.3.0",
+        "to-array": "0.1.4"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "isarray": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+          "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4="
+        },
+        "socket.io-parser": {
+          "version": "3.3.0",
+          "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.3.0.tgz",
+          "integrity": "sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==",
+          "requires": {
+            "component-emitter": "1.2.1",
+            "debug": "~3.1.0",
+            "isarray": "2.0.1"
+          },
+          "dependencies": {
+            "debug": {
+              "version": "3.1.0",
+              "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+              "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+              "requires": {
+                "ms": "2.0.0"
+              }
+            },
+            "ms": {
+              "version": "2.0.0",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+              "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+            }
+          }
+        }
+      }
+    },
+    "socket.io-parser": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-3.4.0.tgz",
+      "integrity": "sha512-/G/VOI+3DBp0+DJKW4KesGnQkQPFmUCbA/oO2QGT6CWxU7hLGWqU3tyuzeSK/dqcyeHsQg1vTe9jiZI8GU9SCQ==",
+      "requires": {
+        "component-emitter": "1.2.1",
+        "debug": "~4.1.0",
+        "isarray": "2.0.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "4.1.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+          "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "isarray": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.1.tgz",
+          "integrity": "sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4="
+        }
+      }
+    },
+    "socks": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/socks/-/socks-2.3.3.tgz",
+      "integrity": "sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==",
+      "requires": {
+        "ip": "1.1.5",
+        "smart-buffer": "^4.1.0"
+      }
+    },
+    "socks-proxy-agent": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz",
+      "integrity": "sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==",
+      "requires": {
+        "agent-base": "~4.2.1",
+        "socks": "~2.3.2"
+      },
+      "dependencies": {
+        "agent-base": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz",
+          "integrity": "sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==",
+          "requires": {
+            "es6-promisify": "^5.0.0"
+          }
+        }
+      }
+    },
+    "source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+    },
+    "source-map-support": {
+      "version": "0.5.19",
+      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
+      "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
+      "requires": {
+        "buffer-from": "^1.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "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"
+      }
+    },
+    "sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
+    },
+    "sshpk": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
+      "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==",
+      "requires": {
+        "asn1": "~0.2.3",
+        "assert-plus": "^1.0.0",
+        "bcrypt-pbkdf": "^1.0.0",
+        "dashdash": "^1.12.0",
+        "ecc-jsbn": "~0.1.1",
+        "getpass": "^0.1.1",
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.0.2",
+        "tweetnacl": "~0.14.0"
+      },
+      "dependencies": {
+        "tweetnacl": {
+          "version": "0.14.5",
+          "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+          "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
+        }
+      }
+    },
+    "statuses": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
+      "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
+    },
+    "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"
+      }
+    },
+    "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"
+      }
+    },
+    "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"
+      }
+    },
+    "strip-eof": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+      "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8="
+    },
+    "strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
+    },
+    "supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "requires": {
+        "has-flag": "^3.0.0"
+      }
+    },
+    "tar": {
+      "version": "4.4.13",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz",
+      "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==",
+      "requires": {
+        "chownr": "^1.1.1",
+        "fs-minipass": "^1.2.5",
+        "minipass": "^2.8.6",
+        "minizlib": "^1.2.1",
+        "mkdirp": "^0.5.0",
+        "safe-buffer": "^5.1.2",
+        "yallist": "^3.0.3"
+      }
+    },
+    "tar-stream": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.2.tgz",
+      "integrity": "sha512-UaF6FoJ32WqALZGOIAApXx+OdxhekNMChu6axLJR85zMMjXKWFGjbIRe+J6P4UnRGg9rAwWvbTT0oI7hD/Un7Q==",
+      "requires": {
+        "bl": "^4.0.1",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "dependencies": {
+        "bl": {
+          "version": "4.0.2",
+          "resolved": "https://registry.npmjs.org/bl/-/bl-4.0.2.tgz",
+          "integrity": "sha512-j4OH8f6Qg2bGuWfRiltT2HYGx0e1QcBTrK9KAHNMwMZdQnDZFk0ZSYIpADjYCB3U12nicC5tVJwSIhwOWjb4RQ==",
+          "requires": {
+            "buffer": "^5.5.0",
+            "inherits": "^2.0.4",
+            "readable-stream": "^3.4.0"
+          }
+        },
+        "readable-stream": {
+          "version": "3.6.0",
+          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+          "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+          "requires": {
+            "inherits": "^2.0.3",
+            "string_decoder": "^1.1.1",
+            "util-deprecate": "^1.0.1"
+          }
+        }
+      }
+    },
+    "temp-dir": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+      "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="
+    },
+    "tempfile": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz",
+      "integrity": "sha1-awRGhWqbERTRhW/8vlCczLCXcmU=",
+      "requires": {
+        "temp-dir": "^1.0.0",
+        "uuid": "^3.0.1"
+      },
+      "dependencies": {
+        "temp-dir": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
+          "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0="
+        }
+      }
+    },
+    "term-size": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz",
+      "integrity": "sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=",
+      "requires": {
+        "execa": "^0.7.0"
+      }
+    },
+    "then-fs": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/then-fs/-/then-fs-2.0.0.tgz",
+      "integrity": "sha1-cveS3Z0xcFqRrhnr/Piz+WjIHaI=",
+      "requires": {
+        "promise": ">=3.2 <8"
+      }
+    },
+    "through": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+      "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="
+    },
+    "thunkify": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/thunkify/-/thunkify-2.1.2.tgz",
+      "integrity": "sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0="
+    },
+    "timed-out": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
+      "integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8="
+    },
+    "tmp": {
+      "version": "0.0.33",
+      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
+      "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
+      "requires": {
+        "os-tmpdir": "~1.0.2"
+      }
+    },
+    "to-array": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz",
+      "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA="
+    },
+    "toidentifier": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz",
+      "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw=="
+    },
+    "toml": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
+      "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
+    },
+    "tough-cookie": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
+      "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
+      "requires": {
+        "psl": "^1.1.28",
+        "punycode": "^2.1.1"
+      }
+    },
+    "tree-kill": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+      "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="
+    },
+    "tslib": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz",
+      "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA=="
+    },
+    "tsscmp": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
+      "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA=="
+    },
+    "tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+      "requires": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "tweetnacl": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
+      "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
+    },
+    "type-check": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
+      "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
+      "requires": {
+        "prelude-ls": "~1.1.2"
+      }
+    },
+    "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"
+      }
+    },
+    "typescript": {
+      "version": "3.8.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz",
+      "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w=="
+    },
+    "underscore": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.10.2.tgz",
+      "integrity": "sha512-N4P+Q/BuyuEKFJ43B9gYuOj4TQUHXX+j2FqguVOpjkssLUUrnJofCcBccJSCoeturDoZU6GorDTHSvUDlSQbTg=="
+    },
+    "unique-string": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
+      "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=",
+      "requires": {
+        "crypto-random-string": "^1.0.0"
+      }
+    },
+    "unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
+    },
+    "unzip-response": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz",
+      "integrity": "sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c="
+    },
+    "uri-js": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
+      "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "url-parse-lax": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-1.0.0.tgz",
+      "integrity": "sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=",
+      "requires": {
+        "prepend-http": "^1.0.1"
+      }
+    },
+    "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="
+    },
+    "uuid": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+      "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
+    },
+    "vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
+    },
+    "verror": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+      "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+      "requires": {
+        "assert-plus": "^1.0.0",
+        "core-util-is": "1.0.2",
+        "extsprintf": "^1.2.0"
+      }
+    },
+    "vscode-languageserver-types": {
+      "version": "3.15.1",
+      "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.15.1.tgz",
+      "integrity": "sha512-+a9MPUQrNGRrGU630OGbYVQ+11iOIovjCkqxajPa9w57Sd5ruK8WQNsslzpa0x/QJqC8kRc2DUxWjIFwoNm4ZQ=="
+    },
+    "which": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+      "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+      "requires": {
+        "isexe": "^2.0.0"
+      }
+    },
+    "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"
+      }
+    },
+    "widest-line": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-2.0.1.tgz",
+      "integrity": "sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==",
+      "requires": {
+        "string-width": "^2.1.1"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+        },
+        "is-fullwidth-code-point": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+        },
+        "string-width": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+          "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+          "requires": {
+            "is-fullwidth-code-point": "^2.0.0",
+            "strip-ansi": "^4.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+          "requires": {
+            "ansi-regex": "^3.0.0"
+          }
+        }
+      }
+    },
+    "window-size": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz",
+      "integrity": "sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY="
+    },
+    "windows-release": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-3.3.0.tgz",
+      "integrity": "sha512-2HetyTg1Y+R+rUgrKeUEhAG/ZuOmTrI1NBb3ZyAGQMYmOJjBBPe4MTodghRkmLJZHwkuPi02anbeGP+Zf401LQ==",
+      "requires": {
+        "execa": "^1.0.0"
+      },
+      "dependencies": {
+        "cross-spawn": {
+          "version": "6.0.5",
+          "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
+          "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
+          "requires": {
+            "nice-try": "^1.0.4",
+            "path-key": "^2.0.1",
+            "semver": "^5.5.0",
+            "shebang-command": "^1.2.0",
+            "which": "^1.2.9"
+          }
+        },
+        "execa": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
+          "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
+          "requires": {
+            "cross-spawn": "^6.0.0",
+            "get-stream": "^4.0.0",
+            "is-stream": "^1.1.0",
+            "npm-run-path": "^2.0.0",
+            "p-finally": "^1.0.0",
+            "signal-exit": "^3.0.0",
+            "strip-eof": "^1.0.0"
+          }
+        },
+        "get-stream": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+          "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+          "requires": {
+            "pump": "^3.0.0"
+          }
+        }
+      }
+    },
+    "with-callback": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/with-callback/-/with-callback-1.0.2.tgz",
+      "integrity": "sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE="
+    },
+    "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=="
+    },
+    "wrap-ansi": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz",
+      "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==",
+      "requires": {
+        "ansi-styles": "^3.2.0",
+        "string-width": "^3.0.0",
+        "strip-ansi": "^5.0.0"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
+          "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg=="
+        },
+        "is-fullwidth-code-point": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+        },
+        "string-width": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
+          "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
+          "requires": {
+            "emoji-regex": "^7.0.1",
+            "is-fullwidth-code-point": "^2.0.0",
+            "strip-ansi": "^5.1.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
+          "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
+          "requires": {
+            "ansi-regex": "^4.1.0"
+          }
+        }
+      }
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    },
+    "write-file-atomic": {
+      "version": "2.4.3",
+      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz",
+      "integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==",
+      "requires": {
+        "graceful-fs": "^4.1.11",
+        "imurmurhash": "^0.1.4",
+        "signal-exit": "^3.0.2"
+      }
+    },
+    "ws": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz",
+      "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==",
+      "requires": {
+        "async-limiter": "~1.0.0"
+      }
+    },
+    "xdg-basedir": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-3.0.0.tgz",
+      "integrity": "sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ="
+    },
+    "xml2js": {
+      "version": "0.4.23",
+      "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
+      "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
+      "requires": {
+        "sax": ">=0.6.0",
+        "xmlbuilder": "~11.0.0"
+      }
+    },
+    "xmlbuilder": {
+      "version": "11.0.1",
+      "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+      "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
+    },
+    "xmlhttprequest-ssl": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz",
+      "integrity": "sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4="
+    },
+    "xregexp": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz",
+      "integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM="
+    },
+    "y18n": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",
+      "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE="
+    },
+    "yallist": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
+    },
+    "yargs": {
+      "version": "3.32.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.32.0.tgz",
+      "integrity": "sha1-AwiOnr+edWtpdRYR0qXvWRSCyZU=",
+      "requires": {
+        "camelcase": "^2.0.1",
+        "cliui": "^3.0.3",
+        "decamelize": "^1.1.1",
+        "os-locale": "^1.4.0",
+        "string-width": "^1.0.1",
+        "window-size": "^0.1.4",
+        "y18n": "^3.2.0"
+      },
+      "dependencies": {
+        "camelcase": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz",
+          "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8="
+        }
+      }
+    },
+    "yeast": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
+      "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
+    }
+  }
+}

+ 11 - 7
backend/package.json

@@ -10,26 +10,30 @@
   "scripts": {
     "dev": "nodemon",
     "docker:dev": "nodemon -L /opt/app",
-    "docker:prod": "node /opt/app"
+    "docker:prod": "node /opt/app",
+    "snyk-protect": "snyk protect",
+    "prepublish": "npm run snyk-protect"
   },
   "dependencies": {
     "async": "3.1.0",
     "bcrypt": "^3.0.6",
     "bluebird": "^3.5.5",
     "body-parser": "^1.19.0",
-    "config": "^3.2.0",
-    "cookie-parser": "^1.4.4",
+    "config": "^3.3.1",
+    "cookie-parser": "^1.4.5",
     "cors": "^2.8.5",
-    "discord.js": "^11.5.1",
+    "discord.js": "^11.6.4",
     "express": "^4.17.1",
     "mailgun-js": "^0.22.0",
     "moment": "^2.24.0",
-    "mongoose": "^5.6.4",
+    "mongoose": "^5.9.10",
     "oauth": "^0.9.15",
     "redis": "^2.8.0",
     "request": "^2.88.0",
     "sha256": "^0.2.0",
     "socket.io": "^2.2.0",
-    "underscore": "^1.9.1"
-  }
+    "underscore": "^1.10.2",
+    "snyk": "^1.316.1"
+  },
+  "snyk": true
 }

+ 19 - 15
docker-compose.yml

@@ -3,29 +3,31 @@ services:
   backend:
     build: ./backend
     ports:
-    - "${BACKEND_PORT}:8080"
+      - "${BACKEND_PORT}:8080"
     volumes:
-    - ./backend:/opt/app
-    - ./log:/opt/log
+      - ./backend:/opt/app
+      - ./log:/opt/log
     links:
-    - mongo
-    - redis
+      - mongo
+      - redis
     environment:
-    - SNYK_TOKEN=${SNYK_TOKEN}
+      - SNYK_TOKEN=${SNYK_TOKEN}
+    stdin_open: true
+    tty: true
   frontend:
     build: ./frontend
-    environment:
     ports:
-    - "${FRONTEND_PORT}:80"
+      - "${FRONTEND_PORT}:80"
     volumes:
-    - ./frontend:/opt/app
+      - ./frontend:/opt/app
+      - /opt/app/node_modules/
     environment:
-    - FRONTEND_MODE=${FRONTEND_MODE}
-    - SNYK_TOKEN=${SNYK_TOKEN}
+      - FRONTEND_MODE=${FRONTEND_MODE}
+      - SNYK_TOKEN=${SNYK_TOKEN}
   mongo:
     image: mongo:4.0
     ports:
-    - "${MONGO_PORT}:27017"
+      - "${MONGO_PORT}:27017"
     environment:
       - MONGO_INITDB_ROOT_USERNAME=admin
       - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
@@ -39,11 +41,13 @@ services:
   mongoclient:
     image: mongoclient/mongoclient
     ports:
-    - "${MONGOCLIENT_PORT}:3000"
+      - "${MONGOCLIENT_PORT}:3000"
+    environment:
+      - MONGOCLIENT_DEFAULT_CONNECTION_URL=mongodb://${MONGO_USER_USERNAME}:${MONGO_USER_PASSWORD}@mongo:27017/musare
   redis:
     image: redis
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}"
     volumes:
-    - .redis:/data
+      - .redis:/data
     ports:
-    - "${REDIS_PORT}:6379"
+      - "${REDIS_PORT}:6379"

+ 19 - 0
frontend/.devcontainer/devcontainer.json

@@ -0,0 +1,19 @@
+{
+	// 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": [
+		"dbaeumer.vscode-eslint",
+		"esbenp.prettier-vscode",
+		"octref.vetur"
+	]
+}

+ 1 - 2
frontend/.prettierignore

@@ -1,3 +1,2 @@
 node_modules/
-build/
-yarn.lock
+build/

+ 101 - 4
frontend/.snyk

@@ -1,5 +1,102 @@
 # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
-version: v1.13.5
-# ignores vulnerabilities until expiry date; change duration by modifying expiry date
-ignore:
-patch: {}
+version: v1.14.1
+ignore: {}
+# patches apply the minimum changes required to fix a vulnerability
+patch:
+  SNYK-JS-LODASH-567746:
+    - html-webpack-plugin > lodash:
+        patched: '2020-05-01T08:37:15.509Z'
+    - webpack-merge > lodash:
+        patched: '2020-05-01T08:37:15.509Z'
+    - snyk > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > graphlib > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > @snyk/ruby-semver > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > inquirer > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-config > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-mvn-plugin > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-nodejs-lockfile-parser > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-nuget-plugin > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > @snyk/dep-graph > graphlib > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-go-plugin > graphlib > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-nodejs-lockfile-parser > graphlib > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/dep-graph > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-nodejs-lockfile-parser > snyk-config > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-mvn-plugin > @snyk/java-call-graph-builder > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-nuget-plugin > dotnet-deps-parser > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-php-plugin > @snyk/composer-lockfile-parser > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/dep-graph > graphlib > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-mvn-plugin > @snyk/java-call-graph-builder > graphlib > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/cocoapods-lockfile-parser > @snyk/ruby-semver > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > snyk-mvn-plugin > @snyk/java-call-graph-builder > snyk-config > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/cocoapods-lockfile-parser > @snyk/dep-graph > graphlib > lodash:
+        patched: '2020-05-01T11:34:18.550Z'
+    - html-webpack-plugin > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - webpack-merge > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > graphlib > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > @snyk/dep-graph > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > @snyk/ruby-semver > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > inquirer > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-config > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-mvn-plugin > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-nodejs-lockfile-parser > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-nuget-plugin > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > @snyk/dep-graph > graphlib > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-go-plugin > graphlib > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-nodejs-lockfile-parser > graphlib > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/dep-graph > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-nodejs-lockfile-parser > snyk-config > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-mvn-plugin > @snyk/java-call-graph-builder > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-nuget-plugin > dotnet-deps-parser > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-php-plugin > @snyk/composer-lockfile-parser > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/dep-graph > graphlib > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-mvn-plugin > @snyk/java-call-graph-builder > graphlib > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/cocoapods-lockfile-parser > @snyk/dep-graph > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/cocoapods-lockfile-parser > @snyk/ruby-semver > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > snyk-mvn-plugin > @snyk/java-call-graph-builder > snyk-config > lodash:
+        patched: '2020-05-16T18:33:43.925Z'
+    - snyk > @snyk/snyk-cocoapods-plugin > @snyk/cocoapods-lockfile-parser > @snyk/dep-graph > graphlib > lodash:
+        patched: '2020-05-16T18:33:43.925Z'

+ 110 - 16
frontend/App.vue

@@ -1,12 +1,8 @@
 <template>
-	<div>
+	<div class="upper-container">
 		<banned v-if="banned" />
-		<div v-else>
-			<h1 v-if="!socketConnected" class="alert">
-				Could not connect to the server.
-			</h1>
-			<!-- should be a persistant toast -->
-			<router-view />
+		<div v-else class="upper-container">
+			<router-view :key="$route.fullPath" class="main-container" />
 			<what-is-new />
 			<mobile-alert />
 			<login-modal v-if="modals.header.login" />
@@ -17,7 +13,6 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
-
 import Toast from "toasters";
 
 import Banned from "./components/pages/Banned.vue";
@@ -26,13 +21,15 @@ import MobileAlert from "./components/Modals/MobileAlert.vue";
 import LoginModal from "./components/Modals/Login.vue";
 import RegisterModal from "./components/Modals/Register.vue";
 import io from "./io";
+import keyboardShortcuts from "./keyboardShortcuts";
 
 export default {
 	replace: false,
 	data() {
 		return {
 			serverDomain: "",
-			socketConnected: true
+			socketConnected: true,
+			keyIsDown: false
 		};
 	},
 	computed: mapState({
@@ -42,24 +39,89 @@ export default {
 		userId: state => state.user.auth.userId,
 		banned: state => state.user.auth.banned,
 		modals: state => state.modals.modals,
-		currentlyActive: state => state.modals.currentlyActive
+		currentlyActive: state => state.modals.currentlyActive,
+		nightmode: state => state.user.preferences.nightmode
 	}),
 	methods: {
 		submitOnEnter: (cb, event) => {
 			if (event.which === 13) cb();
 		},
-		...mapActions("modals", ["closeCurrentModal"])
+		enableNightMode: () => {
+			document
+				.getElementsByTagName("body")[0]
+				.classList.add("night-mode");
+		},
+		disableNightMode: () => {
+			document
+				.getElementsByTagName("body")[0]
+				.classList.remove("night-mode");
+		},
+		...mapActions("modals", ["closeCurrentModal"]),
+		...mapActions("user/preferences", ["changeNightmode"])
+	},
+	watch: {
+		socketConnected: connected => {
+			console.log(connected);
+			if (!connected)
+				new Toast({
+					content: "Could not connect to the server.",
+					persistant: true
+				});
+			else {
+				// better implementation once vue-roaster is updated
+				document
+					.getElementById("toasts-content")
+					.childNodes.forEach(toast => {
+						if (
+							toast.innerHTML ===
+							"Could not connect to the server."
+						) {
+							toast.remove();
+						}
+					});
+			}
+		},
+		nightmode(nightmode) {
+			if (nightmode) this.enableNightMode();
+			else this.disableNightMode();
+		}
+	},
+	beforeMount() {
+		const nightmode =
+			false || JSON.parse(localStorage.getItem("nightmode"));
+		this.changeNightmode(nightmode);
+		if (nightmode) this.enableNightMode();
+		else this.disableNightMode();
 	},
 	mounted() {
 		document.onkeydown = ev => {
 			const event = ev || window.event;
-			if (
-				event.keyCode === 27 &&
-				Object.keys(this.currentlyActive).length !== 0
-			)
-				this.closeCurrentModal();
+			const { keyCode } = event;
+			const shift = event.shiftKey;
+			const ctrl = event.ctrlKey;
+
+			const identifier = `${keyCode}.${shift}.${ctrl}`;
+
+			if (this.keyIsDown === identifier) return;
+			this.keyIsDown = identifier;
+
+			keyboardShortcuts.handleKeyDown(keyCode, shift, ctrl);
+		};
+
+		document.onkeyup = () => {
+			this.keyIsDown = "";
 		};
 
+		keyboardShortcuts.registerShortcut("closeModal", {
+			keyCode: 27,
+			shift: false,
+			ctrl: false,
+			handler: () => {
+				if (Object.keys(this.currentlyActive).length !== 0)
+					this.closeCurrentModal();
+			}
+		});
+
 		if (localStorage.getItem("github_redirect")) {
 			this.$router.go(localStorage.getItem("github_redirect"));
 			localStorage.removeItem("github_redirect");
@@ -116,6 +178,22 @@ export default {
 <style lang="scss">
 @import "styles/global.scss";
 
+.night-mode {
+	div {
+		// background-color: #000;
+		color: #ddd;
+	}
+
+	#toasts-container .toast {
+		background-color: #ddd;
+		color: #333;
+	}
+}
+
+body.night-mode {
+	background-color: #000 !important;
+}
+
 #toasts-container {
 	z-index: 10000 !important;
 
@@ -130,12 +208,28 @@ export default {
 
 html {
 	overflow: auto !important;
+	height: 100%;
 }
 
 body {
 	background-color: $light-grey;
 	color: $dark-grey;
 	font-family: "Roboto", Helvetica, Arial, sans-serif;
+	height: 100%;
+}
+
+.upper-container {
+	height: 100%;
+}
+
+.main-container {
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+
+	> .container {
+		flex: 1 0 auto;
+	}
 }
 
 a {

+ 5 - 7
frontend/Dockerfile

@@ -3,18 +3,16 @@ FROM node:12
 RUN apt-get update
 RUN apt-get install nginx -y
 
-RUN npm install -g yarn
-
-RUN yarn global add snyk
-RUN yarn global add webpack@4.35.3
-RUN yarn global add webpack-cli@3.3.5
-RUN yarn global add webpack-dev-server@3.7.2
+RUN npm install -g snyk
+RUN npm install -g webpack@4.35.3
+RUN npm install -g webpack-cli@3.3.5
+RUN npm install -g webpack-dev-server@3.7.2
 
 RUN mkdir -p /opt
 WORKDIR /opt
 ADD package.json /opt/package.json
 
-RUN yarn install
+RUN npm install
 
 RUN mkdir -p /run/nginx
 

+ 7 - 0
frontend/api/admin/index.js

@@ -0,0 +1,7 @@
+import reports from "./reports";
+
+// when Vuex needs to interact with socket.io
+
+export default {
+	reports
+};

+ 17 - 0
frontend/api/admin/reports.js

@@ -0,0 +1,17 @@
+import Toast from "toasters";
+import io from "../../io";
+
+export default {
+	resolve(reportId) {
+		return new Promise((resolve, reject) => {
+			io.getSocket(socket => {
+				socket.emit("reports.resolve", reportId, res => {
+					new Toast({ content: res.message, timeout: 3000 });
+					if (res.status === "success")
+						return resolve({ status: "success" });
+					return reject(new Error(res.message));
+				});
+			});
+		});
+	}
+};

+ 8 - 5
frontend/api/auth.js

@@ -33,7 +33,10 @@ export default {
 										cookie.domain
 									}; ${secure}path=/`;
 
-									return resolve({ status: "success" });
+									return resolve({
+										status: "success",
+										message: "Account registered!"
+									});
 								});
 							}
 							return reject(new Error("You must login"));
@@ -77,15 +80,15 @@ export default {
 	logout() {
 		return new Promise((resolve, reject) => {
 			io.getSocket(socket => {
-				socket.emit("users.logout", result => {
-					if (result.status === "success") {
+				socket.emit("users.logout", res => {
+					if (res.status === "success") {
 						return lofig.get("cookie").then(cookie => {
 							document.cookie = `${cookie.SIDname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
 							return window.location.reload();
 						});
 					}
-					new Toast({ content: result.message, timeout: 4000 });
-					return reject(new Error(result.message));
+					new Toast({ content: res.message, timeout: 4000 });
+					return reject(new Error(res.message));
 				});
 			});
 		});

+ 2 - 2
frontend/bootstrap.sh

@@ -1,9 +1,9 @@
 #!/bin/bash
 
 if [ "$FRONTEND_MODE" == "prod" ] ; then
-	cd /opt/app ; yarn run $FRONTEND_MODE
+	cd /opt/app ; npm run $FRONTEND_MODE
 	nginx -c /opt/app/$FRONTEND_MODE.nginx.conf -g "daemon off;"
 elif [ "$FRONTEND_MODE" == "dev" ] ; then
 	nginx -c /opt/app/$FRONTEND_MODE.nginx.conf
-	cd /opt/app; yarn run $FRONTEND_MODE
+	cd /opt/app; npm run $FRONTEND_MODE
 fi

+ 240 - 0
frontend/components/Admin/NewStatistics.vue

@@ -0,0 +1,240 @@
+<template>
+	<div class="container">
+		<metadata title="Admin | Statistics" />
+		<div class="columns">
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<header class="card-header">
+					<p class="card-header-title">
+						Average Logs
+					</p>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<table class="table">
+							<thead>
+								<tr>
+									<th>Name</th>
+									<th>Status</th>
+									<th>Stage</th>
+									<th>Jobs in queue</th>
+									<th>Jobs in progress</th>
+									<th>Concurrency</th>
+								</tr>
+							</thead>
+							<tbody>
+								<tr
+									v-for="module_ in modules"
+									:key="module_.name"
+								>
+									<td>
+										<router-link
+											:to="'?moduleName=' + module_.name"
+											>{{ module_.name }}</router-link
+										>
+									</td>
+									<td>{{ module_.status }}</td>
+									<td>{{ module_.stage }}</td>
+									<td>{{ module_.jobsInQueue }}</td>
+									<td>{{ module_.jobsInProgress }}</td>
+									<td>{{ module_.concurrency }}</td>
+								</tr>
+							</tbody>
+						</table>
+					</div>
+				</div>
+			</div>
+		</div>
+		<br />
+		<div class="columns" v-if="module_">
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<header class="card-header">
+					<p class="card-header-title">
+						Average Logs
+					</p>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<table class="table">
+							<thead>
+								<tr>
+									<th>Name</th>
+									<th>Payload</th>
+								</tr>
+							</thead>
+							<tbody>
+								<tr
+									v-for="job in module_.runningJobs"
+									:key="JSON.stringify(job)"
+								>
+									<td>{{ job.name }}</td>
+									<td>
+										{{ JSON.stringify(job.payload) }}
+									</td>
+								</tr>
+							</tbody>
+						</table>
+					</div>
+				</div>
+			</div>
+		</div>
+		<br />
+		<div class="columns" v-if="module_">
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<header class="card-header">
+					<p class="card-header-title">
+						Average Logs
+					</p>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<table class="table">
+							<thead>
+								<tr>
+									<th>Job name</th>
+									<th>Successful</th>
+									<th>Failed</th>
+									<th>Total</th>
+									<th>Average timing</th>
+								</tr>
+							</thead>
+							<tbody>
+								<tr
+									v-for="(job,
+									jobName) in module_.jobStatistics"
+									:key="jobName"
+								>
+									<td>{{ jobName }}</td>
+									<td>
+										{{ job.successful }}
+									</td>
+									<td>
+										{{ job.failed }}
+									</td>
+									<td>
+										{{ job.total }}
+									</td>
+									<td>
+										{{ job.averageTiming }}
+									</td>
+								</tr>
+							</tbody>
+						</table>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import io from "../../io";
+
+export default {
+	components: {},
+	data() {
+		return {
+			modules: [],
+			module_: null
+		};
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
+		});
+	},
+	methods: {
+		init() {
+			this.socket.emit("utils.getModules", data => {
+				console.log(data);
+				if (data.status === "success") {
+					this.modules = data.modules;
+				}
+			});
+
+			if (this.$route.query.moduleName) {
+				this.socket.emit(
+					"utils.getModule",
+					this.$route.query.moduleName,
+					data => {
+						console.log(data);
+						if (data.status === "success") {
+							this.module_ = {
+								runningJobs: data.runningJobs,
+								jobStatistics: data.jobStatistics
+							};
+						}
+					}
+				);
+			}
+		},
+		round(number) {
+			return Math.round(number);
+		}
+	}
+};
+</script>
+
+//
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+
+	.card {
+		background-color: $night-mode-secondary;
+
+		p {
+			color: #ddd;
+		}
+	}
+}
+
+body {
+	font-family: "Roboto", sans-serif;
+}
+
+.user-avatar {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
+
+td {
+	vertical-align: middle;
+}
+
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
+</style>

+ 39 - 0
frontend/components/Admin/News.vue

@@ -345,6 +345,45 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+
+	.card {
+		background: #222;
+
+		.card-header {
+			box-shadow: 0 1px 2px rgba(10, 10, 10, 0.8);
+		}
+
+		p,
+		.label {
+			color: #ddd;
+		}
+	}
+}
+
 .tag:not(:last-child) {
 	margin-right: 5px;
 }

+ 39 - 0
frontend/components/Admin/Punishments.vue

@@ -178,6 +178,45 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+
+	.card {
+		background: #222;
+
+		.card-header {
+			box-shadow: 0 1px 2px rgba(10, 10, 10, 0.8);
+		}
+
+		p,
+		.label {
+			color: #ddd;
+		}
+	}
+}
+
 body {
 	font-family: "Roboto", sans-serif;
 }

+ 26 - 0
frontend/components/Admin/QueueSongs.vue

@@ -235,6 +235,32 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+}
+
 .optionsColumn {
 	width: 140px;
 	button {

+ 38 - 9
frontend/components/Admin/Reports.vue

@@ -129,17 +129,20 @@ export default {
 			this.openModal({ sector: "admin", modal: "viewReport" });
 		},
 		resolve(reportId) {
-			this.socket.emit("reports.resolve", reportId, res => {
-				new Toast({ content: res.message, timeout: 3000 });
-				if (res.status === "success" && this.modals.viewReport)
-					this.closeModal({
-						sector: "admin",
-						modal: "viewReport"
-					});
-			});
+			return this.resolveReport(reportId)
+				.then(res => {
+					if (res.status === "success" && this.modals.viewReport)
+						this.closeModal({
+							sector: "admin",
+							modal: "viewReport"
+						});
+				})
+				.catch(
+					err => new Toast({ content: err.message, timeout: 5000 })
+				);
 		},
 		...mapActions("modals", ["openModal", "closeModal"]),
-		...mapActions("admin/reports", ["viewReport"])
+		...mapActions("admin/reports", ["viewReport", "resolveReport"])
 	}
 };
 </script>
@@ -147,6 +150,32 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+}
+
 .tag:not(:last-child) {
 	margin-right: 5px;
 }

+ 26 - 0
frontend/components/Admin/Songs.vue

@@ -243,6 +243,32 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+}
+
 body {
 	font-family: "Roboto", sans-serif;
 }

+ 52 - 7
frontend/components/Admin/Stations.vue

@@ -194,7 +194,6 @@ export default {
 	components: { EditStation, UserIdToUsername },
 	data() {
 		return {
-			stations: [],
 			newStation: {
 				genres: [],
 				blacklistedGenres: []
@@ -202,6 +201,9 @@ export default {
 		};
 	},
 	computed: {
+		...mapState("admin/stations", {
+			stations: state => state.stations
+		}),
 		...mapState("modals", {
 			modals: state => state.modals.station
 		})
@@ -329,24 +331,28 @@ export default {
 		},
 		init() {
 			this.socket.emit("stations.index", data => {
-				this.stations = data.stations;
+				this.loadStations(data.stations);
 			});
 			this.socket.emit("apis.joinAdminRoom", "stations", () => {});
 		},
 		...mapActions("modals", ["openModal"]),
-		...mapActions("admin/stations", ["editStation"])
+		...mapActions("admin/stations", [
+			"editStation",
+			"loadStations",
+			"stationRemoved",
+			"stationAdded"
+		])
 	},
 	mounted() {
 		io.getSocket(socket => {
 			this.socket = socket;
 			if (this.socket.connected) this.init();
+
 			this.socket.on("event:admin.station.added", station => {
-				this.stations.push(station);
+				this.stationAdded(station);
 			});
 			this.socket.on("event:admin.station.removed", stationId => {
-				this.stations = this.stations.filter(station => {
-					return station._id !== stationId;
-				});
+				this.stationRemoved(stationId);
 			});
 			io.onConnect(() => {
 				this.init();
@@ -359,6 +365,45 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+
+	.card {
+		background: #222;
+
+		.card-header {
+			box-shadow: 0 1px 2px rgba(10, 10, 10, 0.8);
+		}
+
+		p,
+		.label {
+			color: #ddd;
+		}
+	}
+}
+
 .tag {
 	margin-top: 5px;
 	&:not(:last-child) {

+ 34 - 0
frontend/components/Admin/Statistics.vue

@@ -330,6 +330,40 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+
+	.card {
+		background-color: $night-mode-secondary;
+
+		p {
+			color: #ddd;
+		}
+	}
+}
+
 body {
 	font-family: "Roboto", sans-serif;
 }

+ 35 - 1
frontend/components/Admin/Users.vue

@@ -88,7 +88,15 @@ export default {
 		},
 		init() {
 			this.socket.emit("users.index", result => {
-				if (result.status === "success") this.users = result.data;
+				if (result.status === "success") {
+					this.users = result.data;
+					if (this.$route.query.userId) {
+						const user = this.users.find(
+							user => user._id === this.$route.query.userId
+						);
+						if (user) this.edit(user);
+					}
+				}
 			});
 			this.socket.emit("apis.joinAdminRoom", "users", () => {});
 		},
@@ -108,6 +116,32 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.table {
+		color: #ddd;
+		background-color: #222;
+
+		thead tr {
+			background: $night-mode-secondary;
+			td {
+				color: #fff;
+			}
+		}
+
+		tbody tr:hover {
+			background-color: #111 !important;
+		}
+
+		tbody tr:nth-child(even) {
+			background-color: #444;
+		}
+
+		strong {
+			color: #ddd;
+		}
+	}
+}
+
 body {
 	font-family: "Roboto", sans-serif;
 }

+ 14 - 5
frontend/components/MainFooter.vue

@@ -54,7 +54,7 @@
 					</router-link>
 				</p>
 				<p>
-					© Copyright Musare 2015 - 2019
+					© Copyright Musare 2015 - 2020
 				</p>
 			</div>
 		</div>
@@ -84,6 +84,18 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	footer.footer,
+	footer.footer .container,
+	footer.footer .container .content {
+		background-color: #222;
+	}
+
+	footer.footer .socialIcons img {
+		filter: invert(1);
+	}
+}
+
 .content a:not(.button) {
 	border: 0;
 }
@@ -95,10 +107,7 @@ export default {
 }
 
 .footer {
-	position: absolute;
-	right: 0;
-	bottom: 0;
-	left: 0;
+	flex-shrink: 0;
 	height: 240px;
 	padding: 40px 20px 40px;
 	border-radius: 33% 33% 0% 0% / 7% 7% 0% 0%;

+ 12 - 0
frontend/components/MainHeader.vue

@@ -109,7 +109,19 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.nav-left,
+	.nav-right {
+		background-color: #222;
+	}
+
+	.nav-item {
+		color: #ddd !important;
+	}
+}
+
 .nav {
+	flex-shrink: 0;
 	background-color: $primary-color;
 	height: 64px;
 	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;

+ 1 - 0
frontend/components/Modals/AddSongToPlaylist.vue

@@ -78,6 +78,7 @@ export default {
 		addSongToPlaylist(playlistId) {
 			this.socket.emit(
 				"playlists.addSongToPlaylist",
+				false,
 				this.currentSong.songId,
 				playlistId,
 				res => {

+ 9 - 13
frontend/components/Modals/AddSongToQueue.vue

@@ -4,12 +4,9 @@
 			<aside class="menu" v-if="loggedIn && station.type === 'community'">
 				<ul class="menu-list">
 					<li v-for="(playlist, index) in playlists" :key="index">
-						<a
-							href="#"
-							target="_blank"
-							v-on:click="editPlaylist(playlist._id)"
-							>{{ playlist.displayName }}</a
-						>
+						<a href="#" v-on:click="editPlaylist(playlist._id)">{{
+							playlist.displayName
+						}}</a>
 						<div class="controls">
 							<a
 								href="#"
@@ -104,13 +101,14 @@ export default {
 			querySearch: "",
 			queryResults: [],
 			playlists: [],
-			privatePlaylistQueueSelected: null,
 			importQuery: ""
 		};
 	},
 	computed: mapState({
 		loggedIn: state => state.user.auth.loggedIn,
-		station: state => state.station.station
+		station: state => state.station.station,
+		privatePlaylistQueueSelected: state =>
+			state.station.privatePlaylistQueueSelected
 	}),
 	methods: {
 		isPlaylistSelected(playlistId) {
@@ -118,15 +116,13 @@ export default {
 		},
 		selectPlaylist(playlistId) {
 			if (this.station.type === "community") {
-				this.privatePlaylistQueueSelected = playlistId;
-				this.$parent.privatePlaylistQueueSelected = playlistId;
+				this.updatePrivatePlaylistQueueSelected(playlistId);
 				this.$parent.addFirstPrivatePlaylistSongToQueue();
 			}
 		},
 		unSelectPlaylist() {
 			if (this.station.type === "community") {
-				this.privatePlaylistQueueSelected = null;
-				this.$parent.privatePlaylistQueueSelected = null;
+				this.updatePrivatePlaylistQueueSelected(null);
 			}
 		},
 		addSongToQueue(songId) {
@@ -204,6 +200,7 @@ export default {
 				}
 			});
 		},
+		...mapActions("station", ["updatePrivatePlaylistQueueSelected"]),
 		...mapActions("user/playlists", ["editPlaylist"])
 	},
 	mounted() {
@@ -212,7 +209,6 @@ export default {
 			this.socket.emit("playlists.indexForUser", res => {
 				if (res.status === "success") this.playlists = res.data;
 			});
-			this.privatePlaylistQueueSelected = this.$parent.privatePlaylistQueueSelected;
 		});
 	},
 	components: { Modal }

+ 12 - 1
frontend/components/Modals/CreateCommunityStation.vue

@@ -6,7 +6,7 @@
 			<p class="control">
 				<input
 					v-model="newCommunity.name"
-					class="input"
+					class="input station-id"
 					type="text"
 					placeholder="Name..."
 					autofocus
@@ -64,6 +64,7 @@ export default {
 	},
 	methods: {
 		submitModal() {
+			this.newCommunity.name = this.newCommunity.name.toLowerCase();
 			const { name, displayName, description } = this.newCommunity;
 
 			if (!name || !displayName || !description)
@@ -143,3 +144,13 @@ export default {
 	}
 };
 </script>
+
+<style lang="scss" scoped>
+.station-id {
+	text-transform: lowercase;
+
+	&::placeholder {
+		text-transform: none;
+	}
+}
+</style>

+ 2 - 4
frontend/components/Modals/EditSong.vue

@@ -1068,10 +1068,8 @@ export default {
 				this.genreHelper.pos3 = e.clientX;
 				this.genreHelper.pos4 = e.clientY;
 				// set the element's new position:
-				this.genreHelper.top =
-					this.genreHelper.top - this.genreHelper.pos2;
-				this.genreHelper.left =
-					this.genreHelper.left - this.genreHelper.pos1;
+				this.genreHelper.top -= this.genreHelper.pos2;
+				this.genreHelper.left -= this.genreHelper.pos1;
 			};
 
 			document.onmouseup = () => {

+ 53 - 24
frontend/components/Modals/EditStation.vue

@@ -301,16 +301,23 @@ import io from "../../io";
 import validation from "../../validation";
 
 export default {
-	computed: mapState({
-		editing(state) {
-			return this.$props.store.split("/").reduce((a, v) => a[v], state)
-				.editing;
-		},
-		station(state) {
-			return this.$props.store.split("/").reduce((a, v) => a[v], state)
-				.station;
-		}
-	}),
+	computed: {
+		...mapState("admin/station", {
+			stations: state => state.stations
+		}),
+		...mapState({
+			editing(state) {
+				return this.$props.store
+					.split("/")
+					.reduce((a, v) => a[v], state).editing;
+			},
+			station(state) {
+				return this.$props.store
+					.split("/")
+					.reduce((a, v) => a[v], state).station;
+			}
+		})
+	},
 	mounted() {
 		io.getSocket(socket => {
 			this.socket = socket;
@@ -422,9 +429,9 @@ export default {
 					if (res.status === "success") {
 						if (this.station) this.station.name = name;
 						else {
-							this.$parent.stations.forEach((station, index) => {
+							this.stations.forEach((station, index) => {
 								if (station._id === this.editing._id) {
-									this.$parent.stations[index].name = name;
+									this.stations[index].name = name;
 									return name;
 								}
 
@@ -461,9 +468,9 @@ export default {
 						if (this.station)
 							this.station.displayName = displayName;
 						else {
-							this.$parent.stations.forEach((station, index) => {
+							this.stations.forEach((station, index) => {
 								if (station._id === this.editing._id) {
-									this.$parent.stations[
+									this.stations[
 										index
 									].displayName = displayName;
 									return displayName;
@@ -506,9 +513,9 @@ export default {
 						if (this.station)
 							this.station.description = description;
 						else {
-							this.$parent.stations.forEach((station, index) => {
+							this.stations.forEach((station, index) => {
 								if (station._id === this.editing._id) {
-									this.$parent.stations[
+									this.stations[
 										index
 									].description = description;
 									return description;
@@ -543,9 +550,9 @@ export default {
 						if (this.station)
 							this.station.privacy = this.editing.privacy;
 						else {
-							this.$parent.stations.forEach((station, index) => {
+							this.stations.forEach((station, index) => {
 								if (station._id === this.editing._id) {
-									this.$parent.stations[
+									this.stations[
 										index
 									].privacy = this.editing.privacy;
 									return this.editing.privacy;
@@ -575,9 +582,9 @@ export default {
 							JSON.stringify(this.editing.genres)
 						);
 						if (this.station) this.station.genres = genres;
-						this.$parent.stations.forEach((station, index) => {
+						this.stations.forEach((station, index) => {
 							if (station._id === this.editing._id) {
-								this.$parent.stations[index].genres = genres;
+								this.stations[index].genres = genres;
 								return genres;
 							}
 
@@ -606,9 +613,9 @@ export default {
 						);
 						if (this.station)
 							this.station.blacklistedGenres = blacklistedGenres;
-						this.$parent.stations.forEach((station, index) => {
+						this.stations.forEach((station, index) => {
 							if (station._id === this.editing._id) {
-								this.$parent.stations[
+								this.stations[
 									index
 								].blacklistedGenres = blacklistedGenres;
 								return blacklistedGenres;
@@ -642,9 +649,9 @@ export default {
 							this.station.partyMode = this.editing.partyMode;
 						// if (this.station)
 						// 	this.station.partyMode = this.editing.partyMode;
-						// this.$parent.stations.forEach((station, index) => {
+						// this.stations.forEach((station, index) => {
 						// 	if (station._id === this.editing._id) {
-						// 		this.$parent.stations[
+						// 		this.stations[
 						// 			index
 						// 		].partyMode = this.editing.partyMode;
 						// 		return this.editing.partyMode;
@@ -806,6 +813,28 @@ export default {
 </script>
 
 <style lang="scss">
+@import "styles/global.scss";
+
+.night-mode {
+	.modal-card,
+	.modal-card-head,
+	.modal-card-body,
+	.modal-card-foot {
+		background-color: $night-mode-secondary;
+	}
+
+	.section {
+		background-color: #111 !important;
+		border: 0 !important;
+	}
+
+	.label,
+	p,
+	strong {
+		color: #ddd;
+	}
+}
+
 .edit-station-modal {
 	.modal-card-title {
 		text-align: center;

+ 16 - 5
frontend/components/Modals/IssuesModal.vue

@@ -62,11 +62,7 @@
 			</table>
 		</div>
 		<div slot="footer">
-			<a
-				class="button is-primary"
-				href="#"
-				@click="$parent.resolve(report._id)"
-			>
+			<a class="button is-primary" href="#" @click="resolve(report._id)">
 				<span>Resolve</span>
 			</a>
 			<a
@@ -95,6 +91,7 @@
 <script>
 import { mapActions, mapState } from "vuex";
 import { formatDistance } from "date-fns";
+import Toast from "toasters";
 
 import UserIdToUsername from "../UserIdToUsername.vue";
 import Modal from "./Modal.vue";
@@ -112,6 +109,20 @@ export default {
 	},
 	methods: {
 		formatDistance,
+		resolve(reportId) {
+			return this.resolveReport(reportId)
+				.then(res => {
+					if (res.status === "success")
+						this.closeModal({
+							sector: "admin",
+							modal: "viewReport"
+						});
+				})
+				.catch(
+					err => new Toast({ content: err.message, timeout: 5000 })
+				);
+		},
+		...mapActions("admin/reports", ["resolveReport"]),
 		...mapActions("modals", ["closeModal"])
 	},
 	components: { Modal, UserIdToUsername }

+ 15 - 1
frontend/components/Modals/Login.vue

@@ -99,7 +99,7 @@ export default {
 				password: this.password
 			})
 				.then(res => {
-					if (res.status === "success") window.location.reload();
+					if (res.status === "success") window.location.href = "/";
 				})
 				.catch(
 					err => new Toast({ content: err.message, timeout: 5000 })
@@ -126,6 +126,20 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.modal-card,
+	.modal-card-head,
+	.modal-card-body,
+	.modal-card-foot {
+		background-color: $night-mode-secondary;
+	}
+
+	.label,
+	p {
+		color: #ddd;
+	}
+}
+
 .button.is-github {
 	background-color: $dark-grey-2;
 	color: $white !important;

+ 79 - 54
frontend/components/Modals/Playlists/Edit.vue

@@ -57,7 +57,7 @@
 			<div class="control is-grouped">
 				<p class="control is-expanded">
 					<input
-						v-model="songQuery"
+						v-model="searchSongQuery"
 						class="input"
 						type="text"
 						placeholder="Search for Song to add"
@@ -92,6 +92,23 @@
 					</tr>
 				</tbody>
 			</table>
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="directSongQuery"
+						class="input"
+						type="text"
+						placeholder="Enter a YouTube id or URL directly"
+						autofocus
+						@keyup.enter="addSong()"
+					/>
+				</p>
+				<p class="control">
+					<a class="button is-info" @click="addSong()" href="#"
+						>Add</a
+					>
+				</p>
+			</div>
 			<div class="control is-grouped">
 				<p class="control is-expanded">
 					<input
@@ -99,15 +116,27 @@
 						class="input"
 						type="text"
 						placeholder="YouTube Playlist URL"
-						@keyup.enter="importPlaylist()"
+						@keyup.enter="importPlaylist(false)"
 					/>
 				</p>
 				<p class="control">
-					<a class="button is-info" @click="importPlaylist()" href="#"
-						>Import</a
+					<a
+						class="button is-info"
+						@click="importPlaylist(true)"
+						href="#"
+						>Import music</a
+					>
+				</p>
+				<p class="control">
+					<a
+						class="button is-info"
+						@click="importPlaylist(false)"
+						href="#"
+						>Import all</a
 					>
 				</p>
 			</div>
+			<button class="button is-info" @click="shuffle()">Shuffle</button>
 			<h5>Edit playlist details:</h5>
 			<div class="control is-grouped">
 				<p class="control is-expanded">
@@ -141,14 +170,17 @@ import Toast from "toasters";
 import Modal from "../Modal.vue";
 import io from "../../../io";
 import validation from "../../../validation";
+import utils from "../../../js/utils";
 
 export default {
 	components: { Modal },
 	data() {
 		return {
+			utils,
 			playlist: { songs: [] },
 			songQueryResults: [],
-			songQuery: "",
+			searchSongQuery: "",
+			directSongQuery: "",
 			importQuery: ""
 		};
 	},
@@ -201,58 +233,15 @@ export default {
 		});
 	},
 	methods: {
-		formatTime(duration) {
-			if (duration <= 0) return "0 seconds";
-
-			const hours = Math.floor(duration / (60 * 60));
-			const formatHours = () => {
-				if (hours > 0) {
-					if (hours > 1) {
-						if (hours < 10) return `0${hours} hours `;
-						return `${hours} hours `;
-					}
-					return `0${hours} hour `;
-				}
-				return "";
-			};
-
-			const minutes = Math.floor((duration - hours) / 60);
-			const formatMinutes = () => {
-				if (minutes > 0) {
-					if (minutes > 1) {
-						if (minutes < 10) return `0${minutes} minutes `;
-						return `${minutes} minutes `;
-					}
-					return `0${minutes} minute `;
-				}
-				return "";
-			};
-
-			const seconds = Math.floor(
-				duration - hours * 60 * 60 - minutes * 60
-			);
-			const formatSeconds = () => {
-				if (seconds > 0) {
-					if (seconds > 1) {
-						if (seconds < 10) return `0${seconds} seconds `;
-						return `${seconds} seconds `;
-					}
-					return `0${seconds} second `;
-				}
-				return "";
-			};
-
-			return formatHours() + formatMinutes() + formatSeconds();
-		},
 		totalLength() {
 			let length = 0;
 			this.playlist.songs.forEach(song => {
 				length += song.duration;
 			});
-			return this.formatTime(length);
+			return this.utils.formatTimeLong(length);
 		},
 		searchForSongs() {
-			let query = this.songQuery;
+			let query = this.searchSongQuery;
 			if (query.indexOf("&index=") !== -1) {
 				query = query.split("&index=");
 				query.pop();
@@ -282,6 +271,7 @@ export default {
 		addSongToPlaylist(id) {
 			this.socket.emit(
 				"playlists.addSongToPlaylist",
+				false,
 				id,
 				this.playlist._id,
 				res => {
@@ -289,7 +279,28 @@ export default {
 				}
 			);
 		},
-		importPlaylist() {
+		/* eslint-disable prefer-destructuring */
+		addSong() {
+			let id = "";
+
+			if (this.directSongQuery.length === 11) id = this.directSongQuery;
+			else {
+				const match = this.directSongQuery.match("v=([0-9A-Za-z_-]+)");
+				if (match.length > 0) id = match[1];
+			}
+
+			this.addSongToPlaylist(id);
+		},
+		/* eslint-enable prefer-destructuring */
+		shuffle() {
+			this.socket.emit("playlists.shuffle", this.playlist._id, res => {
+				new Toast({ content: res.message, timeout: 4000 });
+				if (res.status === "success") {
+					this.playlist = res.data;
+				}
+			});
+		},
+		importPlaylist(musicOnly) {
 			new Toast({
 				content:
 					"Starting to import your playlist. This can take some time to do.",
@@ -299,10 +310,21 @@ export default {
 				"playlists.addSetToPlaylist",
 				this.importQuery,
 				this.playlist._id,
+				musicOnly,
 				res => {
-					if (res.status === "success")
-						this.playlist.songs = res.data;
 					new Toast({ content: res.message, timeout: 4000 });
+					if (res.status === "success") {
+						new Toast({
+							content: `Successfully added ${res.stats.songsAddedSuccessfully} songs. Failed to add ${res.stats.songsFailedToAdd} songs.`,
+							timeout: 4000
+						});
+						if (musicOnly) {
+							new Toast({
+								content: `${res.stats.songsInPlaylistTotal} of the ${res.stats.videosInPlaylistTotal} videos in the playlist were songs.`,
+								timeout: 4000
+							});
+						}
+					}
 				}
 			);
 		},
@@ -344,7 +366,10 @@ export default {
 			this.socket.emit("playlists.remove", this.playlist._id, res => {
 				new Toast({ content: res.message, timeout: 3000 });
 				if (res.status === "success") {
-					this.closeModal();
+					this.closeModal({
+						sector: "station",
+						modal: "editPlaylist"
+					});
 				}
 			});
 		},

+ 131 - 11
frontend/components/Modals/Register.vue

@@ -29,32 +29,59 @@
 				<label class="label">Email</label>
 				<p class="control">
 					<input
-						v-model="email"
+						v-model="email.value"
 						class="input"
 						type="email"
 						placeholder="Email..."
+						@blur="onInputBlur('email')"
 						autofocus
 					/>
 				</p>
+				<p
+					class="help"
+					v-if="email.entered"
+					:class="email.valid ? 'is-success' : 'is-danger'"
+				>
+					{{ email.message }}
+				</p>
+				<br />
 				<label class="label">Username</label>
 				<p class="control">
 					<input
-						v-model="username"
+						v-model="username.value"
 						class="input"
 						type="text"
 						placeholder="Username..."
+						@blur="onInputBlur('username')"
 					/>
 				</p>
+				<p
+					class="help"
+					v-if="username.entered"
+					:class="username.valid ? 'is-success' : 'is-danger'"
+				>
+					{{ username.message }}
+				</p>
+				<br />
 				<label class="label">Password</label>
 				<p class="control">
 					<input
-						v-model="password"
+						v-model="password.value"
 						class="input"
 						type="password"
 						placeholder="Password..."
+						@blur="onInputBlur('password')"
 						@keypress="$parent.submitOnEnter(submitModal, $event)"
 					/>
 				</p>
+				<p
+					class="help"
+					v-if="password.entered"
+					:class="password.valid ? 'is-success' : 'is-danger'"
+				>
+					{{ password.message }}
+				</p>
+				<br />
 				<p>
 					By logging in/registering you agree to our
 					<router-link to="/terms"> Terms of Service </router-link
@@ -86,12 +113,29 @@ import { mapActions } from "vuex";
 
 import Toast from "toasters";
 
+import validation from "../../validation";
+
 export default {
 	data() {
 		return {
-			username: "",
-			email: "",
-			password: "",
+			username: {
+				value: "",
+				valid: false,
+				entered: false,
+				message: "Please enter a valid username."
+			},
+			email: {
+				value: "",
+				valid: false,
+				entered: false,
+				message: "Please enter a valid email address."
+			},
+			password: {
+				value: "",
+				valid: false,
+				entered: false,
+				message: "Please enter a valid password."
+			},
 			recaptcha: {
 				key: "",
 				token: ""
@@ -99,6 +143,55 @@ export default {
 			serverDomain: ""
 		};
 	},
+	watch: {
+		// eslint-disable-next-line func-names
+		"username.value": function(value) {
+			if (!validation.isLength(value, 2, 32)) {
+				this.username.message =
+					"Username must have between 2 and 32 characters.";
+				this.username.valid = false;
+			} else if (!validation.regex.azAZ09_.test(value)) {
+				this.username.message =
+					"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.";
+				this.username.valid = false;
+			} else {
+				this.username.message = "Everything looks great!";
+				this.username.valid = true;
+			}
+		},
+		// eslint-disable-next-line func-names
+		"email.value": function(value) {
+			if (!validation.isLength(value, 3, 254)) {
+				this.email.message =
+					"Email must have between 3 and 254 characters.";
+				this.email.valid = false;
+			} else if (
+				value.indexOf("@") !== value.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(value)
+			) {
+				this.email.message = "Invalid Email format.";
+				this.email.valid = false;
+			} else {
+				this.email.message = "Everything looks great!";
+				this.email.valid = true;
+			}
+		},
+		// eslint-disable-next-line func-names
+		"password.value": function(value) {
+			if (!validation.isLength(value, 6, 200)) {
+				this.password.message =
+					"Password must have between 6 and 200 characters.";
+				this.password.valid = false;
+			} else if (!validation.regex.password.test(value)) {
+				this.password.message =
+					"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.";
+				this.password.valid = false;
+			} else {
+				this.password.message = "Everything looks great!";
+				this.password.valid = true;
+			}
+		}
+	},
 	mounted() {
 		lofig.get("serverDomain").then(serverDomain => {
 			this.serverDomain = serverDomain;
@@ -127,19 +220,32 @@ export default {
 	},
 	methods: {
 		submitModal() {
-			this.register({
-				username: this.username,
-				email: this.email,
-				password: this.password,
+			if (
+				!this.username.valid ||
+				!this.email.valid ||
+				!this.password.valid
+			)
+				return new Toast({
+					content: "Please ensure all fields are valid.",
+					timeout: 5000
+				});
+
+			return this.register({
+				username: this.username.value,
+				email: this.email.value,
+				password: this.password.value,
 				recaptchaToken: this.recaptcha.token
 			})
 				.then(res => {
-					if (res.status === "success") window.location.reload();
+					if (res.status === "success") window.location.href = "/";
 				})
 				.catch(
 					err => new Toast({ content: err.message, timeout: 5000 })
 				);
 		},
+		onInputBlur(inputName) {
+			this[inputName].entered = true;
+		},
 		githubRedirect() {
 			localStorage.setItem("github_redirect", this.$route.path);
 		},
@@ -152,6 +258,20 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.modal-card,
+	.modal-card-head,
+	.modal-card-body,
+	.modal-card-foot {
+		background-color: $night-mode-secondary;
+	}
+
+	.label,
+	p {
+		color: #ddd;
+	}
+}
+
 .button.is-github {
 	background-color: $dark-grey-2;
 	color: $white !important;

+ 9 - 10
frontend/components/Modals/Report.vue

@@ -118,7 +118,6 @@
 							class="textarea"
 							maxlength="400"
 							placeholder="Any other details..."
-							@keyup="updateCharactersRemaining()"
 						/>
 						<div class="textarea-counter">
 							{{ charactersRemaining }}
@@ -159,7 +158,6 @@ export default {
 	components: { Modal },
 	data() {
 		return {
-			charactersRemaining: 400,
 			isPreviousSongActive: false,
 			isCurrentSongActive: true,
 			report: {
@@ -207,10 +205,15 @@ export default {
 			]
 		};
 	},
-	computed: mapState({
-		currentSong: state => state.station.currentSong,
-		previousSong: state => state.station.previousSong
-	}),
+	computed: {
+		charactersRemaining() {
+			return 400 - this.report.description.length;
+		},
+		...mapState({
+			currentSong: state => state.station.currentSong,
+			previousSong: state => state.station.previousSong
+		})
+	},
 	mounted() {
 		io.getSocket(socket => {
 			this.socket = socket;
@@ -230,10 +233,6 @@ export default {
 					});
 			});
 		},
-		updateCharactersRemaining() {
-			this.charactersRemaining =
-				400 - document.getElementsByClassName("textarea").value.length;
-		},
 		highlight(type) {
 			if (type === "currentSong") {
 				this.report.songId = this.currentSong.songId;

+ 13 - 0
frontend/components/Modals/WhatIsNew.vue

@@ -133,6 +133,19 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.modal-card,
+	.modal-card-head,
+	.modal-card-body {
+		background-color: $night-mode-secondary;
+	}
+
+	strong,
+	p {
+		color: #ddd;
+	}
+}
+
 .modal-card-head {
 	border-bottom: none;
 	background-color: ghostwhite;

+ 14 - 0
frontend/components/Sidebars/Playlist.vue

@@ -144,6 +144,20 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.sidebar {
+		background-color: $night-mode-secondary;
+
+		.title {
+			color: #fff;
+		}
+
+		* {
+			color: #ddd;
+		}
+	}
+}
+
 .sidebar {
 	position: fixed;
 	z-index: 1;

+ 21 - 9
frontend/components/Sidebars/SongsList.vue

@@ -28,19 +28,15 @@
 					</div>
 				</div>
 				<div class="media-right">
-					{{ $parent.formatTime(currentSong.duration) }}
+					{{ utils.formatTime(currentSong.duration) }}
 				</div>
 			</article>
 			<p v-if="noSong" class="center">
 				There is currently no song playing.
 			</p>
+			<hr v-if="noSong" />
 
-			<article
-				v-else
-				v-for="(song, index) in songsList"
-				:key="index"
-				class="media"
-			>
+			<article v-for="song in songsList" :key="song.songId" class="media">
 				<div class="media-content">
 					<div
 						class="content"
@@ -74,7 +70,7 @@
 					</div>
 				</div>
 				<div class="media-right">
-					{{ $parent.formatTime(song.duration) }}
+					{{ utils.formatTime(song.duration) }}
 				</div>
 			</article>
 			<div
@@ -127,14 +123,16 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
-
 import Toast from "toasters";
 
+import utils from "../../js/utils";
+
 import UserIdToUsername from "../UserIdToUsername.vue";
 
 export default {
 	data() {
 		return {
+			utils,
 			dismissedWarning: false
 		};
 	},
@@ -186,6 +184,20 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.sidebar {
+		background-color: $night-mode-secondary;
+
+		.title {
+			color: #fff;
+		}
+
+		* {
+			color: #ddd;
+		}
+	}
+}
+
 .sidebar {
 	position: fixed;
 	z-index: 1;

+ 14 - 0
frontend/components/Sidebars/UsersList.vue

@@ -35,6 +35,20 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.sidebar {
+		background-color: $night-mode-secondary;
+
+		.title {
+			color: #fff;
+		}
+
+		* {
+			color: #ddd;
+		}
+	}
+}
+
 .sidebar {
 	position: fixed;
 	z-index: 1;

+ 202 - 107
frontend/components/Station/Station.vue

@@ -62,7 +62,12 @@
 					<a
 						href="#"
 						class="no-song"
-						@click="sidebars.playlist = true"
+						@click="
+							toggleSidebar({
+								sector: 'station',
+								sidebar: 'playlist'
+							})
+						"
 						>Play a private playlist</a
 					>
 				</h4>
@@ -131,7 +136,7 @@
 							</div>
 						</div>
 						<div class="media-right">
-							{{ formatTime(currentSong.duration) }}
+							{{ utils.formatTime(currentSong.duration) }}
 						</div>
 					</article>
 					<p v-if="noSong" class="center">
@@ -173,7 +178,7 @@
 							</div>
 						</div>
 						<div class="media-right">
-							{{ formatTime(song.duration) }}
+							{{ utils.formatTime(song.duration) }}
 						</div>
 					</article>
 					<a
@@ -198,7 +203,7 @@
 						<div class="column is-12-desktop">
 							<h4 id="time-display">
 								{{ timeElapsed }} /
-								{{ formatTime(currentSong.duration) }}
+								{{ utils.formatTime(currentSong.duration) }}
 							</h4>
 							<h3>{{ currentSong.title }}</h3>
 							<h4 class="thin" style="margin-left: 0">
@@ -224,11 +229,11 @@
 											>volume_down</i
 										>
 										<input
-											id="volumeSlider"
+											v-model="volumeSliderValue"
 											type="range"
 											min="0"
 											max="10000"
-											class="active"
+											class="volumeSlider active"
 											@change="changeVolume()"
 											@input="changeVolume()"
 										/>
@@ -239,10 +244,7 @@
 										>
 									</p>
 								</form>
-								<div
-									class="column is-8-mobile is-5-desktop"
-									style="float: right;"
-								>
+								<div class="column is-8-mobile is-5-desktop">
 									<ul
 										v-if="
 											currentSong.likes !== -1 &&
@@ -252,7 +254,7 @@
 									>
 										<li
 											id="like"
-											class="right"
+											style="margin-right: 10px;"
 											@click="toggleLike()"
 										>
 											<span class="flow-text">{{
@@ -272,8 +274,6 @@
 										</li>
 										<li
 											id="dislike"
-											style="margin-right: 10px;"
-											class="right"
 											@click="toggleDislike()"
 										>
 											<span class="flow-text">{{
@@ -321,7 +321,7 @@
 							</h4>
 							<h5>
 								{{ timeElapsed }} /
-								{{ formatTime(currentSong.duration) }}
+								{{ utils.formatTime(currentSong.duration) }}
 							</h5>
 							<div>
 								<form class="columns" action="#">
@@ -341,11 +341,11 @@
 											>volume_down</i
 										>
 										<input
-											id="volumeSlider"
+											v-model="volumeSliderValue"
 											type="range"
 											min="0"
 											max="10000"
-											class="active"
+											class="active volumeSlider"
 											@change="changeVolume()"
 											@input="changeVolume()"
 										/>
@@ -430,10 +430,13 @@ import UserIdToUsername from "../UserIdToUsername.vue";
 import Z404 from "../404.vue";
 
 import io from "../../io";
+import keyboardShortcuts from "../../keyboardShortcuts";
+import utils from "../../js/utils";
 
 export default {
 	data() {
 		return {
+			utils,
 			title: "Station",
 			loading: true,
 			ready: false,
@@ -445,33 +448,33 @@ export default {
 			timeElapsed: "0:00",
 			liked: false,
 			disliked: false,
-			sidebars: {
-				songslist: false,
-				users: false,
-				playlist: false
-			},
 			timeBeforePause: 0,
 			skipVotes: 0,
-			privatePlaylistQueueSelected: null,
 			automaticallyRequestedSongId: null,
 			systemDifference: 0,
 			attemptsToPlayVideo: 0,
 			canAutoplay: true,
 			lastTimeRequestedIfCanAutoplay: 0,
 			seeking: false,
-			playbackRate: 1
+			playbackRate: 1,
+			volumeSliderValue: 0
 		};
 	},
 	computed: {
 		...mapState("modals", {
 			modals: state => state.modals.station
 		}),
+		...mapState("sidebars", {
+			sidebars: state => state.sidebars.station
+		}),
 		...mapState("station", {
 			station: state => state.station,
 			currentSong: state => state.currentSong,
 			songsList: state => state.songsList,
 			paused: state => state.paused,
-			noSong: state => state.noSong
+			noSong: state => state.noSong,
+			privatePlaylistQueueSelected: state =>
+				state.privatePlaylistQueueSelected
 		}),
 		...mapState({
 			loggedIn: state => state.user.auth.loggedIn,
@@ -486,6 +489,9 @@ export default {
 		isAdminOnly() {
 			return this.loggedIn && this.role === "admin";
 		},
+		isOwnerOrAdmin() {
+			return this.isOwnerOnly() || this.isAdminOnly();
+		},
 		removeFromQueue(songId) {
 			window.socket.emit(
 				"stations.removeFromQueue",
@@ -502,12 +508,6 @@ export default {
 				}
 			);
 		},
-		toggleSidebar(type) {
-			Object.keys(this.sidebars).forEach(sidebar => {
-				if (sidebar !== type) this.sidebars[sidebar] = false;
-				else this.sidebars[type] = !this.sidebars[type];
-			});
-		},
 		youtubeReady() {
 			if (!this.player) {
 				this.player = new window.YT.Player("player", {
@@ -626,30 +626,6 @@ export default {
 				)}%`;
 			}
 		},
-		formatTime(duration) {
-			if (duration) {
-				if (duration < 0) return "0:00";
-
-				const hours = Math.floor(duration / (60 * 60));
-				const minutes = Math.floor((duration - hours) / 60);
-				const seconds = Math.floor(
-					duration - hours * 60 * 60 - minutes * 60
-				);
-
-				const formatHours = () => {
-					if (hours > 0) {
-						if (hours < 10) return `0${hours}:`;
-						return `${hours}:`;
-					}
-					return "";
-				};
-
-				return `${formatHours()}${minutes}:${
-					seconds < 10 ? `0${seconds}` : seconds
-				}`;
-			}
-			return false;
-		},
 		calculateTimeElapsed() {
 			if (
 				this.playerReady &&
@@ -745,7 +721,7 @@ export default {
 			const songDuration = this.currentSong.duration;
 			if (songDuration <= duration) this.player.pauseVideo();
 			if (!this.paused && duration <= songDuration)
-				this.timeElapsed = this.formatTime(duration);
+				this.timeElapsed = utils.formatTime(duration);
 		},
 		toggleLock() {
 			window.socket.emit("stations.toggleLock", this.station._id, res => {
@@ -758,7 +734,7 @@ export default {
 			});
 		},
 		changeVolume() {
-			const volume = document.getElementById("volumeSlider").value;
+			const volume = this.volumeSliderValue;
 			localStorage.setItem("volume", volume / 100);
 			if (this.playerReady) {
 				this.player.setVolume(volume / 100);
@@ -854,7 +830,7 @@ export default {
 					this.player.getVolume() * 100 <= 0 ? previousVolume : 0;
 				this.muted = !this.muted;
 				localStorage.setItem("muted", this.muted);
-				document.getElementById("volumeSlider").value = volume * 100;
+				this.volumeSliderValue = volume * 100;
 				this.player.setVolume(volume);
 				if (!this.muted) localStorage.setItem("volume", volume);
 			}
@@ -868,7 +844,7 @@ export default {
 					localStorage.setItem("muted", false);
 				}
 				if (volume > 100) volume = 100;
-				document.getElementById("volumeSlider").value = volume * 100;
+				this.volumeSliderValue = volume * 100;
 				this.player.setVolume(volume);
 				localStorage.setItem("volume", volume);
 			}
@@ -927,7 +903,10 @@ export default {
 		},
 		addFirstPrivatePlaylistSongToQueue() {
 			let isInQueue = false;
-			if (this.station.type === "community") {
+			if (
+				this.station.type === "community" &&
+				this.station.partyMode === true
+			) {
 				this.songsList.forEach(queueSong => {
 					if (queueSong.requestedBy === this.userId) isInQueue = true;
 				});
@@ -937,47 +916,58 @@ export default {
 						this.privatePlaylistQueueSelected,
 						data => {
 							if (data.status === "success") {
-								if (data.song.duration < 15 * 60) {
-									this.automaticallyRequestedSongId =
-										data.song.songId;
-									this.socket.emit(
-										"stations.addToQueue",
-										this.station._id,
-										data.song.songId,
-										data2 => {
-											if (data2.status === "success") {
-												this.socket.emit(
-													"playlists.moveSongToBottom",
-													this
-														.privatePlaylistQueueSelected,
-													data.song.songId,
-													data3 => {
-														if (
-															data3.status ===
-															"success"
-														) {} // eslint-disable-line
-													}
-												);
+								if (data.song) {
+									if (data.song.duration < 15 * 60) {
+										this.automaticallyRequestedSongId =
+											data.song.songId;
+										this.socket.emit(
+											"stations.addToQueue",
+											this.station._id,
+											data.song.songId,
+											data2 => {
+												if (
+													data2.status === "success"
+												) {
+													this.socket.emit(
+														"playlists.moveSongToBottom",
+														this
+															.privatePlaylistQueueSelected,
+														data.song.songId,
+														data3 => {
+															if (
+																data3.status ===
+																"success"
+															) {} // eslint-disable-line
+														}
+													);
+												}
+											}
+										);
+									} else {
+										new Toast({
+											content: `Top song in playlist was too long to be added.`,
+											timeout: 3000
+										});
+										this.socket.emit(
+											"playlists.moveSongToBottom",
+											this.privatePlaylistQueueSelected,
+											data.song.songId,
+											data3 => {
+												if (
+													data3.status === "success"
+												) {
+													setTimeout(() => {
+														this.addFirstPrivatePlaylistSongToQueue();
+													}, 3000);
+												}
 											}
-										}
-									);
+										);
+									}
 								} else {
 									new Toast({
-										content: `Top song in playlist was too long to be added.`,
-										timeout: 3000
+										content: `Selected playlist has no songs.`,
+										timeout: 4000
 									});
-									this.socket.emit(
-										"playlists.moveSongToBottom",
-										this.privatePlaylistQueueSelected,
-										data.song.songId,
-										data3 => {
-											if (data3.status === "success") {
-												setTimeout(() => {
-													this.addFirstPrivatePlaylistSongToQueue();
-												}, 3000);
-											}
-										}
-									);
 								}
 							}
 						}
@@ -1044,6 +1034,94 @@ export default {
 						if (this.playerReady) this.player.pauseVideo();
 						this.updateNoSong(true);
 					}
+
+					if (type === "community" && partyMode === true) {
+						this.socket.emit("stations.getQueue", _id, res => {
+							if (res.status === "success") {
+								this.updateSongsList(res.queue);
+							}
+						});
+					}
+
+					if (this.isOwnerOrAdmin()) {
+						keyboardShortcuts.registerShortcut(
+							"station.pauseResume",
+							{
+								keyCode: 32,
+								shift: false,
+								ctrl: true,
+								handler: () => {
+									if (this.paused) this.resumeStation();
+									else this.pauseStation();
+								}
+							}
+						);
+
+						keyboardShortcuts.registerShortcut(
+							"station.skipStation",
+							{
+								keyCode: 39,
+								shift: false,
+								ctrl: true,
+								handler: () => {
+									this.skipStation();
+								}
+							}
+						);
+					}
+
+					keyboardShortcuts.registerShortcut(
+						"station.lowerVolumeLarge",
+						{
+							keyCode: 40,
+							shift: false,
+							ctrl: true,
+							handler: () => {
+								this.volumeSliderValue -= 1000;
+								this.changeVolume();
+							}
+						}
+					);
+
+					keyboardShortcuts.registerShortcut(
+						"station.lowerVolumeSmall",
+						{
+							keyCode: 40,
+							shift: true,
+							ctrl: true,
+							handler: () => {
+								this.volumeSliderValue -= 100;
+								this.changeVolume();
+							}
+						}
+					);
+
+					keyboardShortcuts.registerShortcut(
+						"station.increaseVolumeLarge",
+						{
+							keyCode: 38,
+							shift: false,
+							ctrl: true,
+							handler: () => {
+								this.volumeSliderValue += 1000;
+								this.changeVolume();
+							}
+						}
+					);
+
+					keyboardShortcuts.registerShortcut(
+						"station.increaseVolumeSmall",
+						{
+							keyCode: 38,
+							shift: true,
+							ctrl: true,
+							handler: () => {
+								this.volumeSliderValue += 100;
+								this.changeVolume();
+							}
+						}
+					);
+
 					// UNIX client time before ping
 					const beforePing = Date.now();
 					this.socket.emit("apis.ping", pong => {
@@ -1071,6 +1149,7 @@ export default {
 				}
 			});
 		},
+		...mapActions("sidebars", ["toggleSidebar"]),
 		...mapActions("modals", ["openModal"]),
 		...mapActions("station", [
 			"joinStation",
@@ -1256,7 +1335,7 @@ export default {
 		if (JSON.parse(localStorage.getItem("muted"))) {
 			this.muted = true;
 			this.player.setVolume(0);
-			document.getElementById("volumeSlider").value = 0 * 100;
+			this.volumeSliderValue = 0 * 100;
 		} else {
 			let volume = parseFloat(localStorage.getItem("volume"));
 			volume =
@@ -1264,9 +1343,23 @@ export default {
 					? volume
 					: 20;
 			localStorage.setItem("volume", volume);
-			document.getElementById("volumeSlider").value = volume * 100;
+			this.volumeSliderValue = volume * 100;
 		}
 	},
+	beforeDestroy() {
+		const shortcutNames = [
+			"station.pauseResume",
+			"station.skipStation",
+			"station.lowerVolumeLarge",
+			"station.lowerVolumeSmall",
+			"station.increaseVolumeLarge",
+			"station.increaseVolumeSmall"
+		];
+
+		shortcutNames.forEach(shortcutName => {
+			keyboardShortcuts.unregisterShortcut(shortcutName);
+		});
+	},
 	components: {
 		StationHeader,
 		SongQueue: () => import("../Modals/AddSongToQueue.vue"),
@@ -1287,6 +1380,13 @@ export default {
 <style lang="scss">
 @import "styles/global.scss";
 
+.night-mode {
+	.nav,
+	.control-sidebar {
+		background-color: #222;
+	}
+}
+
 .player-can-not-autoplay {
 	position: absolute;
 	width: 100%;
@@ -1317,7 +1417,7 @@ export default {
 	text-align: center;
 }
 
-#volumeSlider {
+.volumeSlider {
 	padding: 0 15px;
 	background: transparent;
 }
@@ -1597,6 +1697,9 @@ export default {
 }
 
 #ratings {
+	display: flex;
+	justify-content: flex-end;
+
 	span {
 		font-size: 1.68rem;
 	}
@@ -1708,14 +1811,6 @@ h6 {
 	font-weight: 200;
 }
 
-.left {
-	float: left !important;
-}
-
-.right {
-	float: right !important;
-}
-
 .light-blue {
 	background-color: $primary-color !important;
 }

+ 17 - 4
frontend/components/Station/StationHeader.vue

@@ -118,7 +118,7 @@
 						<span class="icon-purpose">Add song to queue</span>
 					</a>
 					<a
-						v-if="!isOwner() && !noSong"
+						v-if="!noSong"
 						class="sidebar-item"
 						href="#"
 						@click="$parent.voteSkipStation()"
@@ -174,7 +174,12 @@
 					"
 					class="sidebar-item"
 					href="#"
-					@click="$parent.toggleSidebar('songslist')"
+					@click="
+						toggleSidebar({
+							sector: 'station',
+							sidebar: 'songslist'
+						})
+					"
 				>
 					<span class="icon">
 						<i class="material-icons">queue_music</i>
@@ -184,7 +189,9 @@
 				<a
 					class="sidebar-item"
 					href="#"
-					@click="$parent.toggleSidebar('users')"
+					@click="
+						toggleSidebar({ sector: 'station', sidebar: 'users' })
+					"
 				>
 					<span class="icon">
 						<i class="material-icons">people</i>
@@ -197,7 +204,12 @@
 					v-if="loggedIn && station.type === 'community'"
 					class="sidebar-item"
 					href="#"
-					@click="$parent.toggleSidebar('playlist')"
+					@click="
+						toggleSidebar({
+							sector: 'station',
+							sidebar: 'playlist'
+						})
+					"
 				>
 					<span class="icon">
 						<i class="material-icons">library_music</i>
@@ -268,6 +280,7 @@ export default {
 			});
 		},
 		...mapActions("modals", ["openModal"]),
+		...mapActions("sidebars", ["toggleSidebar"]),
 		...mapActions("station", ["editStation"]),
 		...mapActions("user/auth", ["logout"])
 	}

+ 10 - 0
frontend/components/User/ResetPassword.vue

@@ -151,6 +151,16 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.label {
+		color: #ddd;
+	}
+
+	.skip-step {
+		border: 0;
+	}
+}
+
 .container {
 	padding: 25px;
 }

+ 545 - 142
frontend/components/User/Settings.vue

@@ -3,151 +3,246 @@
 		<metadata title="Settings" />
 		<main-header />
 		<div class="container">
-			<!--Implement Validation-->
-			<label class="label">Username</label>
-			<div class="control is-grouped">
-				<p class="control is-expanded has-icon has-icon-right">
-					<input
-						v-model="user.username"
-						class="input"
-						type="text"
-						placeholder="Change username"
-					/>
-					<!--Remove validation if it's their own without changing-->
-				</p>
-				<p class="control">
-					<button class="button is-success" @click="changeUsername()">
-						Save changes
-					</button>
-				</p>
+			<div class="nav-links">
+				<router-link
+					:class="{ active: activeTab === 'profile' }"
+					to="#profile"
+				>
+					Profile
+				</router-link>
+				<router-link
+					:class="{ active: activeTab === 'account' }"
+					to="#account"
+				>
+					Account
+				</router-link>
+				<router-link
+					:class="{ active: activeTab === 'security' }"
+					to="#security"
+				>
+					Security
+				</router-link>
+				<router-link
+					:class="{ active: activeTab === 'preferences' }"
+					to="#preferences"
+				>
+					Preferences
+				</router-link>
 			</div>
-			<label class="label">Email</label>
-			<div v-if="user.email" class="control is-grouped">
-				<p class="control is-expanded has-icon has-icon-right">
+			<div class="content profile-tab" v-if="activeTab === 'profile'">
+				<p class="control is-expanded">
+					<label for="name">Name</label>
 					<input
-						v-model="user.email.address"
 						class="input"
+						id="name"
 						type="text"
-						placeholder="Change email address"
+						placeholder="Name"
+						v-model="user.name"
 					/>
-					<!--Remove validation if it's their own without changing-->
 				</p>
 				<p class="control is-expanded">
-					<button class="button is-success" @click="changeEmail()">
-						Save changes
-					</button>
-				</p>
-			</div>
-			<label v-if="password" class="label">Change Password</label>
-			<div v-if="password" class="control is-grouped">
-				<p class="control is-expanded has-icon has-icon-right">
+					<label for="location">Location</label>
 					<input
-						v-model="newPassword"
 						class="input"
-						type="password"
-						placeholder="Change password"
+						id="location"
+						type="text"
+						placeholder="Location"
+						v-model="user.location"
 					/>
 				</p>
 				<p class="control is-expanded">
-					<button class="button is-success" @click="changePassword()">
-						Change password
-					</button>
+					<label for="bio">Bio</label>
+					<textarea
+						class="textarea"
+						id="bio"
+						placeholder="Bio"
+						v-model="user.bio"
+					/>
 				</p>
-			</div>
-
-			<label v-if="!password" class="label">Add password</label>
-			<div v-if="!password" class="control is-grouped">
+				<div class="control is-expanded avatar-select">
+					<label>Avatar</label>
+					<div class="select">
+						<select v-if="user.avatar" v-model="user.avatar.type">
+							<option value="gravatar">Using Gravatar</option>
+							<option value="initials">Based on initials</option>
+						</select>
+					</div>
+				</div>
 				<button
-					v-if="passwordStep === 1"
-					class="button is-success"
-					@click="requestPassword()"
+					class="button is-primary"
+					@click="saveChangesToProfile()"
 				>
-					Request password email
+					Save changes
 				</button>
-				<br />
-
-				<p
-					v-if="passwordStep === 2"
-					class="control is-expanded has-icon has-icon-right"
-				>
+			</div>
+			<div class="content account-tab" v-if="activeTab === 'account'">
+				<p class="control is-expanded">
+					<label for="name">Username</label>
 					<input
-						v-model="passwordCode"
 						class="input"
+						id="username"
 						type="text"
-						placeholder="Code"
+						placeholder="Username"
+						v-model="user.username"
+						@blur="onInputBlur('username')"
 					/>
 				</p>
-				<p v-if="passwordStep === 2" class="control is-expanded">
-					<button class="button is-success" v-on:click="verifyCode()">
-						Verify code
-					</button>
-				</p>
-
 				<p
-					v-if="passwordStep === 3"
-					class="control is-expanded has-icon has-icon-right"
+					class="help"
+					v-if="validation.username.entered"
+					:class="
+						validation.username.valid ? 'is-success' : 'is-danger'
+					"
 				>
+					{{ validation.username.message }}
+				</p>
+				<p class="control is-expanded">
+					<label for="location">Email</label>
 					<input
-						v-model="setNewPassword"
 						class="input"
-						type="password"
-						placeholder="New password"
+						id="email"
+						type="text"
+						placeholder="Email"
+						v-if="user.email"
+						v-model="user.email.address"
+						@blur="onInputBlur('email')"
 					/>
 				</p>
-				<p v-if="passwordStep === 3" class="control is-expanded">
-					<button class="button is-success" @click="setPassword()">
-						Set password
-					</button>
+				<p
+					class="help"
+					v-if="validation.email.entered"
+					:class="validation.email.valid ? 'is-success' : 'is-danger'"
+				>
+					{{ validation.email.message }}
 				</p>
+				<button
+					class="button is-primary"
+					@click="saveChangesToAccount()"
+				>
+					Save changes
+				</button>
 			</div>
-			<a
-				v-if="passwordStep === 1 && !password"
-				href="#"
-				@click="passwordStep = 2"
-				>Skip this step</a
-			>
+			<div class="content security-tab" v-if="activeTab === 'security'">
+				<label v-if="!password" class="label">Add password</label>
+				<div v-if="!password" class="control is-grouped">
+					<button
+						v-if="passwordStep === 1"
+						class="button is-success"
+						@click="requestPassword()"
+					>
+						Request password email
+					</button>
+					<br />
 
-			<a
-				v-if="!github"
-				class="button is-github"
-				:href="`${serverDomain}/auth/github/link`"
-			>
-				<div class="icon">
-					<img class="invert" src="/assets/social/github.svg" />
+					<p
+						v-if="passwordStep === 2"
+						class="control is-expanded has-icon has-icon-right"
+					>
+						<input
+							v-model="passwordCode"
+							class="input"
+							type="text"
+							placeholder="Code"
+						/>
+					</p>
+					<p v-if="passwordStep === 2" class="control is-expanded">
+						<button
+							class="button is-success"
+							v-on:click="verifyCode()"
+						>
+							Verify code
+						</button>
+					</p>
+
+					<p
+						v-if="passwordStep === 3"
+						class="control is-expanded has-icon has-icon-right"
+					>
+						<input
+							v-model="setNewPassword"
+							class="input"
+							type="password"
+							placeholder="New password"
+						/>
+					</p>
+					<p v-if="passwordStep === 3" class="control is-expanded">
+						<button
+							class="button is-success"
+							@click="setPassword()"
+						>
+							Set password
+						</button>
+					</p>
 				</div>
-				&nbsp; Link GitHub to account
-			</a>
+				<a
+					v-if="passwordStep === 1 && !password"
+					href="#"
+					@click="passwordStep = 2"
+					>Skip this step</a
+				>
 
-			<button
-				v-if="password && github"
-				class="button is-danger"
-				@click="unlinkPassword()"
-			>
-				Remove logging in with password
-			</button>
-			<button
-				v-if="password && github"
-				class="button is-danger"
-				@click="unlinkGitHub()"
-			>
-				Remove logging in with GitHub
-			</button>
-
-			<br />
-			<button
-				class="button is-warning"
-				style="margin-top: 30px;"
-				@click="removeSessions()"
+				<a
+					v-if="!github"
+					class="button is-github"
+					:href="`${serverDomain}/auth/github/link`"
+				>
+					<div class="icon">
+						<img class="invert" src="/assets/social/github.svg" />
+					</div>
+					&nbsp; Link GitHub to account
+				</a>
+				<button
+					v-if="password && github"
+					class="button is-danger"
+					@click="unlinkPassword()"
+				>
+					Remove logging in with password
+				</button>
+				<button
+					v-if="password && github"
+					class="button is-danger"
+					@click="unlinkGitHub()"
+				>
+					Remove logging in with GitHub
+				</button>
+				<br />
+				<button
+					class="button is-warning"
+					style="margin-top: 30px;"
+					@click="removeSessions()"
+				>
+					Log out everywhere
+				</button>
+			</div>
+			<div
+				class="content preferences-tab"
+				v-if="activeTab === 'preferences'"
 			>
-				Log out everywhere
-			</button>
+				<p class="control is-expanded checkbox-control">
+					<input
+						type="checkbox"
+						id="nightmode"
+						v-model="localNightmode"
+					/>
+					<label for="nightmode">
+						<span></span>
+						<p>Use nightmode</p>
+					</label>
+				</p>
+				<button
+					class="button is-primary"
+					@click="saveChangesPreferences()"
+				>
+					Save changes
+				</button>
+			</div>
 		</div>
 		<main-footer />
 	</div>
 </template>
 
 <script>
-import { mapState } from "vuex";
+import { mapState, mapActions } from "vuex";
 
 import Toast from "toasters";
 
@@ -162,52 +257,136 @@ export default {
 	data() {
 		return {
 			user: {},
+			originalUser: {},
+			validation: {
+				username: {
+					entered: false,
+					valid: false,
+					message: "Please enter a valid username."
+				},
+				email: {
+					entered: false,
+					valid: false,
+					message: "Please enter a valid email address."
+				}
+			},
 			newPassword: "",
 			password: false,
 			github: false,
 			setNewPassword: "",
 			passwordStep: 1,
 			passwordCode: "",
-			serverDomain: ""
+			serverDomain: "",
+			activeTab: "",
+			localNightmode: false
 		};
 	},
+	watch: {
+		// eslint-disable-next-line func-names
+		"user.username": function(value) {
+			if (!validation.isLength(value, 2, 32)) {
+				this.validation.username.message =
+					"Username must have between 2 and 32 characters.";
+				this.validation.username.valid = false;
+			} else if (
+				!validation.regex.azAZ09_.test(value) &&
+				value !== this.originalUser.username // Sometimes a username pulled from GitHub won't succeed validation
+			) {
+				this.validation.username.message =
+					"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.";
+				this.validation.username.valid = false;
+			} else {
+				this.validation.username.message = "Everything looks great!";
+				this.validation.username.valid = true;
+			}
+		},
+		// eslint-disable-next-line func-names
+		"user.email.address": function(value) {
+			if (!validation.isLength(value, 3, 254)) {
+				this.validation.email.message =
+					"Email must have between 3 and 254 characters.";
+				this.validation.email.valid = false;
+			} else if (
+				value.indexOf("@") !== value.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(value)
+			) {
+				this.validation.email.message = "Invalid Email format.";
+				this.validation.email.valid = false;
+			} else {
+				this.validation.email.message = "Everything looks great!";
+				this.validation.email.valid = true;
+			}
+		}
+	},
 	computed: mapState({
-		userId: state => state.user.auth.userId
+		userId: state => state.user.auth.userId,
+		nightmode: state => state.user.preferences.nightmode
 	}),
 	mounted() {
-		lofig.get("serverDomain").then(serverDomain => {
-			this.serverDomain = serverDomain;
-		});
+		if (this.$route.hash === "") {
+			this.$router.push("#profile");
+		} else {
+			this.activeTab = this.$route.hash.replace("#", "");
+			this.localNightmode = this.nightmode;
 
-		io.getSocket(socket => {
-			this.socket = socket;
-			this.socket.emit("users.findBySession", res => {
-				if (res.status === "success") {
-					this.user = res.data;
-					this.password = this.user.password;
-					this.github = this.user.github;
-				} else {
-					new Toast({
-						content: "Your are currently not signed in",
-						timeout: 3000
-					});
-				}
-			});
-			this.socket.on("event:user.linkPassword", () => {
-				this.password = true;
-			});
-			this.socket.on("event:user.linkGitHub", () => {
-				this.github = true;
+			lofig.get("serverDomain").then(serverDomain => {
+				this.serverDomain = serverDomain;
 			});
-			this.socket.on("event:user.unlinkPassword", () => {
-				this.password = false;
-			});
-			this.socket.on("event:user.unlinkGitHub", () => {
-				this.github = false;
+
+			io.getSocket(socket => {
+				this.socket = socket;
+				this.socket.emit("users.findBySession", res => {
+					if (res.status === "success") {
+						this.user = res.data;
+						this.originalUser = JSON.parse(
+							JSON.stringify(this.user)
+						);
+						this.password = this.user.password;
+						this.github = this.user.github;
+					} else {
+						new Toast({
+							content: "Your are currently not signed in",
+							timeout: 3000
+						});
+					}
+				});
+				this.socket.on("event:user.linkPassword", () => {
+					this.password = true;
+				});
+				this.socket.on("event:user.linkGitHub", () => {
+					this.github = true;
+				});
+				this.socket.on("event:user.unlinkPassword", () => {
+					this.password = false;
+				});
+				this.socket.on("event:user.unlinkGitHub", () => {
+					this.github = false;
+				});
 			});
-		});
+		}
 	},
 	methods: {
+		onInputBlur(inputName) {
+			this.validation[inputName].entered = true;
+		},
+		saveChangesToProfile() {
+			if (this.user.name !== this.originalUser.name) this.changeName();
+			if (this.user.location !== this.originalUser.location)
+				this.changeLocation();
+			if (this.user.bio !== this.originalUser.bio) this.changeBio();
+			if (this.user.avatar.type !== this.originalUser.avatar.type)
+				this.changeAvatarType();
+		},
+		saveChangesToAccount() {
+			if (this.user.username !== this.originalUser.username)
+				this.changeUsername();
+			if (this.user.email.address !== this.originalUser.email.address)
+				this.changeEmail();
+		},
+		saveChangesPreferences() {
+			if (this.localNightmode !== this.nightmode)
+				this.changeNightmodeLocal();
+		},
 		changeEmail() {
 			const email = this.user.email.address;
 			if (!validation.isLength(email, 3, 254))
@@ -231,11 +410,13 @@ export default {
 				res => {
 					if (res.status !== "success")
 						new Toast({ content: res.message, timeout: 8000 });
-					else
+					else {
 						new Toast({
 							content: "Successfully changed email address",
 							timeout: 4000
 						});
+						this.originalUser.email.address = email;
+					}
 				}
 			);
 		},
@@ -260,11 +441,108 @@ export default {
 				res => {
 					if (res.status !== "success")
 						new Toast({ content: res.message, timeout: 8000 });
-					else
+					else {
 						new Toast({
 							content: "Successfully changed username",
 							timeout: 4000
 						});
+						this.originalUser.username = username;
+					}
+				}
+			);
+		},
+		changeName() {
+			const { name } = this.user;
+			if (!validation.isLength(name, 1, 64))
+				return new Toast({
+					content: "Name must have between 1 and 64 characters.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateName",
+				this.userId,
+				name,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully changed name",
+							timeout: 4000
+						});
+						this.originalUser.name = name;
+					}
+				}
+			);
+		},
+		changeLocation() {
+			const { location } = this.user;
+			if (!validation.isLength(location, 0, 50))
+				return new Toast({
+					content: "Location must have between 0 and 50 characters.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateLocation",
+				this.userId,
+				location,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully changed location",
+							timeout: 4000
+						});
+						this.originalUser.location = location;
+					}
+				}
+			);
+		},
+		changeBio() {
+			const { bio } = this.user;
+			if (!validation.isLength(bio, 0, 200))
+				return new Toast({
+					content: "Bio must have between 0 and 200 characters.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateBio",
+				this.userId,
+				bio,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully changed bio",
+							timeout: 4000
+						});
+						this.originalUser.bio = bio;
+					}
+				}
+			);
+		},
+		changeAvatarType() {
+			const { type } = this.user.avatar;
+
+			return this.socket.emit(
+				"users.updateAvatarType",
+				this.userId,
+				type,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else {
+						new Toast({
+							content: "Successfully updated avatar type",
+							timeout: 4000
+						});
+						this.originalUser.avatar.type = type;
+					}
 				}
 			);
 		},
@@ -358,7 +636,12 @@ export default {
 			this.socket.emit(`users.removeSessions`, this.userId, res => {
 				new Toast({ content: res.message, timeout: 4000 });
 			});
-		}
+		},
+		changeNightmodeLocal() {
+			localStorage.setItem("nightmode", this.localNightmode);
+			this.changeNightmode(this.localNightmode);
+		},
+		...mapActions("user/preferences", ["changeNightmode"])
 	}
 };
 </script>
@@ -367,10 +650,130 @@ export default {
 @import "styles/global.scss";
 
 .container {
-	padding: 25px;
+	@media only screen and (min-width: 900px) {
+		width: 962px;
+		margin: 0 auto;
+		flex-direction: row;
+
+		.content {
+			width: 600px;
+			margin-top: 0px;
+		}
+	}
+
+	margin-top: 32px;
+	padding: 24px;
+	display: flex;
+	flex-direction: column;
+
+	.nav-links {
+		height: 100%;
+		width: 250px;
+		margin-right: 64px;
+
+		a {
+			outline: none;
+			border: none;
+			box-shadow: none;
+			color: $musareBlue;
+			font-size: 22px;
+			line-height: 26px;
+			padding: 7px 0 7px 12px;
+			width: 100%;
+			text-align: left;
+			cursor: pointer;
+			border-radius: 5px;
+			background-color: transparent;
+			display: inline-block;
+
+			&.active {
+				color: $white;
+				background-color: $musareBlue;
+			}
+		}
+	}
+
+	.content {
+		margin: 24px 0;
+
+		label {
+			font-size: 14px;
+			color: $dark-grey-2;
+			padding-bottom: 4px;
+		}
+
+		input {
+			height: 32px;
+		}
+
+		textarea {
+			height: 96px;
+		}
+
+		input,
+		textarea {
+			border-radius: 3px;
+			border: 1px solid $light-grey-2;
+		}
+
+		button {
+			width: 100%;
+		}
+
+		.checkbox-control {
+			input[type="checkbox"] {
+				opacity: 0;
+				position: absolute;
+			}
+
+			label {
+				display: flex;
+				flex-direction: row;
+				align-items: center;
+
+				span {
+					cursor: pointer;
+					width: 24px;
+					height: 24px;
+					background-color: $white;
+					display: inline-block;
+					border: 1px solid $dark-grey-2;
+					position: relative;
+					border-radius: 3px;
+				}
+
+				p {
+					margin-left: 10px;
+				}
+			}
+
+			input[type="checkbox"]:checked + label span::after {
+				content: "";
+				width: 18px;
+				height: 18px;
+				left: 2px;
+				top: 2px;
+				border-radius: 3px;
+				background-color: $musareBlue;
+				position: absolute;
+			}
+		}
+	}
+}
+
+.avatar-select {
+	display: flex;
+	flex-direction: column;
+	align-items: flex-start;
+
+	.select:after {
+		border-color: $musareBlue;
+	}
 }
 
-a {
-	color: $primary-color !important;
+.night-mode {
+	label {
+		color: #ddd !important;
+	}
 }
 </style>

+ 702 - 97
frontend/components/User/Show.vue

@@ -1,89 +1,229 @@
 <template>
 	<div v-if="isUser">
 		<metadata v-bind:title="`Profile | ${user.username}`" />
+		<edit-playlist v-if="modals.editPlaylist" />
+		<create-playlist v-if="modals.createPlaylist" />
 		<main-header />
 		<div class="container">
-			<img class="avatar" src="/assets/notes.png" />
-			<h2 class="has-text-centered username">@{{ user.username }}</h2>
-			<h5>A member since {{ user.createdAt }}</h5>
-			<div
-				v-if="role === 'admin' && userId !== user._id"
-				class="admin-functionality"
-			>
-				<a
-					v-if="user.role == 'default'"
-					class="button is-small is-info is-outlined"
-					@click="changeRank('admin')"
-					>Promote to Admin</a
+			<div class="info-section">
+				<div class="picture-name-row">
+					<img
+						class="profile-picture"
+						:src="
+							user.avatar.url && user.avatar.type === 'gravatar'
+								? `${user.avatar.url}?d=${notes}&s=250`
+								: '/assets/notes.png'
+						"
+						onerror="this.src='/assets/notes.png'; this.onerror=''"
+					/>
+					<div>
+						<div class="name-role-row">
+							<p class="name">{{ user.name }}</p>
+							<span
+								class="role admin"
+								v-if="user.role === 'admin'"
+								>admin</span
+							>
+						</div>
+						<p class="username">@{{ user.username }}</p>
+					</div>
+				</div>
+				<div
+					class="buttons"
+					v-if="userId === user._id || role === 'admin'"
 				>
-				<a
-					v-if="user.role == 'admin'"
-					class="button is-small is-danger is-outlined"
-					@click="changeRank('default')"
-					>Demote to User</a
+					<router-link
+						:to="`/admin/users?userId=${user._id}`"
+						class="button is-primary"
+						v-if="role === 'admin'"
+					>
+						Edit
+					</router-link>
+					<router-link
+						to="/settings"
+						class="button is-primary"
+						v-if="userId === user._id"
+					>
+						Settings
+					</router-link>
+				</div>
+				<div class="bio-row" v-if="user.bio">
+					<i class="material-icons">notes</i>
+					<p>{{ user.bio }}</p>
+				</div>
+				<div
+					class="date-location-row"
+					v-if="user.createdAt || user.location"
 				>
-			</div>
-			<nav class="level">
-				<div class="level-item has-text-centered">
-					<p class="heading">
-						Rank
-					</p>
-					<p class="title role">
-						{{ user.role }}
-					</p>
+					<div class="date" v-if="user.createdAt">
+						<i class="material-icons">calendar_today</i>
+						<p>{{ user.createdAt }}</p>
+					</div>
+					<div class="location" v-if="user.location">
+						<i class="material-icons">location_on</i>
+						<p>{{ user.location }}</p>
+					</div>
 				</div>
-				<div class="level-item has-text-centered">
-					<p class="heading">
-						Songs Requested
-					</p>
-					<p class="title">
-						{{ user.statistics.songsRequested }}
-					</p>
+			</div>
+			<div class="bottom-section">
+				<div class="buttons">
+					<button
+						:class="{ active: activeTab === 'recentActivity' }"
+						@click="switchTab('recentActivity')"
+					>
+						Recent activity
+					</button>
+					<button
+						:class="{ active: activeTab === 'playlists' }"
+						@click="switchTab('playlists')"
+						v-if="user._id === userId"
+					>
+						Playlists
+					</button>
 				</div>
-				<div class="level-item has-text-centered">
-					<p class="heading">
-						Likes
-					</p>
-					<p class="title">
-						{{ user.liked.length }}
-					</p>
+				<div
+					class="content recent-activity-tab"
+					v-if="activeTab === 'recentActivity'"
+				>
+					<div v-if="activities.length > 0">
+						<div
+							class="item activity"
+							v-for="activity in sortedActivities"
+							:key="activity._id"
+						>
+							<div class="thumbnail">
+								<img :src="activity.thumbnail" alt="" />
+								<i class="material-icons activity-type-icon">{{
+									activity.icon
+								}}</i>
+							</div>
+							<div class="left-part">
+								<p
+									class="top-text"
+									v-html="activity.message"
+								></p>
+								<p class="bottom-text">
+									{{
+										formatDistance(
+											parseISO(activity.createdAt),
+											new Date(),
+											{ addSuffix: true }
+										)
+									}}
+								</p>
+							</div>
+							<div class="actions">
+								<a
+									class="hide-icon"
+									href="#"
+									@click="hideActivity(activity._id)"
+								>
+									<i class="material-icons">visibility_off</i>
+								</a>
+							</div>
+						</div>
+					</div>
+					<div v-else>
+						<h2>No recent activity.</h2>
+					</div>
 				</div>
-				<div class="level-item has-text-centered">
-					<p class="heading">
-						Dislikes
-					</p>
-					<p class="title">
-						{{ user.disliked.length }}
-					</p>
+				<div
+					class="content playlists-tab"
+					v-if="activeTab === 'playlists'"
+				>
+					<div
+						class="item playlist"
+						v-for="playlist in playlists"
+						:key="playlist._id"
+					>
+						<div class="left-part">
+							<p class="top-text">{{ playlist.displayName }}</p>
+							<p class="bottom-text">
+								{{ totalLength(playlist) }} •
+								{{ playlist.songs.length }}
+								{{
+									playlist.songs.length === 1
+										? "song"
+										: "songs"
+								}}
+							</p>
+						</div>
+						<div class="actions">
+							<button
+								class="button is-primary"
+								@click="editPlaylistClick(playlist._id)"
+							>
+								Edit
+							</button>
+						</div>
+					</div>
+					<button
+						class="button is-primary"
+						@click="
+							openModal({
+								sector: 'station',
+								modal: 'createPlaylist'
+							})
+						"
+					>
+						Create new playlist
+					</button>
 				</div>
-			</nav>
+			</div>
 		</div>
 		<main-footer />
 	</div>
 </template>
 
 <script>
-import { mapState } from "vuex";
+import { mapState, mapActions } from "vuex";
+import { format, formatDistance, parseISO } from "date-fns";
 import Toast from "toasters";
-import { format, parseISO } from "date-fns";
 
 import MainHeader from "../MainHeader.vue";
 import MainFooter from "../MainFooter.vue";
 import io from "../../io";
+import utils from "../../js/utils";
 
 export default {
-	components: { MainHeader, MainFooter },
+	components: {
+		MainHeader,
+		MainFooter,
+		CreatePlaylist: () => import("../Modals/Playlists/Create.vue"),
+		EditPlaylist: () => import("../Modals/Playlists/Edit.vue")
+	},
 	data() {
 		return {
+			utils,
 			user: {},
-			isUser: false
+			notes: "",
+			isUser: false,
+			activeTab: "recentActivity",
+			playlists: [],
+			activities: []
 		};
 	},
-	computed: mapState({
-		role: state => state.user.auth.role,
-		userId: state => state.user.auth.userId
-	}),
+	computed: {
+		...mapState({
+			role: state => state.user.auth.role,
+			userId: state => state.user.auth.userId,
+			...mapState("modals", {
+				modals: state => state.modals.station
+			})
+		}),
+		sortedActivities() {
+			const { activities } = this;
+			return activities.sort(
+				(x, y) => new Date(y.createdAt) - new Date(x.createdAt)
+			);
+		}
+	},
 	mounted() {
+		lofig.get("frontendDomain").then(frontendDomain => {
+			this.frontendDomain = frontendDomain;
+			this.notes = encodeURI(`${this.frontendDomain}/assets/notes.png`);
+		});
+
 		io.getSocket(socket => {
 			this.socket = socket;
 			this.socket.emit(
@@ -98,28 +238,283 @@ export default {
 							"MMMM do yyyy"
 						);
 						this.isUser = true;
+
+						if (this.user._id === this.userId) {
+							this.socket.emit("playlists.indexForUser", res => {
+								if (res.status === "success")
+									this.playlists = res.data;
+							});
+
+							this.socket.emit(
+								"activities.getSet",
+								this.userId,
+								1,
+								res => {
+									if (res.status === "success") {
+										for (
+											let a = 0;
+											a < res.data.length;
+											a += 1
+										) {
+											this.formatActivity(
+												res.data[a],
+												activity => {
+													this.activities.unshift(
+														activity
+													);
+												}
+											);
+										}
+									}
+								}
+							);
+
+							this.socket.on(
+								"event:activity.create",
+								activity => {
+									console.log(activity);
+									this.formatActivity(activity, activity => {
+										this.activities.unshift(activity);
+									});
+								}
+							);
+
+							this.socket.on(
+								"event:playlist.create",
+								playlist => {
+									this.playlists.push(playlist);
+								}
+							);
+
+							this.socket.on(
+								"event:playlist.delete",
+								playlistId => {
+									this.playlists.forEach(
+										(playlist, index) => {
+											if (playlist._id === playlistId) {
+												this.playlists.splice(index, 1);
+											}
+										}
+									);
+								}
+							);
+
+							this.socket.on("event:playlist.addSong", data => {
+								this.playlists.forEach((playlist, index) => {
+									if (playlist._id === data.playlistId) {
+										this.playlists[index].songs.push(
+											data.song
+										);
+									}
+								});
+							});
+
+							this.socket.on(
+								"event:playlist.removeSong",
+								data => {
+									this.playlists.forEach(
+										(playlist, index) => {
+											if (
+												playlist._id === data.playlistId
+											) {
+												this.playlists[
+													index
+												].songs.forEach(
+													(song, index2) => {
+														if (
+															song._id ===
+															data.songId
+														) {
+															this.playlists[
+																index
+															].songs.splice(
+																index2,
+																1
+															);
+														}
+													}
+												);
+											}
+										}
+									);
+								}
+							);
+
+							this.socket.on(
+								"event:playlist.updateDisplayName",
+								data => {
+									this.playlists.forEach(
+										(playlist, index) => {
+											if (
+												playlist._id === data.playlistId
+											) {
+												this.playlists[
+													index
+												].displayName =
+													data.displayName;
+											}
+										}
+									);
+								}
+							);
+						}
 					}
 				}
 			);
 		});
 	},
 	methods: {
-		changeRank(newRank) {
-			this.socket.emit(
-				"users.updateRole",
-				this.user._id,
-				newRank === "admin" ? "admin" : "default",
-				res => {
-					if (res.status === "error")
-						new Toast({ content: res.message, timeout: 2000 });
-					else this.user.role = newRank;
-					new Toast({
-						content: `User ${this.$route.params.username}'s rank has been changed to: ${newRank}`,
-						timeout: 2000
-					});
+		formatDistance,
+		parseISO,
+		switchTab(tabName) {
+			this.activeTab = tabName;
+		},
+		editPlaylistClick(playlistId) {
+			console.log(playlistId);
+			this.editPlaylist(playlistId);
+			this.openModal({ sector: "station", modal: "editPlaylist" });
+		},
+		totalLength(playlist) {
+			let length = 0;
+			playlist.songs.forEach(song => {
+				length += song.duration;
+			});
+			return this.utils.formatTimeLong(length);
+		},
+		hideActivity(activityId) {
+			this.socket.emit("activities.hideActivity", activityId, res => {
+				if (res.status === "success") {
+					this.activities = this.activities.filter(
+						activity => activity._id !== activityId
+					);
+				} else {
+					new Toast({ content: res.message, timeout: 3000 });
 				}
-			);
-		}
+			});
+		},
+		formatActivity(res, cb) {
+			console.log("activity", res);
+
+			const icons = {
+				created_account: "account_circle",
+				created_station: "radio",
+				deleted_station: "delete",
+				created_playlist: "playlist_add_check",
+				deleted_playlist: "delete_sweep",
+				liked_song: "favorite",
+				added_song_to_playlist: "playlist_add",
+				added_songs_to_playlist: "playlist_add"
+			};
+
+			const activity = {
+				...res,
+				thumbnail: "",
+				message: "",
+				icon: ""
+			};
+
+			const plural = activity.payload.length > 1;
+
+			activity.icon = icons[activity.activityType];
+
+			if (activity.activityType === "created_account") {
+				activity.message = "Welcome to Musare!";
+				return cb(activity);
+			}
+			if (activity.activityType === "created_station") {
+				this.socket.emit(
+					"stations.getStationForActivity",
+					activity.payload[0],
+					res => {
+						if (res.status === "success") {
+							activity.message = `Created the station <strong>${res.data.title}</strong>`;
+							activity.thumbnail = res.data.thumbnail;
+							return cb(activity);
+						}
+						activity.message = "Created a station";
+						return cb(activity);
+					}
+				);
+			}
+			if (activity.activityType === "deleted_station") {
+				activity.message = `Deleted a station`;
+				return cb(activity);
+			}
+			if (activity.activityType === "created_playlist") {
+				this.socket.emit(
+					"playlists.getPlaylistForActivity",
+					activity.payload[0],
+					res => {
+						if (res.status === "success") {
+							activity.message = `Created the playlist <strong>${res.data.title}</strong>`;
+							// activity.thumbnail = res.data.thumbnail;
+							return cb(activity);
+						}
+						activity.message = "Created a playlist";
+						return cb(activity);
+					}
+				);
+			}
+			if (activity.activityType === "deleted_playlist") {
+				activity.message = `Deleted a playlist`;
+				return cb(activity);
+			}
+			if (activity.activityType === "liked_song") {
+				if (plural) {
+					activity.message = `Liked ${activity.payload.length} songs.`;
+					return cb(activity);
+				}
+				this.socket.emit(
+					"songs.getSongForActivity",
+					activity.payload[0],
+					res => {
+						if (res.status === "success") {
+							activity.message = `Liked the song <strong>${res.data.title}</strong>`;
+							activity.thumbnail = res.data.thumbnail;
+							return cb(activity);
+						}
+						activity.message = "Liked a song";
+						return cb(activity);
+					}
+				);
+			}
+			if (activity.activityType === "added_song_to_playlist") {
+				this.socket.emit(
+					"songs.getSongForActivity",
+					activity.payload[0].songId,
+					song => {
+						console.log(song);
+						this.socket.emit(
+							"playlists.getPlaylistForActivity",
+							activity.payload[0].playlistId,
+							playlist => {
+								if (song.status === "success") {
+									if (playlist.status === "success")
+										activity.message = `Added the song <strong>${song.data.title}</strong> to the playlist <strong>${playlist.data.title}</strong>`;
+									else
+										activity.message = `Added the song <strong>${song.data.title}</strong> to a playlist`;
+									activity.thumbnail = song.data.thumbnail;
+									return cb(activity);
+								}
+								if (playlist.status === "success") {
+									activity.message = `Added a song to the playlist <strong>${playlist.data.title}</strong>`;
+									return cb(activity);
+								}
+								activity.message = "Added a song to a playlist";
+								return cb(activity);
+							}
+						);
+					}
+				);
+			}
+			if (activity.activityType === "added_songs_to_playlist") {
+				activity.message = `Added ${activity.payload.length} songs to a playlist`;
+				return cb(activity);
+			}
+			return false;
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
 	}
 };
 </script>
@@ -127,40 +522,250 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
-.container {
-	padding: 25px;
-}
+.info-section {
+	width: 912px;
+	margin-left: auto;
+	margin-right: auto;
+	margin-top: 32px;
+	padding: 24px;
 
-.avatar {
-	border-radius: 50%;
-	width: 250px;
-	display: block;
-	margin: auto;
-}
+	.picture-name-row {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+		justify-content: center;
+		margin-bottom: 24px;
+	}
 
-h5 {
-	text-align: center;
-	margin-bottom: 25px;
-	font-size: 17px;
-}
+	.profile-picture {
+		width: 100px;
+		height: 100px;
+		border-radius: 100%;
+		margin-right: 32px;
+	}
 
-.role {
-	text-transform: capitalize;
-}
+	.name-role-row {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+	}
+
+	.name {
+		font-size: 34px;
+		line-height: 40px;
+		color: $dark-grey-3;
+	}
+
+	.role {
+		padding: 2px 24px;
+		color: $white;
+		text-transform: uppercase;
+		font-size: 12px;
+		line-height: 14px;
+		height: 18px;
+		border-radius: 5px;
+		margin-left: 12px;
+
+		&.admin {
+			background-color: $red;
+		}
+	}
+
+	.username {
+		font-size: 24px;
+		line-height: 28px;
+		color: $dark-grey;
+	}
 
-.level {
-	margin-top: 40px;
+	.buttons {
+		width: 388px;
+		display: flex;
+		flex-direction: row;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 24px;
+
+		.button {
+			flex: 1;
+			font-size: 17px;
+			line-height: 20px;
+
+			&:nth-child(2) {
+				margin-left: 20px;
+			}
+		}
+	}
+
+	.bio-row,
+	.date-location-row {
+		i {
+			font-size: 24px;
+			color: $dark-grey-2;
+			margin-right: 12px;
+		}
+
+		p {
+			font-size: 17px;
+			line-height: 20px;
+			color: $dark-grey-2;
+			word-break: break-word;
+		}
+	}
+
+	.bio-row {
+		max-width: 608px;
+		margin-bottom: 24px;
+		margin-left: auto;
+		margin-right: auto;
+		display: flex;
+		width: max-content;
+	}
+
+	.date-location-row {
+		max-width: 608px;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 24px;
+		display: flex;
+		width: max-content;
+		margin-bottom: 24px;
+
+		> div:nth-child(2) {
+			margin-left: 48px;
+		}
+	}
+
+	.date,
+	.location {
+		display: flex;
+	}
 }
 
-.admin-functionality {
-	text-align: center;
-	margin: 0 auto;
+.bottom-section {
+	width: 962px;
+	margin-left: auto;
+	margin-right: auto;
+	margin-top: 32px;
+	padding: 24px;
+	display: flex;
+
+	.buttons {
+		height: 100%;
+		width: 250px;
+		margin-right: 64px;
+
+		button {
+			outline: none;
+			border: none;
+			box-shadow: none;
+			color: $musareBlue;
+			font-size: 22px;
+			line-height: 26px;
+			padding: 7px 0 7px 12px;
+			width: 100%;
+			text-align: left;
+			cursor: pointer;
+			border-radius: 5px;
+			background-color: transparent;
+
+			&.active {
+				color: $white;
+				background-color: $musareBlue;
+			}
+		}
+	}
+
+	.content {
+		width: 600px;
+
+		.item {
+			width: 100%;
+			height: 72px;
+			border: 0.5px $light-grey-2 solid;
+			margin-bottom: 12px;
+			border-radius: 0 5px 5px 0;
+			display: flex;
+
+			.top-text {
+				color: $dark-grey-2;
+				font-size: 20px;
+				line-height: 23px;
+				margin-bottom: 0;
+			}
+
+			.bottom-text {
+				color: $dark-grey-2;
+				font-size: 16px;
+				line-height: 19px;
+				margin-bottom: 0;
+				margin-top: 6px;
+
+				&:first-letter {
+					text-transform: uppercase;
+				}
+			}
+
+			.thumbnail {
+				position: relative;
+				display: flex;
+				align-items: center;
+				justify-content: center;
+				width: 70.5px;
+				height: 70.5px;
+				background-color: #000;
+
+				img {
+					opacity: 0.4;
+				}
+
+				.activity-type-icon {
+					position: absolute;
+					color: #fff;
+				}
+			}
+
+			.left-part {
+				flex: 1;
+				padding: 12px;
+			}
+
+			.actions {
+				display: flex;
+				align-items: center;
+				padding: 12px;
+
+				.hide-icon {
+					border-bottom: 0;
+					display: flex;
+
+					i {
+						color: #bdbdbd;
+					}
+				}
+			}
+
+			button {
+				font-size: 17px;
+			}
+		}
+	}
+
+	.playlists-tab > button {
+		width: 100%;
+		font-size: 17px;
+	}
 }
 
-@media (max-width: 350px) {
-	.username {
-		font-size: 2.9rem;
-		word-wrap: break-all;
+.night-mode {
+	.name,
+	.username,
+	.bio-row i,
+	.bio-row p,
+	.date-location-row i,
+	.date-location-row p,
+	.item .left-part .top-text,
+	.item .left-part .bottom-text {
+		color: $light-grey;
 	}
 }
 </style>

+ 65 - 50
frontend/components/pages/About.vue

@@ -2,63 +2,68 @@
 	<div class="app">
 		<metadata title="About" />
 		<main-header />
-		<div class="content-wrapper">
-			<div class="card is-fullwidth">
-				<header class="card-header">
-					<p class="card-header-title">
-						The project
-					</p>
-				</header>
-				<div class="card-content">
-					<div class="content">
-						<p>
-							Musare is an open-source music website where you can
-							listen to real-time genre specific music stations,
-							or join community stations created by users.
+		<div class="container">
+			<div class="content-wrapper">
+				<div class="card is-fullwidth">
+					<header class="card-header">
+						<p class="card-header-title">
+							The project
 						</p>
+					</header>
+					<div class="card-content">
+						<div class="content">
+							<p>
+								Musare is an open-source music website where you
+								can listen to real-time genre specific music
+								stations, or join community stations created by
+								users.
+							</p>
+						</div>
 					</div>
 				</div>
-			</div>
-			<div class="card is-fullwidth">
-				<header class="card-header">
-					<p class="card-header-title">
-						How you can help
-					</p>
-				</header>
-				<div class="card-content">
-					<div class="content">
-						<span>
-							There are multiple ways you can help us:
-							<ol>
-								<li>
-									Reporting bugs. No website is perfect, but
-									we try to eliminate as many bugs as
-									possible. If you find a bug, we would highly
-									appreciate it if you could create an issue
-									on the GitHub project with steps to
-									reproduce the issue, so we can fix it as
-									soon as possible.
-								</li>
-								<li>
-									Sending us feedback. Your comments and/or
-									suggestions are extremely valuable to us. In
-									order to improve we need to know what you
-									like, don't like and what you might want on
-									the website.
-								</li>
-								<li>
-									Sharing the joy. The more people enjoying
-									Musare, the better. Telling your friends or
-									relatives about Musare would increase the
-									amount of users we have, which would
-									motivate us and cause Musare to grow faster.
-								</li>
-							</ol>
-						</span>
+				<div class="card is-fullwidth">
+					<header class="card-header">
+						<p class="card-header-title">
+							How you can help
+						</p>
+					</header>
+					<div class="card-content">
+						<div class="content">
+							<span>
+								There are multiple ways you can help us:
+								<ol>
+									<li>
+										Reporting bugs. No website is perfect,
+										but we try to eliminate as many bugs as
+										possible. If you find a bug, we would
+										highly appreciate it if you could create
+										an issue on the GitHub project with
+										steps to reproduce the issue, so we can
+										fix it as soon as possible.
+									</li>
+									<li>
+										Sending us feedback. Your comments
+										and/or suggestions are extremely
+										valuable to us. In order to improve we
+										need to know what you like, don't like
+										and what you might want on the website.
+									</li>
+									<li>
+										Sharing the joy. The more people
+										enjoying Musare, the better. Telling
+										your friends or relatives about Musare
+										would increase the amount of users we
+										have, which would motivate us and cause
+										Musare to grow faster.
+									</li>
+								</ol>
+							</span>
+						</div>
 					</div>
 				</div>
 			</div>
 		</div>
+
 		<main-footer />
 	</div>
 </template>
@@ -80,6 +85,16 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.card {
+		background-color: $night-mode-secondary;
+	}
+
+	p {
+		color: #ddd;
+	}
+}
+
 .card {
 	margin-top: 50px;
 }

+ 36 - 0
frontend/components/pages/Admin.vue

@@ -66,6 +66,18 @@
 						<span>&nbsp;Statistics</span>
 					</router-link>
 				</li>
+				<li
+					:class="{ 'is-active': currentTab == 'newstatistics' }"
+					@click="showTab('newstatistics')"
+				>
+					<router-link
+						class="tab newstatistics"
+						to="/admin/newstatistics"
+					>
+						<i class="material-icons">show_chart</i>
+						<span>&nbsp;New Statistics</span>
+					</router-link>
+				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'punishments' }"
 					@click="showTab('punishments')"
@@ -88,6 +100,7 @@
 		<news v-if="currentTab == 'news'" />
 		<users v-if="currentTab == 'users'" />
 		<statistics v-if="currentTab == 'statistics'" />
+		<new-statistics v-if="currentTab == 'newstatistics'" />
 		<punishments v-if="currentTab == 'punishments'" />
 	</div>
 </template>
@@ -105,6 +118,7 @@ export default {
 		News: () => import("../Admin/News.vue"),
 		Users: () => import("../Admin/Users.vue"),
 		Statistics: () => import("../Admin/Statistics.vue"),
+		NewStatistics: () => import("../Admin/NewStatistics.vue"),
 		Punishments: () => import("../Admin/Punishments.vue")
 	},
 	data() {
@@ -144,6 +158,9 @@ export default {
 				case "/admin/statistics":
 					this.currentTab = "statistics";
 					break;
+				case "/admin/newstatistics":
+					this.currentTab = "newstatistics";
+					break;
 				case "/admin/punishments":
 					this.currentTab = "punishments";
 					break;
@@ -161,6 +178,21 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.tabs {
+		background-color: #333;
+		border: 0;
+
+		ul {
+			border-bottom: 0;
+		}
+	}
+}
+
+.main-container {
+	height: auto;
+}
+
 .tabs {
 	background-color: $white;
 	.queueSongs {
@@ -191,6 +223,10 @@ export default {
 		color: $light-orange;
 		border-color: $light-orange;
 	}
+	.newstatistics {
+		color: $light-orange;
+		border-color: $light-orange;
+	}
 	.punishments {
 		color: $dark-orange;
 		border-color: $dark-orange;

+ 28 - 3
frontend/components/pages/Home.vue

@@ -7,7 +7,7 @@
 				<div class="group-title">
 					Stations&nbsp;
 					<a
-						v-if="$parent.loggedIn"
+						v-if="loggedIn"
 						href="#"
 						@click="
 							openModal({
@@ -115,6 +115,9 @@
 						<span v-else class="songTitle">No song</span>
 					</div>
 				</router-link>
+				<h4 v-if="stations.length === 0">
+					There are no stations to display
+				</h4>
 			</div>
 			<main-footer />
 		</div>
@@ -154,9 +157,9 @@ export default {
 			);
 		},
 		...mapState({
-			modals: state => state.modals.modals.home,
 			loggedIn: state => state.user.auth.loggedIn,
-			userId: state => state.user.auth.userId
+			userId: state => state.user.auth.userId,
+			modals: state => state.modals.modals.home
 		})
 	},
 	mounted() {
@@ -299,6 +302,22 @@ html {
 	}
 }
 
+.night-mode {
+	.card,
+	.card-content,
+	.card-content div {
+		background-color: $night-mode-secondary;
+	}
+
+	.card-content .icons i {
+		color: #ddd;
+	}
+
+	.card-image .image {
+		background-color: #333;
+	}
+}
+
 @media only screen and (min-width: 1200px) {
 	html {
 		font-size: 15px;
@@ -352,6 +371,11 @@ html {
 	}
 }
 
+.app {
+	display: flex;
+	flex-direction: column;
+}
+
 .users-count {
 	font-size: 20px;
 	position: relative;
@@ -360,6 +384,7 @@ html {
 
 .group {
 	min-height: 64px;
+	flex: 1 0 auto;
 }
 
 .station-card {

+ 6 - 0
frontend/components/pages/News.vue

@@ -128,6 +128,12 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	p {
+		color: #ddd;
+	}
+}
+
 .card {
 	margin-top: 50px;
 }

+ 30 - 80
frontend/components/pages/Team.vue

@@ -44,43 +44,6 @@
 				</div>
 			</div>
 			<br />
-			<div class="columns">
-				<div
-					class="card column is-6-desktop is-offset-3-desktop is-12-mobile"
-				>
-					<header class="card-header">
-						<p class="card-header-title">
-							Owen Diffey
-						</p>
-					</header>
-					<div class="card-content">
-						<div class="content">
-							<span class="role"
-								><span class="custom-tag purple"
-									>Project Manager</span
-								>
-								and
-								<span class="custom-tag light-blue"
-									>Developer</span
-								></span
-							>
-							<ul>
-								<li>
-									<b>Joined: </b>
-									February 29, 2016
-								</li>
-								<li>
-									<b>Email: </b>
-									<a href="mailto:owen@musare.com"
-										>&#111;&#119;&#101;&#110;&#064;&#109;&#117;&#115;&#097;&#114;&#101;&#046;&#099;&#111;&#109;</a
-									>
-								</li>
-							</ul>
-						</div>
-					</div>
-				</div>
-			</div>
-			<br />
 			<div class="columns">
 				<div
 					class="card column is-6-desktop is-offset-3-desktop is-12-mobile"
@@ -146,48 +109,18 @@
 					</div>
 				</div>
 			</div>
-			<br />
-			<div class="columns">
-				<div
-					class="card column is-6-desktop is-offset-3-desktop is-12-mobile"
-				>
-					<header class="card-header">
-						<p class="card-header-title">
-							Zachary
-						</p>
-					</header>
-					<div class="card-content">
-						<div class="content">
-							<span class="role"
-								><span class="custom-tag light-blue"
-									>Developer</span
-								></span
-							>
-							<ul>
-								<li>
-									<b>Joined: </b>
-									July 12, 2019
-								</li>
-								<li>
-									<b>Email: </b>
-									<a href="mailto:zachary@musare.com"
-										>&#122;&#97;&#99;&#104;&#97;&#114;&#121;&#64;&#109;&#117;&#115;&#97;&#114;&#101;&#46;&#99;&#111;&#109;</a
-									>
-								</li>
-							</ul>
-						</div>
-					</div>
-				</div>
+			<div id="special-thanks">
+				<h4 class="center">
+					Special Thanks
+				</h4>
+				<br />
+				<p class="center thanks">
+					Special thanks to Owen Diffey, Zachery, Adryd, Cameron
+					Kline, Wesley McCann,
+					<strong>Akira Laine (Co-Founder)</strong>, Johannes Andersen
+					and Aaron Gildea for their contributions to Musare.
+				</p>
 			</div>
-			<h4 class="center">
-				Special Thanks
-			</h4>
-			<br />
-			<p class="center thanks">
-				Special thanks to Adryd, Cameron Kline, Wesley McCann,
-				<strong>Akira Laine (Co-Founder)</strong>, Johannes Andersen and
-				Aaron Gildea for their contributions to Musare.
-			</p>
 		</div>
 		<main-footer />
 	</div>
@@ -205,6 +138,15 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
+.night-mode {
+	.card {
+		background-color: $night-mode-secondary;
+		p {
+			color: #ddd;
+		}
+	}
+}
+
 li a {
 	color: dodgerblue;
 	border-bottom: 0 !important;
@@ -216,6 +158,10 @@ ul {
 	list-style: none;
 }
 
+.columns {
+	margin: 0;
+}
+
 .card-content .content {
 	font-size: 15px;
 }
@@ -251,7 +197,11 @@ ul {
 	border-bottom: 2px $purple solid;
 }
 
-.thanks {
-	font-size: 15px;
+#special-thanks {
+	margin-top: 60px;
+
+	.thanks {
+		font-size: 15px;
+	}
 }
 </style>

+ 1 - 1
frontend/components/pages/Terms.vue

@@ -2,7 +2,7 @@
 	<div class="app">
 		<metadata title="Terms of Service" />
 		<main-header />
-		<div class="content-wrapper">
+		<div class="container">
 			<h1>MUSARE TERMS OF SERVICE</h1>
 			<h4>Last Updated: January 25, 2016</h4>
 

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff